hermoso 0.1.410 → 0.1.413
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 +5 -0
- package/bin/hermoso.mjs +16 -0
- package/mcp/client.mjs +17 -0
- package/mcp/tools.mjs +397 -120
- package/package.json +2 -2
- package/skills/hermoso-ad-from-brand/SKILL.md +1 -1
- package/skills/hermoso-generate/SKILL.md +1 -1
- package/skills/hermoso-marketing/SKILL.md +1 -1
- package/skills/hermoso-product-photoshoot/SKILL.md +1 -1
- package/skills/hermoso-research/SKILL.md +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -15,7 +15,15 @@ import { wellFormedValue, wellFormedString } from './well-formed.mjs';
|
|
|
15
15
|
// WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
|
|
16
16
|
// re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
|
|
17
17
|
// twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
|
|
18
|
-
import { toolHeldBackByConnectors, toolProvider, toolUnoffered, toolWithheldByServer, metaAlternativeNote } from './roster-scope.mjs';
|
|
18
|
+
import { toolHeldBackByConnectors as heldBackByConnectorsOf, toolProvider as providerOf, toolUnoffered, toolWithheldByServer as withheldByServerOf, metaAlternativeNote as metaAlternativeNoteOf } from './roster-scope.mjs';
|
|
19
|
+
// A tool a widget host lists under ANOTHER name (a WIDGET_HOST_RENAMES alias, or one operation of a WIDGET_HOST_SPLITS
|
|
20
|
+
// tool) is gated, attributed and explained as the tool it comes from: same connector, same Instagram-Login exemption,
|
|
21
|
+
// same server withhold. Function declarations so they are hoisted; the tables they read are only consulted at call time.
|
|
22
|
+
function canonicalToolName(name) { const n = String(name || ''); return WIDGET_SPLIT_OF[n] || WIDGET_HOST_ALIAS_OF[n] || n; }
|
|
23
|
+
function toolHeldBackByConnectors(name, conn) { return heldBackByConnectorsOf(canonicalToolName(name), conn); }
|
|
24
|
+
function toolProvider(name) { return providerOf(canonicalToolName(name)); }
|
|
25
|
+
function toolWithheldByServer(name, conn) { return withheldByServerOf(canonicalToolName(name), conn); }
|
|
26
|
+
function metaAlternativeNote(name) { return metaAlternativeNoteOf(canonicalToolName(name)); }
|
|
19
27
|
// WHAT A TOOL COSTS AND WHETHER IT IS WORKING — the two facts find_tools puts on every row beside the parameters.
|
|
20
28
|
// Same twin-safe specifier rule as the two imports above: no lib/, no repo, no relative escape.
|
|
21
29
|
import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
|
|
@@ -247,7 +255,7 @@ export const CAPABILITY_MAP = [
|
|
|
247
255
|
'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).',
|
|
248
256
|
'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.',
|
|
249
257
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
|
|
250
|
-
'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, and until now the one platform Hermoso could not moderate. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this 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. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
258
|
+
'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. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
251
259
|
'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.',
|
|
252
260
|
].join('\n');
|
|
253
261
|
|
|
@@ -2188,6 +2196,49 @@ export const CORE_FIRST_HEADLINE = Object.freeze([
|
|
|
2188
2196
|
'schedule_post', 'list_library', 'upload_file', 'post_performance', 'analyze_campaigns',
|
|
2189
2197
|
]);
|
|
2190
2198
|
export const CORE_FIRST_EXTRA = Object.freeze(['get_brand', 'get_job', 'list_jobs', 'list_connectors', 'list_scheduled', ...CORE_FIRST_HEADLINE]);
|
|
2199
|
+
// CHATGPT CARRIES MORE THAN THE SHORT LIST (2026-10-16, the owner: "eventually make all the features available"). A widget
|
|
2200
|
+
// host may not use the generic executor (OpenAI's rule, EXECUTOR_TOOLS), so a tool ChatGPT does not LIST, it cannot
|
|
2201
|
+
// reach at all: publishing straight to one channel, managing the schedule and running the brand's own campaigns
|
|
2202
|
+
// were simply absent there. They ride in the ChatGPT roster by name. Connector-gated ones still list only for an
|
|
2203
|
+
// account that has connected that provider. Each new name goes through OpenAI's own scan before it is live.
|
|
2204
|
+
export const WIDGET_HOST_EXTRA = Object.freeze([
|
|
2205
|
+
'post_to_meta', 'post_to_x', 'post_to_linkedin', 'post_to_linkedin_page', 'post_to_tiktok', 'post_to_youtube',
|
|
2206
|
+
'post_to_pinterest', 'post_to_bluesky', 'post_to_telegram', 'post_to_google_business',
|
|
2207
|
+
'reschedule_post', 'cancel_scheduled', 'list_connector_accounts', 'search_meta_ads',
|
|
2208
|
+
'list_meta_ads', 'meta_insights', 'set_meta_campaign_status',
|
|
2209
|
+
'list_google_ads_campaigns', 'google_ads_report', 'set_google_ads_status',
|
|
2210
|
+
// Second batch (2026-10-16): build, run and report campaigns on every ad platform, the inbox and comments,
|
|
2211
|
+
// and channel analytics, plus the account/page/targeting lookups those tools name as their inputs.
|
|
2212
|
+
'create_meta_campaign', 'create_meta_adset', 'create_meta_ad', 'upload_meta_asset', 'update_meta_object',
|
|
2213
|
+
'preview_meta_ad', 'estimate_meta_reach', 'list_meta_audiences', 'create_meta_audience',
|
|
2214
|
+
'create_google_ads_campaign', 'create_google_ads_ad_group', 'create_google_ads_ad', 'set_google_ads_budget',
|
|
2215
|
+
'list_tiktok_ads_campaigns', 'tiktok_ads_report', 'create_tiktok_ads_campaign', 'create_tiktok_ads_ad_group',
|
|
2216
|
+
'create_tiktok_ads_ad', 'set_tiktok_ads_status', 'set_tiktok_ads_budget', 'list_linkedin_ads_campaigns',
|
|
2217
|
+
'linkedin_ads_report', 'create_linkedin_ads_campaign_group', 'create_linkedin_ads_campaign',
|
|
2218
|
+
'create_linkedin_ads_creative', 'set_linkedin_ads_status', 'set_linkedin_ads_budget',
|
|
2219
|
+
'list_pinterest_ads_campaigns', 'pinterest_ads_report', 'create_pinterest_ads_campaign',
|
|
2220
|
+
'create_pinterest_ads_ad_group', 'create_pinterest_ads_ad', 'set_pinterest_ads_status',
|
|
2221
|
+
'set_pinterest_ads_budget', 'list_reddit_ads_campaigns', 'reddit_ads_report', 'create_reddit_ads_campaign',
|
|
2222
|
+
'create_reddit_ads_ad_group', 'create_reddit_ads_ad', 'set_reddit_ads_status', 'list_microsoft_ads_campaigns',
|
|
2223
|
+
'microsoft_ads_report', 'create_microsoft_ads_campaign', 'create_microsoft_ads_ad_group',
|
|
2224
|
+
'create_microsoft_ads_ad', 'set_microsoft_ads_status', 'set_microsoft_ads_budget', 'list_openai_ads_campaigns',
|
|
2225
|
+
'openai_ads_report', 'create_openai_ads_campaign', 'create_openai_ads_ad_group', 'create_openai_ads_ad',
|
|
2226
|
+
'set_openai_ads_status', 'set_openai_ads_budget', 'list_snapchat_ads_campaigns', 'snapchat_ads_report',
|
|
2227
|
+
'create_snapchat_ads_campaign', 'create_snapchat_ads_ad_squad', 'create_snapchat_ads_ad',
|
|
2228
|
+
'set_snapchat_ads_status', 'set_snapchat_ads_budget', 'list_x_ads_campaigns', 'x_ads_report',
|
|
2229
|
+
'create_x_ads_campaign', 'create_x_ads_line_item', 'create_x_ads_promoted_tweet', 'set_x_ads_status',
|
|
2230
|
+
'list_inbox', 'reply_to_inbox_item', 'list_meta_comments', 'reply_to_meta_comment', 'list_youtube_comments',
|
|
2231
|
+
'reply_to_youtube_comment', 'list_tiktok_comments', 'reply_to_tiktok_comment', 'instagram_insights',
|
|
2232
|
+
'meta_page_insights', 'meta_post_insights', 'youtube_channel_report', 'tiktok_account_insights',
|
|
2233
|
+
'linkedin_page_analytics', 'analytics_report', 'list_meta_pages', 'list_meta_posts',
|
|
2234
|
+
'list_meta_lead_forms', 'upload_google_ads_asset', 'list_tiktok_ads_accounts', 'list_tiktok_ads_identities',
|
|
2235
|
+
'upload_tiktok_ads_creative', 'search_tiktok_ads_targeting', 'search_linkedin_ads_targeting',
|
|
2236
|
+
'list_linkedin_pages', 'search_reddit_ads_targeting', 'list_reddit_ads_posts', 'create_reddit_ads_post',
|
|
2237
|
+
'microsoft_ads_geo_search', 'openai_ads_geo_search', 'list_openai_ads_audiences', 'list_snapchat_ads_accounts',
|
|
2238
|
+
'search_snapchat_ads_targeting', 'upload_snapchat_ads_creative', 'list_x_ads_accounts',
|
|
2239
|
+
'set_connector_accounts', 'youtube_channel', 'x_post_metrics', 'list_analytics_properties',
|
|
2240
|
+
'moderate_meta_comment', 'list_brands',
|
|
2241
|
+
]);
|
|
2191
2242
|
// Hosts that have been SEEN to follow find_tools → call_tool. A name that does not match keeps the full roster.
|
|
2192
2243
|
// WIDENED 2026-09-25 ON EVIDENCE: Gemini CLI (0.60, clientInfo "gemini-cli-mcp-client"), Grok (clientInfo "grok", UA
|
|
2193
2244
|
// grok-connectors-manager) and Mistral Le Chat / Vibe (clientInfo "mcp", so it is recognised by its UA MistralAI-MCPClient)
|
|
@@ -2244,7 +2295,15 @@ export function parseToolScope(raw) {
|
|
|
2244
2295
|
// `delete_brand` JOINED THEM 2026-09-30 (owner's call, for the 1.0.0 review): destroying a whole workspace is a heavy,
|
|
2245
2296
|
// irreversible action a reviewer has to reason about, and it is always one click away in the Hermoso app. Every other
|
|
2246
2297
|
// surface keeps it.
|
|
2247
|
-
|
|
2298
|
+
// connect_connector takes a pasted key in the conversation, which ChatGPT's review refuses (connect in the app instead);
|
|
2299
|
+
// x_post_insights / x_post_insights_historical need X analytics access our X app does not hold today, so ChatGPT's scan
|
|
2300
|
+
// read their descriptions as claiming what the tool cannot do (2026-10-18). x_post_metrics answers there.
|
|
2301
|
+
// fetch_social_data is a generic proxy over allowlisted vendor paths, which OpenAI's scan held as operations "not
|
|
2302
|
+
// individually exposed" (2026-10-18); see WIDGET_HOST_SPLITS for why it is withheld rather than split.
|
|
2303
|
+
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']);
|
|
2304
|
+
// The withheld tools whose refusal is the generic-executor sentence rather than the commerce one.
|
|
2305
|
+
const WIDGET_GENERIC_PROXIES = new Set(['fetch_social_data']);
|
|
2306
|
+
const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIES.has(name);
|
|
2248
2307
|
// ── WHAT A WIDGET HOST (CHATGPT) LISTS, AFTER OPENAI'S AUTOMATED PLUGIN SCAN (2026-09-30) ──────────────────────────
|
|
2249
2308
|
// Two rules from developers.openai.com/plugins/plugin-guidelines.md, both HOST rules: every other surface keeps today's
|
|
2250
2309
|
// roster exactly.
|
|
@@ -2268,10 +2327,147 @@ export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools'
|
|
|
2268
2327
|
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand' });
|
|
2269
2328
|
export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
|
|
2270
2329
|
export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand' });
|
|
2330
|
+
// ── ONE OPERATION, ONE TOOL, ON A WIDGET HOST (2026-10-18) ─────────────────────────────────────────────────────
|
|
2331
|
+
// OpenAI's daily scan HELD 18 tools that take an `action` enum and do different things per value: "This tool appears
|
|
2332
|
+
// to execute operations that are not individually exposed. Please expose each model-callable operation upfront as a
|
|
2333
|
+
// separate tool with a clear description, input schema, and annotations." Rule 1 above, one level down.
|
|
2334
|
+
// So a widget host lists each OPERATION as its own tool, generated from the multi-op tool: the op's own name, the
|
|
2335
|
+
// params that op reads (a trailing `!` makes one required there), its own hints, and a handler that calls the
|
|
2336
|
+
// ORIGINAL handler with the operation fixed — so the behaviour, the server route, the billing and the error ledger
|
|
2337
|
+
// (which still reads the canonical name) are the multi-op tool's own. The multi-op tool is hidden on a widget host
|
|
2338
|
+
// (a direct call from an older snapshot still answers), and every other host keeps it exactly as it is: these op tools
|
|
2339
|
+
// are registered per widget-host session and never into the canon, like the aliases above.
|
|
2340
|
+
// fetch_social_data is NOT split: it is a generic proxy over ~100 allowlisted vendor paths, i.e. the generic executor
|
|
2341
|
+
// the same rule forbids, so it is withheld on a widget host (WITHHELD_FROM_WIDGET_HOSTS) and the dedicated search_* /
|
|
2342
|
+
// profile tools (search_tiktok, search_instagram, search_instagram_hashtag, instagram_profile, search_youtube,
|
|
2343
|
+
// search_threads, threads_profile, search_x, x_user, the ad-library searches) are what ChatGPT uses instead.
|
|
2344
|
+
// Hints follow app-review.md: readOnly for a read; destructive for a delete, anything hard to reverse, or a change to
|
|
2345
|
+
// something the public already sees (hide, re-order, overwrite); additive creation is not destructive.
|
|
2346
|
+
const SPLIT_RO = Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true });
|
|
2347
|
+
const splitW = (destructiveHint, idempotentHint) => Object.freeze({ readOnlyHint: false, destructiveHint, idempotentHint, openWorldHint: true });
|
|
2348
|
+
const splitOp = (name, set, params, ann, title, description, desc) => Object.freeze({ name, set: Object.freeze(set), params: Object.freeze(params), ann, title, description, ...(desc ? { desc: Object.freeze(desc) } : {}) });
|
|
2349
|
+
const X_BILLED = ' Needs X connected; X bills per call, so it costs credits.';
|
|
2350
|
+
const META_ACCT = ['account', 'pageId'];
|
|
2351
|
+
export const WIDGET_HOST_SPLITS = Object.freeze({
|
|
2352
|
+
moderate_meta_comment: [
|
|
2353
|
+
splitOp('hide_meta_comment', { action: 'hide' }, ['commentId!', ...META_ACCT], splitW(true, true), 'Hide a Facebook or Instagram comment', 'Use this when a comment on the brand’s own Facebook or Instagram post should be hidden from the public. Reversible with unhide_meta_comment. Get commentId from list_meta_comments. Free.'),
|
|
2354
|
+
splitOp('unhide_meta_comment', { action: 'unhide' }, ['commentId!', ...META_ACCT], splitW(true, true), 'Unhide a Facebook or Instagram comment', 'Use this when a comment the brand hid on its own Facebook or Instagram post should be visible again. Get commentId from list_meta_comments. Free.'),
|
|
2355
|
+
splitOp('delete_meta_comment', { action: 'delete' }, ['commentId!', 'confirm', ...META_ACCT], splitW(true, true), 'Delete a Facebook or Instagram comment', 'Use this when a comment on the brand’s own Facebook or Instagram post must be removed for good. PERMANENT: pass confirm:true only after the user agreed; hide_meta_comment is the reversible option. Free.', { confirm: 'must be true to delete; get the user’s yes first' }),
|
|
2356
|
+
],
|
|
2357
|
+
manage_meta_post: [
|
|
2358
|
+
splitOp('edit_facebook_post', { action: 'edit', target: 'facebook' }, ['brand', 'postId!', 'message!', 'pageId'], splitW(true, true), 'Edit the text of a published Facebook post', 'Use this when the text of a Facebook post the brand already published needs changing. Replaces the post’s message in public. Instagram and Threads posts cannot be edited after publishing. Get postId from list_meta_posts. Free.', { message: 'the new post text' }),
|
|
2359
|
+
splitOp('delete_meta_post', { action: 'delete' }, ['brand', 'postId!', 'target', 'pageId', 'confirm', 'confirmName', 'confirmChildren'], splitW(true, true), 'Delete a published Facebook, Instagram or Threads post', 'Use this when a post the brand published on Facebook, Instagram or Threads must be removed. PERMANENT. For Instagram call it first WITHOUT confirm: nothing is deleted and the reply shows the post’s caption, likes, comments and carousel cards; show the user that, then call again with confirm:true (plus confirmName / confirmChildren when the reply asks). An Instagram carousel is deleted as a whole album. Get postId from list_meta_posts or list_instagram_media. Free.', { confirm: 'must be true to delete (permanent)' }),
|
|
2360
|
+
splitOp('set_facebook_video_cover', { action: 'cover', target: 'facebook' }, ['brand', 'postId!', 'coverAtMs', 'coverImageUrl', 'videoUrl', 'pageId'], splitW(true, true), 'Replace the cover of a published Facebook video or Reel', 'Use this when a Facebook video or Reel the brand already published needs a new cover: a frame (coverAtMs) or a Hermoso-hosted picture (coverImageUrl). postId is the video id. Instagram covers cannot be changed after publishing. Free.'),
|
|
2361
|
+
],
|
|
2362
|
+
set_youtube_watermark: [
|
|
2363
|
+
splitOp('add_youtube_watermark', { action: 'set' }, ['imageUrl!', 'timingType', 'offsetMs', 'durationMs'], splitW(true, true), 'Set the YouTube branding watermark', 'Use this when the brand wants its small subscribe badge shown on EVERY video on the connected YouTube channel, including later uploads. Square image, at least 150x150, under 10MB. Replaces any watermark already set. YouTube has no read-back, so the reply says accepted, not confirmed. Free.'),
|
|
2364
|
+
splitOp('remove_youtube_watermark', { action: 'unset' }, [], splitW(true, true), 'Remove the YouTube branding watermark', 'Use this when the branding watermark should come off every video on the connected YouTube channel. Free.'),
|
|
2365
|
+
],
|
|
2366
|
+
manage_youtube_playlist: [
|
|
2367
|
+
splitOp('create_youtube_playlist', { action: 'create' }, ['title!', 'description', 'privacy'], splitW(false, false), 'Create a YouTube playlist', 'Use this when the brand wants a new playlist on its YouTube channel. It is PRIVATE unless privacy says otherwise. Add videos with add_video_to_youtube_playlist. Free.', { title: 'the playlist title' }),
|
|
2368
|
+
splitOp('update_youtube_playlist', { action: 'update' }, ['playlistId!', 'title', 'description', 'privacy'], splitW(true, true), 'Rename or change a YouTube playlist', 'Use this when a playlist’s title, description or privacy should change. An omitted field is left as it is. Setting privacy to private takes a playlist out of public view without deleting it. Get playlistId from list_youtube_playlists. Free.', { playlistId: 'from list_youtube_playlists' }),
|
|
2369
|
+
splitOp('delete_youtube_playlist', { action: 'delete' }, ['playlistId!', 'confirm', 'confirmTitle'], splitW(true, true), 'Delete a YouTube playlist', 'Use this when a playlist must be removed for good. The videos stay; the playlist, its order and every link to it go. Needs confirm:true, and for a public or non-empty playlist confirmTitle with its exact title. Free.', { playlistId: 'from list_youtube_playlists', confirm: 'must be true to delete' }),
|
|
2370
|
+
],
|
|
2371
|
+
manage_youtube_playlist_items: [
|
|
2372
|
+
splitOp('add_video_to_youtube_playlist', { action: 'add' }, ['playlistId!', 'videoId!', 'position'], splitW(false, false), 'Add a video to a YouTube playlist', 'Use this when a video should be added to one of the channel’s playlists. Returns the new entry’s itemId. position is zero-based; omit it to append. Free.', { playlistId: 'from list_youtube_playlists', videoId: 'the YouTube video id (list_youtube_videos)' }),
|
|
2373
|
+
splitOp('remove_youtube_playlist_item', { action: 'remove' }, ['itemId!'], splitW(true, true), 'Remove an entry from a YouTube playlist', 'Use this when one entry should come out of a playlist. Takes the entry’s itemId (from list_youtube_playlists with a playlistId), not the videoId. The video itself is not deleted. Free.'),
|
|
2374
|
+
splitOp('move_youtube_playlist_item', { action: 'move' }, ['itemId!', 'position!'], splitW(true, true), 'Re-order an entry in a YouTube playlist', 'Use this when an entry should move to a new position in its playlist. Takes the itemId (from list_youtube_playlists with a playlistId); position is zero-based. Free.', { position: 'zero-based new position' }),
|
|
2375
|
+
],
|
|
2376
|
+
manage_youtube_channel_section: [
|
|
2377
|
+
splitOp('list_youtube_channel_sections', { action: 'list' }, ['hl'], SPLIT_RO, 'List the YouTube channel homepage sections', 'Use this when you need the shelves on the connected channel’s homepage, in order, with their ids. YouTube’s list can lag a recent change by a few seconds. Read-only, free.'),
|
|
2378
|
+
splitOp('create_youtube_channel_section', { action: 'create' }, ['type!', 'title', 'playlists', 'channels', 'position', 'style'], splitW(false, false), 'Add a YouTube channel homepage section', 'Use this when the brand wants a new shelf on its channel homepage. Public immediately. singlePlaylist and multiplePlaylists take playlist ids, multipleChannels takes channel ids; only multiplePlaylists and multipleChannels take a title. Free.'),
|
|
2379
|
+
splitOp('update_youtube_channel_section', { action: 'update' }, ['sectionId!', 'type!', 'title', 'playlists', 'channels', 'position', 'style'], splitW(true, true), 'Replace a YouTube channel homepage section', 'Use this when an existing homepage shelf should change or move. It REPLACES the section with what you pass, publicly and immediately. Get sectionId from list_youtube_channel_sections. Free.'),
|
|
2380
|
+
splitOp('delete_youtube_channel_section', { action: 'delete' }, ['sectionId!', 'confirm'], splitW(true, true), 'Delete a YouTube channel homepage section', 'Use this when a homepage shelf should be removed. There is no undo; the layout would have to be rebuilt. Needs confirm:true. Get sectionId from list_youtube_channel_sections. Free.', { confirm: 'must be true to delete' }),
|
|
2381
|
+
],
|
|
2382
|
+
manage_youtube_playlist_image: [
|
|
2383
|
+
splitOp('list_youtube_playlist_images', { action: 'list' }, ['playlistId!'], SPLIT_RO, 'List a YouTube playlist’s cover images', 'Use this when you need the custom cover image on a playlist and its imageId. Read-only, free.'),
|
|
2384
|
+
splitOp('set_youtube_playlist_image', { action: 'set' }, ['playlistId!', 'imageUrl!'], splitW(true, true), 'Set a YouTube playlist’s cover image', 'Use this when a playlist should get a custom cover. imageUrl must be Hermoso-hosted (a render, a make_thumbnail result, or the user’s own file through upload_file). Needs a verified YouTube channel. Free.', { imageUrl: 'a Hermoso-hosted image URL' }),
|
|
2385
|
+
splitOp('delete_youtube_playlist_image', { action: 'delete' }, ['playlistId!', 'imageId!'], splitW(true, true), 'Remove a YouTube playlist’s cover image', 'Use this when a playlist’s custom cover should be removed, so YouTube shows its first video’s thumbnail again. Get imageId from list_youtube_playlist_images. Free.', { imageId: 'from list_youtube_playlist_images' }),
|
|
2386
|
+
],
|
|
2387
|
+
manage_youtube_caption: [
|
|
2388
|
+
splitOp('upload_youtube_caption', { action: 'upload' }, ['videoId!', 'text!', 'language!', 'name', 'draft'], splitW(false, false), 'Upload a caption track to a YouTube video', 'Use this when a video on the connected channel needs a subtitle track. Pass the SRT or WebVTT file body as text, not a URL, and the real language code (never guessed). draft:true keeps it unpublished for review. Free.', { text: 'the caption file body, SRT or WebVTT', language: 'BCP-47 code such as en, es, pt-BR' }),
|
|
2389
|
+
splitOp('update_youtube_caption', { action: 'update' }, ['captionId!', 'videoId', 'text', 'name', 'draft'], splitW(true, true), 'Replace a YouTube caption track', 'Use this when an uploaded caption track needs new text, a new name or its draft state changed. Tracks YouTube generated itself cannot be edited. Get captionId from list_youtube_captions. Free.', { captionId: 'from list_youtube_captions', text: 'the replacement caption body, SRT or WebVTT' }),
|
|
2390
|
+
splitOp('delete_youtube_caption', { action: 'delete' }, ['captionId!', 'videoId', 'confirm'], splitW(true, true), 'Delete a YouTube caption track', 'Use this when a caption track should be removed. Viewers lose it immediately. Needs confirm:true. Tracks YouTube generated itself cannot be deleted. Get captionId from list_youtube_captions. Free.', { captionId: 'from list_youtube_captions', confirm: 'must be true to delete' }),
|
|
2391
|
+
],
|
|
2392
|
+
moderate_youtube_comment: [
|
|
2393
|
+
splitOp('approve_youtube_comment', { action: 'publish' }, ['commentId!'], splitW(true, true), 'Make a YouTube comment visible again', 'Use this when a held or rejected comment on the connected channel should be shown publicly again. Get commentId from list_youtube_comments. Free.'),
|
|
2394
|
+
splitOp('hold_youtube_comment', { action: 'hold' }, ['commentId!'], splitW(true, true), 'Hold a YouTube comment for review', 'Use this when a comment should be hidden while it waits for review. Reversible with approve_youtube_comment. Free.'),
|
|
2395
|
+
splitOp('reject_youtube_comment', { action: 'reject' }, ['commentId!', 'banAuthor'], splitW(true, true), 'Reject a YouTube comment', 'Use this when a comment should be hidden as unfit for display. Reversible with approve_youtube_comment. banAuthor:true also auto-rejects every future comment from that author on the channel. Free.'),
|
|
2396
|
+
splitOp('report_youtube_comment_spam', { action: 'spam' }, ['commentId!'], splitW(true, true), 'Report a YouTube comment as spam', 'Use this when a comment is spam. It is a REPORT to YouTube, not a removal, so do not tell the user the comment is gone. Free.'),
|
|
2397
|
+
splitOp('delete_youtube_comment', { action: 'delete' }, ['commentId!', 'confirm'], splitW(true, true), 'Delete a YouTube comment', 'Use this when a comment must be removed for good; YouTube has no undelete. Needs confirm:true. To only take it out of public view use reject_youtube_comment. Free.', { confirm: 'must be true to delete' }),
|
|
2398
|
+
],
|
|
2399
|
+
manage_x_broadcast: [
|
|
2400
|
+
splitOp('schedule_x_broadcast', { action: 'schedule' }, ['sourceId!', 'startAt!', 'manualPublish!', 'endAt', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', 'recurrence', 'confirm'], splitW(false, false), 'Schedule an X live broadcast', 'Use this when the brand wants to schedule an X live video broadcast. sourceId is the stream key from Media Studio > Producer on x.com. manualPublish is required: true waits for start_x_broadcast; false makes X put the brand on air automatically at startAt and needs confirm:true.' + X_BILLED),
|
|
2401
|
+
splitOp('update_x_broadcast', { action: 'update' }, ['id!', 'startAt', 'endAt', 'manualPublish', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', 'recurrence', 'confirm'], splitW(true, true), 'Change a scheduled X broadcast', 'Use this when a scheduled X broadcast needs a new time, title or setting. Only the fields you pass change. Turning manualPublish off needs confirm:true.' + X_BILLED),
|
|
2402
|
+
splitOp('start_x_broadcast', { action: 'go_live' }, ['id!', 'confirm'], splitW(true, false), 'Go live on a scheduled X broadcast', 'Use this when a broadcast scheduled with manualPublish:true should go on air NOW, publicly, as the brand. Needs confirm:true after the user approved the exact broadcast and stream.' + X_BILLED, { confirm: 'must be true: this goes on air publicly' }),
|
|
2403
|
+
splitOp('cancel_x_broadcast', { action: 'cancel' }, ['id!', 'rollForward', 'confirm'], splitW(true, true), 'Cancel a scheduled X broadcast', 'Use this when a scheduled X broadcast should be deleted. Cannot be undone; needs confirm:true. rollForward:true shifts a recurring series instead of leaving a gap.' + X_BILLED, { confirm: 'must be true to cancel' }),
|
|
2404
|
+
],
|
|
2405
|
+
moderate_x_broadcast: [
|
|
2406
|
+
splitOp('remove_x_broadcast_chat_message', { action: 'remove_message' }, ['broadcastId!', 'messageId!', 'confirm'], splitW(true, true), 'Remove a message from an X broadcast chat', 'Use this when one message in the brand’s live broadcast chat must be removed. Cannot be undone; needs confirm:true. Get messageId from read_x_broadcast_chat.' + X_BILLED, { confirm: 'must be true to remove' }),
|
|
2407
|
+
splitOp('mute_x_broadcast_chat_user', { action: 'mute' }, ['broadcastId!', 'userId', 'username', 'until', 'messageId'], splitW(true, true), 'Mute someone in an X broadcast chat', 'Use this when a viewer should be muted in the brand’s live broadcast chat. Pass userId or username; until ends a timeout (omit to mute for the broadcast); messageId also removes that message.' + X_BILLED),
|
|
2408
|
+
splitOp('unmute_x_broadcast_chat_user', { action: 'unmute' }, ['broadcastId!', 'userId', 'username'], splitW(true, true), 'Unmute someone in an X broadcast chat', 'Use this when a muted viewer may chat again in the brand’s live broadcast. Pass userId or username.' + X_BILLED),
|
|
2409
|
+
splitOp('list_x_broadcast_chat_moderators', { action: 'list_moderators' }, [], SPLIT_RO, 'List the X broadcast chat moderators', 'Use this when you need the brand’s persistent X broadcast chat moderators, who moderate every current and future broadcast. Read-only.' + X_BILLED),
|
|
2410
|
+
splitOp('add_x_broadcast_chat_moderator', { action: 'add_moderator' }, ['userId', 'username'], splitW(false, true), 'Add an X broadcast chat moderator', 'Use this when someone should moderate the chat on every current and future broadcast of the brand. Pass userId or username.' + X_BILLED),
|
|
2411
|
+
splitOp('remove_x_broadcast_chat_moderator', { action: 'remove_moderator' }, ['userId', 'username'], splitW(true, true), 'Remove an X broadcast chat moderator', 'Use this when someone should stop moderating the brand’s broadcast chats. Pass userId or username.' + X_BILLED),
|
|
2412
|
+
],
|
|
2413
|
+
manage_x_chat: [
|
|
2414
|
+
splitOp('mute_x_chat', { action: 'mute' }, ['id!'], splitW(false, true), 'Mute an X Chat conversation', 'Use this when one of the brand’s X Chat conversations should stop notifying it. Only the brand’s own view changes. Get id from list_x_chats.' + X_BILLED),
|
|
2415
|
+
splitOp('unmute_x_chat', { action: 'unmute' }, ['id!'], splitW(false, true), 'Unmute an X Chat conversation', 'Use this when a muted X Chat conversation should notify the brand again. Get id from list_x_chats.' + X_BILLED),
|
|
2416
|
+
splitOp('delete_x_chat', { action: 'delete' }, ['id!', 'confirm'], splitW(true, true), 'Delete an X Chat conversation from the inbox', 'Use this when a conversation should leave the brand’s X inbox for good; the other people keep theirs. Needs confirm:true.' + X_BILLED, { confirm: 'must be true to delete' }),
|
|
2417
|
+
splitOp('set_x_chat_disappearing_messages', { action: 'set_disappearing' }, ['id!', 'disappearAfter!'], splitW(true, true), 'Turn on disappearing messages in an X Chat', 'Use this when messages in a conversation should disappear after a time, for everyone in it. disappearAfter like 24h, 7d, 90m or a number of seconds.' + X_BILLED),
|
|
2418
|
+
splitOp('clear_x_chat_disappearing_messages', { action: 'clear_disappearing' }, ['id!'], splitW(true, true), 'Turn off disappearing messages in an X Chat', 'Use this when messages in a conversation should stop disappearing, for everyone in it.' + X_BILLED),
|
|
2419
|
+
splitOp('set_x_chat_screenshot_setting', { action: 'screen_capture' }, ['id!', 'blockScreenshots', 'notifyOnScreenshot'], splitW(true, true), 'Change the screenshot setting of an X Chat', 'Use this when screenshots in a conversation should be blocked or announced, for everyone in it. Pass blockScreenshots OR notifyOnScreenshot, one per call.' + X_BILLED),
|
|
2420
|
+
splitOp('set_x_chat_admin_settings', { action: 'admin_settings' }, ['id!', 'adminOnly!'], splitW(true, true), 'Change which X Chat group actions are admin-only', 'Use this when a group the brand admins should limit actions to admins: adminOnly {addMember, editGroupInfo, sendMessage, startCall, editMessageTtl, blockScreenCapture}. sendMessage:true makes it announcement-only. Group ids start with g.' + X_BILLED),
|
|
2421
|
+
splitOp('add_x_chat_admins', { action: 'add_admins' }, ['id!', 'userIds!'], splitW(false, true), 'Make people admins of an X Chat group', 'Use this when members of a group the brand admins should become admins. userIds are numeric X account ids.' + X_BILLED),
|
|
2422
|
+
splitOp('remove_x_chat_admins', { action: 'remove_admins' }, ['id!', 'userIds!'], splitW(true, true), 'Remove admins from an X Chat group', 'Use this when people should stop being admins of a group the brand admins. userIds are numeric X account ids.' + X_BILLED),
|
|
2423
|
+
splitOp('remove_x_chat_members', { action: 'remove_members' }, ['id!', 'userIds!', 'confirm'], splitW(true, true), 'Remove members from an X Chat group', 'Use this when people should be removed from a group the brand admins. Needs confirm:true. userIds are numeric X account ids.' + X_BILLED, { confirm: 'must be true to remove them' }),
|
|
2424
|
+
splitOp('leave_x_chat', { action: 'leave' }, ['id!', 'confirm'], splitW(true, true), 'Leave an X Chat group', 'Use this when the brand should leave a group conversation. Needs confirm:true.' + X_BILLED, { confirm: 'must be true to leave' }),
|
|
2425
|
+
splitOp('approve_x_chat_admin_request', { action: 'approve_admin_request' }, ['id!', 'userId!'], splitW(false, true), 'Approve a request to be an X Chat group admin', 'Use this when someone who asked to be an admin of a group the brand admins should be approved. list_x_chats view admin_requests lists the requests.' + X_BILLED),
|
|
2426
|
+
splitOp('reject_x_chat_admin_request', { action: 'reject_admin_request' }, ['id!', 'userId!'], splitW(true, true), 'Reject a request to be an X Chat group admin', 'Use this when a request to become admin of a group the brand admins should be turned down.' + X_BILLED),
|
|
2427
|
+
splitOp('reject_x_chat_join_requests', { action: 'reject_join_requests' }, ['id!', 'userIds!'], splitW(true, true), 'Reject requests to join an X Chat group', 'Use this when people waiting to join a group the brand admins should be turned away. Approving a join is done in the X app.' + X_BILLED),
|
|
2428
|
+
splitOp('enable_x_chat_invite_link', { action: 'enable_invite' }, ['id!', 'title', 'avatarUrl', 'welcomeMessage', 'expiresAt'], splitW(false, false), 'Turn on an X Chat group invite link', 'Use this when a group the brand admins should get a shareable invite link. X stores the preview (title, avatar, welcome message) unencrypted and shows it to anyone with the link.' + X_BILLED),
|
|
2429
|
+
splitOp('disable_x_chat_invite_link', { action: 'disable_invite' }, ['id!', 'confirm'], splitW(true, true), 'Turn off an X Chat group invite link', 'Use this when a group’s shared invite link should stop working for good. Needs confirm:true.' + X_BILLED, { confirm: 'must be true to turn the link off' }),
|
|
2430
|
+
splitOp('accept_x_chat_request', { action: 'accept_request' }, ['id!'], splitW(false, true), 'Accept an X Chat message request', 'Use this when a message request should move into the brand’s X inbox. id is the sender’s numeric id or the request’s conversationId from list_x_chats view message_requests. Show the user who sent it first; the message itself cannot be read here.' + X_BILLED),
|
|
2431
|
+
splitOp('archive_x_chat', { action: 'archive' }, ['id!'], splitW(false, true), 'Archive an X Chat conversation', 'Use this when a conversation should move out of the brand’s main X inbox. Only the brand’s view changes.' + X_BILLED),
|
|
2432
|
+
splitOp('unarchive_x_chat', { action: 'unarchive' }, ['id!'], splitW(false, true), 'Unarchive an X Chat conversation', 'Use this when an archived conversation should return to the brand’s X inbox.' + X_BILLED),
|
|
2433
|
+
splitOp('mark_x_chat_read', { action: 'mark_read' }, ['id!'], splitW(false, true), 'Mark an X Chat conversation read', 'Use this when a conversation should be marked read. The other person may get a read receipt.' + X_BILLED),
|
|
2434
|
+
],
|
|
2435
|
+
manage_bluesky_convo: [
|
|
2436
|
+
splitOp('accept_bluesky_convo', { action: 'accept' }, ['convoId!'], splitW(false, true), 'Accept a Bluesky message request', 'Use this when a Bluesky DM request should move out of the requests folder into the inbox. Get convoId from list_bluesky_convos. Free.'),
|
|
2437
|
+
splitOp('mute_bluesky_convo', { action: 'mute' }, ['convoId!'], splitW(false, true), 'Mute a Bluesky DM conversation', 'Use this when a Bluesky DM conversation should stop notifying the account. Free.'),
|
|
2438
|
+
splitOp('unmute_bluesky_convo', { action: 'unmute' }, ['convoId!'], splitW(false, true), 'Unmute a Bluesky DM conversation', 'Use this when a muted Bluesky DM conversation should notify the account again. Free.'),
|
|
2439
|
+
splitOp('lock_bluesky_convo', { action: 'lock' }, ['convoId!'], splitW(true, true), 'Lock a Bluesky DM conversation', 'Use this when no new messages should be sent in a Bluesky DM conversation. Reversible with unlock_bluesky_convo. Free.'),
|
|
2440
|
+
splitOp('unlock_bluesky_convo', { action: 'unlock' }, ['convoId!'], splitW(false, true), 'Unlock a Bluesky DM conversation', 'Use this when a locked Bluesky DM conversation should accept new messages again. Free.'),
|
|
2441
|
+
splitOp('leave_bluesky_convo', { action: 'leave' }, ['convoId!'], splitW(true, true), 'Leave a Bluesky DM conversation', 'Use this when the account should leave a Bluesky DM conversation. It cannot be undone from here; say so first. Free.'),
|
|
2442
|
+
splitOp('delete_bluesky_dm_for_self', { action: 'deleteMessage' }, ['convoId!', 'messageId!'], splitW(true, true), 'Delete a Bluesky DM from this account’s view', 'Use this when one Bluesky direct message should disappear from THIS account’s view. The other person still sees it; Bluesky has no delete-for-everyone. Get messageId from read_bluesky_dm. Free.', { messageId: 'from read_bluesky_dm' }),
|
|
2443
|
+
],
|
|
2444
|
+
moderate_tiktok_comment: [
|
|
2445
|
+
splitOp('like_tiktok_comment', { action: 'LIKE' }, ['commentId!', 'videoId'], splitW(false, true), 'Like a TikTok comment', 'Use this when the brand should like a comment on its own TikTok post. Pass videoId too and the reply reads the comment back. Get commentId from list_tiktok_comments. Free.'),
|
|
2446
|
+
splitOp('unlike_tiktok_comment', { action: 'UNLIKE' }, ['commentId!', 'videoId'], splitW(false, true), 'Unlike a TikTok comment', 'Use this when the brand should take its like off a comment on its own TikTok post. Free.'),
|
|
2447
|
+
splitOp('hide_tiktok_comment', { action: 'HIDE' }, ['commentId!', 'videoId!'], splitW(true, true), 'Hide a TikTok comment', 'Use this when a comment on the brand’s own TikTok post should be hidden, including a stranger’s (only the brand’s own comments can be deleted). Reversible. Free.'),
|
|
2448
|
+
splitOp('unhide_tiktok_comment', { action: 'UNHIDE' }, ['commentId!', 'videoId!'], splitW(true, true), 'Unhide a TikTok comment', 'Use this when a hidden comment on the brand’s own TikTok post should be visible again. TikTok may keep it hidden if its own filters hid it; the reply reads the real status back. Free.'),
|
|
2449
|
+
splitOp('delete_tiktok_comment', { action: 'DELETE' }, ['commentId!', 'videoId', 'confirm'], splitW(true, true), 'Delete a TikTok comment the brand wrote', 'Use this when a comment the brand’s own account wrote must be removed for good. Other people’s comments can only be hidden (hide_tiktok_comment). PERMANENT: needs confirm:true. Free.', { confirm: 'must be true to delete (permanent)' }),
|
|
2450
|
+
],
|
|
2451
|
+
manage_tiktok_brand_hashtags: [
|
|
2452
|
+
splitOp('enable_tiktok_brand_hashtags', { action: 'ADD' }, ['hashtags!', 'username'], splitW(false, true), 'Turn on brand hashtags for TikTok tracking', 'Use this when TikTok should count up to 10 hashtags as the brand’s (50 in total). A new hashtag is counted after 24 hours and cannot be turned off for 7 days. A tag must be on the available list or contain the account handle. Free.', { hashtags: 'up to 10, with or without the leading #' }),
|
|
2453
|
+
splitOp('disable_tiktok_brand_hashtag', { action: 'REMOVE' }, ['hashtag!', 'username'], splitW(false, true), 'Turn off a brand hashtag for TikTok tracking', 'Use this when TikTok should stop counting one hashtag as the brand’s. Exactly one per call, and not within 7 days of enabling it (the reply names the wait). Free.', { hashtag: 'exactly one enabled hashtag' }),
|
|
2454
|
+
],
|
|
2455
|
+
link_tiktok_tto_video: [
|
|
2456
|
+
splitOp('request_tiktok_tto_video_link', { action: 'LINK' }, ['ttoAccountId', 'campaignId!', 'videoId!'], splitW(true, false), 'Ask a creator to link a video to a TikTok One campaign', 'Use this when an invited creator should be asked to link one of their public videos to a TikTok One campaign. It sends a notification to a real person; a second request within 24 hours is refused and at most two reminders are allowed, so read list_tiktok_tto_link_requests before re-sending. Free.'),
|
|
2457
|
+
splitOp('revoke_tiktok_tto_video_link', { action: 'REVOKE' }, ['ttoAccountId', 'campaignId!', 'videoId!'], splitW(true, true), 'Withdraw a TikTok One video-link request', 'Use this when a video-link request should be withdrawn. Works only while the creator has not answered, once per request, and cannot be undone. Free.'),
|
|
2458
|
+
],
|
|
2459
|
+
manage_tiktok_tto_anchor: [
|
|
2460
|
+
splitOp('list_tiktok_tto_anchors', { action: 'list' }, ['ttoAccountId', 'anchorIds', 'page', 'pageSize'], SPLIT_RO, 'List TikTok One webpage anchors', 'Use this when you need the webpage anchors (links above a creator’s video description) on the TikTok One account, with their status. Read-only, free.'),
|
|
2461
|
+
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.'),
|
|
2462
|
+
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.'),
|
|
2463
|
+
],
|
|
2464
|
+
});
|
|
2465
|
+
// op tool name → the multi-op tool it comes from.
|
|
2466
|
+
export const WIDGET_SPLIT_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_SPLITS).flatMap(([from, ops]) => ops.map((o) => [o.name, from]))));
|
|
2271
2467
|
// Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
|
|
2272
2468
|
export const widgetHostHides = (name, widgetHost) => (widgetHost
|
|
2273
|
-
? (WITHHELD_FROM_WIDGET_HOSTS.has(name) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name))
|
|
2274
|
-
: Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name));
|
|
2469
|
+
? (WITHHELD_FROM_WIDGET_HOSTS.has(name) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
|
|
2470
|
+
: (Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name) || Object.prototype.hasOwnProperty.call(WIDGET_SPLIT_OF, name)));
|
|
2275
2471
|
// HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
|
|
2276
2472
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
2277
2473
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
@@ -2514,11 +2710,34 @@ export function defForHost(name, def, widgetHost) {
|
|
|
2514
2710
|
// by-reference property) and a def with nothing to rename comes back as itself.
|
|
2515
2711
|
const WIDGET_RENAME_RE = new RegExp(`\\b(${Object.keys(WIDGET_HOST_RENAMES).join('|')})\\b`, 'g');
|
|
2516
2712
|
const _widgetRenamed = new WeakMap();
|
|
2713
|
+
// …AND A "(Settings > Connectors > X, or connect_connector)" CLAUSE LOSES ITS SECOND HALF THERE (2026-10-18). On ChatGPT a
|
|
2714
|
+
// key is connected in the Hermoso app, never pasted into the conversation, and OpenAI's scan held tools whose text
|
|
2715
|
+
// offered that (post_to_bluesky / post_to_telegram, 2026-10-16). The app half of the sentence stays.
|
|
2716
|
+
const WIDGET_KEY_PASTE_RE = /,? or connect_connector\b[^.;)]*/g;
|
|
2717
|
+
// …AND A NAME A WIDGET HOST LISTS AS SEVERAL OPERATIONS (WIDGET_HOST_SPLITS) SAYS THE OPERATION (2026-10-18): "manage_x_chat
|
|
2718
|
+
// accept_request" reads accept_x_chat_request there, and a bare "manage_meta_post" names the operations it became.
|
|
2719
|
+
// fetch_social_data is withheld there, so the two descriptions that send a caller to it name the dedicated tools instead.
|
|
2720
|
+
const WIDGET_SPLIT_RE = new RegExp(`\\b(${Object.keys(WIDGET_HOST_SPLITS).join('|')})\\b(?:\\s+(?:action\\s*[:=]\\s*)?["']?([A-Za-z_]+)["']?)?`, 'g');
|
|
2721
|
+
const splitRef = (m, tool, word) => {
|
|
2722
|
+
const ops = WIDGET_HOST_SPLITS[tool] || [];
|
|
2723
|
+
const w = String(word || '').toLowerCase();
|
|
2724
|
+
const hit = w && ops.find((o) => Object.values(o.set).some((v) => String(v).toLowerCase() === w));
|
|
2725
|
+
return hit ? hit.name : ops.map((o) => o.name).join(' / ') + m.slice(tool.length);
|
|
2726
|
+
};
|
|
2727
|
+
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']];
|
|
2517
2728
|
function renamedForWidgetHost(def) {
|
|
2518
|
-
|
|
2519
|
-
WIDGET_RENAME_RE.lastIndex = 0;
|
|
2729
|
+
const d = def && typeof def.description === 'string' ? def.description : '';
|
|
2730
|
+
const rename = !!d && WIDGET_RENAME_RE.test(d); WIDGET_RENAME_RE.lastIndex = 0;
|
|
2731
|
+
const paste = !!d && WIDGET_KEY_PASTE_RE.test(d); WIDGET_KEY_PASTE_RE.lastIndex = 0;
|
|
2732
|
+
const split = !!d && WIDGET_SPLIT_RE.test(d); WIDGET_SPLIT_RE.lastIndex = 0;
|
|
2733
|
+
const proxy = !!d && d.includes('fetch_social_data');
|
|
2734
|
+
if (!rename && !paste && !split && !proxy) return def;
|
|
2520
2735
|
let hit = _widgetRenamed.get(def);
|
|
2521
|
-
if (!hit) {
|
|
2736
|
+
if (!hit) {
|
|
2737
|
+
let out = d.replace(WIDGET_RENAME_RE, (m) => WIDGET_HOST_RENAMES[m] || m).replace(WIDGET_KEY_PASTE_RE, '').replace(WIDGET_SPLIT_RE, splitRef);
|
|
2738
|
+
for (const [re, to] of WIDGET_PROXY_REFS) out = out.replace(re, to);
|
|
2739
|
+
hit = { ...def, description: out }; _widgetRenamed.set(def, hit);
|
|
2740
|
+
}
|
|
2522
2741
|
return hit;
|
|
2523
2742
|
}
|
|
2524
2743
|
|
|
@@ -2622,7 +2841,7 @@ export const holdHints = (name, why, ctx = null) => {
|
|
|
2622
2841
|
if (why === 'not_connected') { const p = toolProvider(name) || 'that'; return [{ do: `have the user connect "${p}" under Settings \u25b8 Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
|
|
2623
2842
|
if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Settings \u25b8 Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
|
|
2624
2843
|
if (why === 'unavailable') return [{ do: 'do not offer this capability; carry on with the tools that are listed', why: `${name} is not available in Hermoso yet` }];
|
|
2625
|
-
if (why === 'host_policy' &&
|
|
2844
|
+
if (why === 'host_policy' && executorLike(name)) return [{ do: 'call the Hermoso tools in your list directly', why: `on this host every Hermoso operation is its own listed tool, so ${name} is not offered` }];
|
|
2626
2845
|
if (why === 'host_policy') return [{ do: 'use the Hermoso app or another MCP client for this one', why: `${name} is withheld by this host's own policy, not by Hermoso` }];
|
|
2627
2846
|
if (why === 'directory') return [{ do: 'use the Hermoso app, or connect the unscoped server URL https://app.hermoso.ai/mcp', why: `${name} is outside what this Claude directory connection may run` }];
|
|
2628
2847
|
return [];
|
|
@@ -2636,7 +2855,7 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2636
2855
|
}
|
|
2637
2856
|
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.`;
|
|
2638
2857
|
// ONE sentence, two reasons: OpenAI's generic-executor rule for EXECUTOR_TOOLS, its commerce policy for the rest.
|
|
2639
|
-
if (why === 'host_policy') return `${name} is not offered on this host (${
|
|
2858
|
+
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.'}`;
|
|
2640
2859
|
if (why === 'not_connected') {
|
|
2641
2860
|
// THE LINK BELONGS HERE TOO (2026-09-13, found live on a brand with no YouTube). A tool held back for a missing
|
|
2642
2861
|
// connection is answered by this sentence BEFORE any request reaches the server, so the brand-scoped link the
|
|
@@ -2903,7 +3122,13 @@ function newToolScope(opts) {
|
|
|
2903
3122
|
// A core-first roster carries the `core` group plus CORE_FIRST_EXTRA — see the block beside that constant.
|
|
2904
3123
|
// Expressed as a predicate rather than as a branch inside applyToolGates so the check can RUN it.
|
|
2905
3124
|
// A widget-host alias (WIDGET_HOST_RENAMES) is listed exactly when the tool it renames would be.
|
|
2906
|
-
|
|
3125
|
+
// EVERY FEATURE ON CHATGPT (2026-10-18, the owner: "get all the rest of our features in our ChatGPT plugin"). A widget host
|
|
3126
|
+
// cannot use the generic executor, so a tool it does not list is a feature it does not have; the batches in
|
|
3127
|
+
// WIDGET_HOST_EXTRA (21, then 129 names) left ~620 out. A widget host now lists every tool, and the connector gate keeps
|
|
3128
|
+
// each provider's tools off until that provider is connected (measured 2026-10-18: nothing connected 43 -> 134 tools,
|
|
3129
|
+
// socials connected 76 -> 308, everything connected 163 -> 892). WIDGET_HOST_EXTRA stays as the record of what passed
|
|
3130
|
+
// OpenAI's scan in the first two rounds.
|
|
3131
|
+
export const listedInCoreFirst = (name, group, ctx) => ctx.enabledGroups.has(group) || (!!ctx.coreFirst && (CORE_FIRST_EXTRA.includes(WIDGET_HOST_ALIAS_OF[name] || name) || !!ctx.widgetHost));
|
|
2907
3132
|
// THE THREE REASONS A REGISTERED TOOL IS HELD BACK. Applied identically on the build path, the replay path and
|
|
2908
3133
|
// `enable_tools`, from ONE function, because a gate applied at two of the three is a gate a group flip undoes.
|
|
2909
3134
|
//
|
|
@@ -2939,6 +3164,7 @@ function replayTools(rawServer, opts, canon) {
|
|
|
2939
3164
|
applyToolGates(h, e.name, e.group, ctx, opts);
|
|
2940
3165
|
}
|
|
2941
3166
|
registerWidgetHostAliases(rawServer, ctx, opts);
|
|
3167
|
+
registerWidgetHostSplits(rawServer, ctx, opts);
|
|
2942
3168
|
registerChatGPTExtensions(rawServer, ctx, opts);
|
|
2943
3169
|
installHeldToolCalls(rawServer, ctx);
|
|
2944
3170
|
return rawServer;
|
|
@@ -2988,6 +3214,57 @@ function registerWidgetHostAliases(rawServer, ctx, opts) {
|
|
|
2988
3214
|
}
|
|
2989
3215
|
}
|
|
2990
3216
|
|
|
3217
|
+
// THE WIDGET-HOST OPERATION TOOLS (2026-10-18) — see WIDGET_HOST_SPLITS. Same seam as the aliases: per widget-host session,
|
|
3218
|
+
// never in the canon. Each def is built ONCE per canonical def and each handler once per canonical handler (WeakMaps),
|
|
3219
|
+
// so a replayed session shares the same objects the first one built and the canon stays one object per tool.
|
|
3220
|
+
const _splitDefs = new WeakMap();
|
|
3221
|
+
const _splitHandlers = new WeakMap();
|
|
3222
|
+
export function widgetSplitDef(o, srcDef) {
|
|
3223
|
+
let m = _splitDefs.get(srcDef); if (!m) { m = Object.create(null); _splitDefs.set(srcDef, m); }
|
|
3224
|
+
if (m[o.name]) return m[o.name];
|
|
3225
|
+
const src = (srcDef && srcDef.inputSchema) || {};
|
|
3226
|
+
const inputSchema = {};
|
|
3227
|
+
for (const p of o.params) {
|
|
3228
|
+
const required = p.endsWith('!'); const k = required ? p.slice(0, -1) : p;
|
|
3229
|
+
let sch = src[k]; if (!sch) continue; // tools/chatgpt-review-findings-check.mjs asserts every op param exists on its tool
|
|
3230
|
+
const text = (o.desc && o.desc[k]) || sch.description;
|
|
3231
|
+
if (required && typeof sch.isOptional === 'function' && sch.isOptional() && typeof sch.unwrap === 'function') sch = sch.unwrap();
|
|
3232
|
+
inputSchema[k] = text && text !== sch.description ? sch.describe(text) : sch;
|
|
3233
|
+
}
|
|
3234
|
+
const def = explicitHints({ title: o.title, description: o.description, inputSchema, annotations: { ...o.ann, title: o.title } });
|
|
3235
|
+
m[o.name] = def;
|
|
3236
|
+
return def;
|
|
3237
|
+
}
|
|
3238
|
+
export function widgetSplitHandler(o, handler) {
|
|
3239
|
+
let m = _splitHandlers.get(handler); if (!m) { m = Object.create(null); _splitHandlers.set(handler, m); }
|
|
3240
|
+
if (m[o.name]) return m[o.name];
|
|
3241
|
+
const keys = o.params.map((p) => (p.endsWith('!') ? p.slice(0, -1) : p));
|
|
3242
|
+
// Only the op's own params pass, and the operation is fixed AFTER them, so a caller cannot re-aim it.
|
|
3243
|
+
const fn = (args, extra) => {
|
|
3244
|
+
const input = {};
|
|
3245
|
+
for (const k of keys) if (args && args[k] !== undefined) input[k] = args[k];
|
|
3246
|
+
return handler({ ...input, ...o.set }, extra);
|
|
3247
|
+
};
|
|
3248
|
+
try { fn._hermosoTool = handler._hermosoTool; } catch {}
|
|
3249
|
+
m[o.name] = fn;
|
|
3250
|
+
return fn;
|
|
3251
|
+
}
|
|
3252
|
+
function registerWidgetHostSplits(rawServer, ctx, opts) {
|
|
3253
|
+
if (!opts.widgetHost) return;
|
|
3254
|
+
for (const [from, ops] of Object.entries(WIDGET_HOST_SPLITS)) {
|
|
3255
|
+
const src = ctx.srcOf[from];
|
|
3256
|
+
if (!src || !src.def || typeof src.handler !== 'function') continue;
|
|
3257
|
+
for (const o of ops) {
|
|
3258
|
+
if (ctx.handleOf[o.name]) continue;
|
|
3259
|
+
const def = widgetSplitDef(o, src.def);
|
|
3260
|
+
const handler = widgetSplitHandler(o, src.handler);
|
|
3261
|
+
const h = rawServer.registerTool(o.name, defForHost(o.name, def, true), handler);
|
|
3262
|
+
ctx.handleOf[o.name] = h; ctx.groupOf[o.name] = ctx.groupOf[from]; ctx.srcOf[o.name] = { def, handler };
|
|
3263
|
+
applyToolGates(h, o.name, ctx.groupOf[o.name], ctx, opts);
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
|
|
2991
3268
|
// ── THE MCP RETURN CROSSING: A JS STRING BECOMES A JSON-RPC FRAME (2026-08-26) ───────────────────────────────────
|
|
2992
3269
|
// A lone UTF-16 surrogate in a scraped caption is well-formed by the JSON grammar and refused by Anthropic:
|
|
2993
3270
|
// `400 … "The request body is not valid JSON: no low surrogate in string"`. Inside our own product that is already
|
|
@@ -3184,10 +3461,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3184
3461
|
// canonical `def` stays ONE object shared by every session, which is the property `capabilities-widget-
|
|
3185
3462
|
// check` §3c asserts by reference identity to prove the replay path is really the replay path. A per-host
|
|
3186
3463
|
// rewrite would hand every session a fresh copy and quietly turn the cache into a rebuild.
|
|
3187
|
-
|
|
3464
|
+
// ONE stripped object for the canon AND this session's srcOf, so the widget-host aliases and operation tools built
|
|
3465
|
+
// from it (WeakMap-cached per def) are the same objects on the build path and on every replay.
|
|
3466
|
+
const canonDef = stripOutputSchema(finalDef);
|
|
3467
|
+
if (sink) sink.push({ name, group, def: canonDef, handler, factory: handler && handler._hermosoFactory || null });
|
|
3188
3468
|
const h = t.registerTool(name, defForHost(name, finalDef, opts.widgetHost), handler);
|
|
3189
3469
|
handleOf[name] = h;
|
|
3190
|
-
ctx.srcOf[name] = { def:
|
|
3470
|
+
ctx.srcOf[name] = { def: canonDef, handler };
|
|
3191
3471
|
// DISABLED, NOT SKIPPED — see (1) above. `disable()` is the SDK's own call and removes it from tools/list.
|
|
3192
3472
|
//
|
|
3193
3473
|
// ONE GATE FUNCTION, NOT A SECOND COPY OF THE RULES (2026-08-26). This branch and the widget branch below
|
|
@@ -3714,7 +3994,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3714
3994
|
server.group('channels');
|
|
3715
3995
|
server.registerTool('post_to_bluesky', {
|
|
3716
3996
|
title: 'Post to Bluesky',
|
|
3717
|
-
description: "Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky
|
|
3997
|
+
description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky.",
|
|
3718
3998
|
inputSchema: {
|
|
3719
3999
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
3720
4000
|
brand: HOOK_ATTR.brand,
|
|
@@ -3776,7 +4056,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3776
4056
|
server.group('channels');
|
|
3777
4057
|
server.registerTool('post_to_telegram', {
|
|
3778
4058
|
title: 'Post to Telegram',
|
|
3779
|
-
description: "Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram
|
|
4059
|
+
description: "Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram.",
|
|
3780
4060
|
inputSchema: {
|
|
3781
4061
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
3782
4062
|
brand: HOOK_ATTR.brand,
|
|
@@ -4762,7 +5042,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4762
5042
|
|
|
4763
5043
|
server.registerTool('moderate_meta_comment', {
|
|
4764
5044
|
title: 'Hide, unhide or delete a Meta comment',
|
|
4765
|
-
description: 'Moderate a comment on the brand’s Facebook or Instagram post.
|
|
5045
|
+
description: 'Moderate a comment on the brand’s Facebook or Instagram post. Hide is the default and can be undone. Deleting is PERMANENT and requires confirm:true after the user has agreed.',
|
|
4766
5046
|
inputSchema: {
|
|
4767
5047
|
commentId: z.string().describe('comment id from list_meta_comments'),
|
|
4768
5048
|
action: z.enum(['hide', 'unhide', 'delete']).optional().describe('default hide'),
|
|
@@ -5253,7 +5533,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5253
5533
|
server.group('channels');
|
|
5254
5534
|
server.registerTool('post_to_meta', {
|
|
5255
5535
|
title: 'Post to Facebook, Instagram or Threads',
|
|
5256
|
-
description: 'Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept
|
|
5536
|
+
description: 'Use this when the user wants to post now to Facebook, Instagram or Threads. Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings > Connectors > Meta) with posting permission; Threads needs its own connection.',
|
|
5257
5537
|
// LOOSE ON PURPOSE (2026-09-30): an argument this schema does not declare is FORWARDED, not stripped, so a caption
|
|
5258
5538
|
// sent as `caption` or a picture as `image_url` reaches the server and is refused BY NAME with the right field
|
|
5259
5539
|
// (metaPostAliasRefusal). Stripped, it vanished and the call read as empty: five identical refusals in 35 minutes.
|
|
@@ -5268,7 +5548,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5268
5548
|
allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
|
|
5269
5549
|
async: z.boolean().optional().describe('publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.'),
|
|
5270
5550
|
link: z.string().optional().describe('a URL to attach (FB text post only)'),
|
|
5271
|
-
collaborators: z.array(z.string()).optional().describe('INSTAGRAM COLLAB \u2014 up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared
|
|
5551
|
+
collaborators: z.array(z.string()).optional().describe('INSTAGRAM COLLAB \u2014 up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY \u2014 Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors \u2014 and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand\u2019s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts \u2014 read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted.'),
|
|
5272
5552
|
target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram -> the Page’s linked IG; threads -> the brand’s connected Threads account'),
|
|
5273
5553
|
account: z.string().optional().describe('WHICH Instagram account when target is instagram and the brand has several — Page-linked and Instagram Login accounts alike; an @username or id from list_connector_accounts("instagram"). Several and none named is refused by name; omit when there is one.'),
|
|
5274
5554
|
scheduleAt: z.string().optional().describe('FACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately.'),
|
|
@@ -5344,7 +5624,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5344
5624
|
// no need for one edge case just for Facebook."
|
|
5345
5625
|
server.registerTool('schedule_post', {
|
|
5346
5626
|
title: 'Schedule a post for later',
|
|
5347
|
-
description: '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. google_business is in the schema but HELD BACK (Google’s API allowlist) and is refused at enqueue. 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.',
|
|
5627
|
+
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. google_business is in the schema but HELD BACK (Google’s API allowlist) and is refused at enqueue. 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.',
|
|
5348
5628
|
inputSchema: {
|
|
5349
5629
|
...HOOK_ATTR,
|
|
5350
5630
|
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'),
|
|
@@ -5478,7 +5758,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5478
5758
|
}));
|
|
5479
5759
|
server.registerTool('list_scheduled', {
|
|
5480
5760
|
title: 'List scheduled and past posts',
|
|
5481
|
-
description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). THE LIST IS COMPACT so it fits in one reply: the next 25 queued and the last 15 fired, captions shortened. Pass `id` for ONE post in full (every caption and setting, which you need before reschedule_post replaces a caption map), `channel` to filter, or `upcoming` / `fired` for more rows. Read-only, 0 credits.',
|
|
5761
|
+
description: 'Use this when the user asks what is scheduled, what already posted, or whether a post went out. Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). THE LIST IS COMPACT so it fits in one reply: the next 25 queued and the last 15 fired, captions shortened. Pass `id` for ONE post in full (every caption and setting, which you need before reschedule_post replaces a caption map), `channel` to filter, or `upcoming` / `fired` for more rows. Read-only, 0 credits.',
|
|
5482
5762
|
inputSchema: {
|
|
5483
5763
|
brand: z.string().optional().describe('WHICH PROFILE to list — the id or exact name from list_brands. Needed when the post lives in a profile this connection is not pinned to: a post you can CREATE in a profile must be manageable there too, without switching the whole connection. A name that matches no profile, or two, is REFUSED.'),
|
|
5484
5764
|
id: z.string().optional().describe('one post id from this list: returns that post in full, every caption and setting included'),
|
|
@@ -5843,7 +6123,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5843
6123
|
}));
|
|
5844
6124
|
server.registerTool('post_to_linkedin', {
|
|
5845
6125
|
title: 'Publish to LinkedIn',
|
|
5846
|
-
description: 'Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
|
|
6126
|
+
description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
|
|
5847
6127
|
inputSchema: {
|
|
5848
6128
|
...HOOK_ATTR,
|
|
5849
6129
|
text: z.string().describe('the post text'),
|
|
@@ -5864,7 +6144,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5864
6144
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
5865
6145
|
server.registerTool('post_to_x', {
|
|
5866
6146
|
title: 'Publish a post to X (Twitter)',
|
|
5867
|
-
description: 'Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Only the first ~280 characters show in the timeline; the rest sits behind \u201cShow more\u201d. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign -> create_x_ads_line_item -> create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).',
|
|
6147
|
+
description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Only the first ~280 characters show in the timeline; the rest sits behind \u201cShow more\u201d. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign -> create_x_ads_line_item -> create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).',
|
|
5868
6148
|
inputSchema: {
|
|
5869
6149
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one x account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
5870
6150
|
...HOOK_ATTR,
|
|
@@ -5932,7 +6212,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5932
6212
|
text: z.string().describe('the FULL new text of the post. It replaces the old text entirely. Same length rule as a new post: 280 characters, or up to 25,000 on an X Premium account.'),
|
|
5933
6213
|
},
|
|
5934
6214
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), previousId: z.string().optional(), editedFromId: z.string().optional(), url: z.string().nullable().optional(), text: z.string().nullable().optional(), editHistory: z.array(z.string()).optional(), editsRemaining: z.number().nullable().optional(), editableUntil: z.string().nullable().optional(), note: z.string().optional(), costCredits: z.number().optional() },
|
|
5935
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6215
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5936
6216
|
}, wrap(async (a) => {
|
|
5937
6217
|
const d = await apiPost('/api/x/edit', a);
|
|
5938
6218
|
return ok(`Edited the post on X. ${d.note || ''} Cost ${d.costCredits ?? '?'} credits.`.trim(), d);
|
|
@@ -5949,7 +6229,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5949
6229
|
}));
|
|
5950
6230
|
server.registerTool('x_post_metrics', {
|
|
5951
6231
|
title: 'Read performance of a post on X',
|
|
5952
|
-
description: "THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround.
|
|
6232
|
+
description: "THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround. Use this rather than x_post_insights, whose endpoint family X has retired. If a post is deleted, protected or suspended, X answers with no data at all and this says so — that is MISSING DATA, never zero engagement, and must never be reported as a measured zero. Costs a small number of credits (X bills per API read). Needs X connected.",
|
|
5953
6233
|
inputSchema: {
|
|
5954
6234
|
...MANAGE_BRAND,
|
|
5955
6235
|
id: z.string().describe('the numeric X post id — the last part of the post URL'),
|
|
@@ -6162,7 +6442,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6162
6442
|
server.group('channels');
|
|
6163
6443
|
server.registerTool('post_to_pinterest', {
|
|
6164
6444
|
title: 'Create a Pin',
|
|
6165
|
-
description: 'Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Settings > Connectors > Pinterest).',
|
|
6445
|
+
description: 'Use this when the user wants to publish a Pin to Pinterest. Publishing costs no credits. Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Settings > Connectors > Pinterest).',
|
|
6166
6446
|
inputSchema: {
|
|
6167
6447
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one pinterest account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
6168
6448
|
...HOOK_ATTR,
|
|
@@ -6555,7 +6835,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6555
6835
|
server.group('channels');
|
|
6556
6836
|
server.registerTool('post_to_youtube', {
|
|
6557
6837
|
title: 'Post a video to YouTube',
|
|
6558
|
-
description: 'Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
|
|
6838
|
+
description: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
|
|
6559
6839
|
inputSchema: {
|
|
6560
6840
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one youtube account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
6561
6841
|
...HOOK_ATTR,
|
|
@@ -6657,7 +6937,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6657
6937
|
schedule: z.boolean().optional().describe('false = do not create the job if it is missing; just report that none exists'),
|
|
6658
6938
|
},
|
|
6659
6939
|
outputSchema: { report: z.string().optional(), reportTypeId: z.string().optional(), jobId: z.string().optional(), scheduled: z.boolean().optional(), justCreated: z.boolean().optional(), days: z.array(z.string()).optional(), columns: z.array(z.string()).optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6660
|
-
annotations: { readOnlyHint:
|
|
6940
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6661
6941
|
}, wrap(async (a) => {
|
|
6662
6942
|
const d = await apiGet('/api/youtube/bulk-report', { report: a.report, days: a.days, since: a.since, until: a.until, schedule: a.schedule === false ? 'false' : undefined });
|
|
6663
6943
|
return ok(`${d.note}\n${rowLines(d.rows)}`, d);
|
|
@@ -6687,7 +6967,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6687
6967
|
description: 'Edit an existing video on the connected channel: title, description, tags, and/or privacy (unlisted | public | private). THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC (post_to_youtube is public by default; unlisted only when the user asked for it). Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched. Needs a connected YouTube channel.',
|
|
6688
6968
|
inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id'), title: z.string().optional().describe('≤100 chars'), description: z.string().optional().describe('≤5000 chars'), tags: z.array(z.string()).optional(), privacy: z.enum(['unlisted', 'public', 'private']).optional().describe('public = live on the channel; confirm with the user first') },
|
|
6689
6969
|
outputSchema: { videoId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), url: z.string().optional() },
|
|
6690
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6970
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6691
6971
|
}, wrap(async (a) => {
|
|
6692
6972
|
const d = await apiPost('/api/youtube/update-video', a);
|
|
6693
6973
|
return ok(`Updated — “${d.title}” is now ${d.privacy}. ${d.url}`, d);
|
|
@@ -6739,7 +7019,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6739
7019
|
bannerImageUrl: z.string().optional().describe('any public https image URL to upload as the channel banner — a Hermoso render, or ANY file of your own brought in with upload_file'),
|
|
6740
7020
|
},
|
|
6741
7021
|
outputSchema: { channelId: z.string().optional(), channelTitle: z.string().optional(), requested: z.any().optional(), applied: z.array(z.string()).optional(), notApplied: z.array(z.string()).optional(), bannerUrl: z.string().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
6742
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7022
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6743
7023
|
}, wrap(async (a) => {
|
|
6744
7024
|
const d = await apiPost('/api/youtube/channel', a);
|
|
6745
7025
|
return ok(`${d.note} ${d.url || ''}`.trim(), d);
|
|
@@ -6755,7 +7035,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6755
7035
|
durationMs: z.number().optional().describe('how long it stays on screen'),
|
|
6756
7036
|
},
|
|
6757
7037
|
outputSchema: { channelId: z.string().optional(), action: z.string().optional(), bytes: z.number().optional(), verified: z.boolean().nullable().optional(), timing: z.any().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
6758
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7038
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6759
7039
|
}, wrap(async (a) => {
|
|
6760
7040
|
const d = await apiPost('/api/youtube/watermark', a);
|
|
6761
7041
|
return ok(d.note, d);
|
|
@@ -6783,7 +7063,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6783
7063
|
description: 'Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image — a make_thumbnail render, a generated image, or a frame. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Images over YouTube’s 2MB cap are compressed automatically. The image must be Hermoso-HOSTED, which is not the same as Hermoso-GENERATED: the user’s own artwork works, put it through upload_file first and pass the URL that returns. An arbitrary external host is refused. 0 credits. Needs a connected YouTube channel.',
|
|
6784
7064
|
inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id (what post_to_youtube returned)'), imageUrl: z.string().describe('a Hermoso-hosted image URL — a make_thumbnail / list_library render, OR any image of the user’s own passed through upload_file first. An arbitrary external host is refused.') },
|
|
6785
7065
|
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), thumbnailUrl: z.string().nullable().optional(), bytes: z.number().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
6786
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7066
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6787
7067
|
}, wrap(async (a) => {
|
|
6788
7068
|
const d = await apiPost('/api/youtube/set-thumbnail', a);
|
|
6789
7069
|
return ok(`${d.note} ${d.url || ''}`, d);
|
|
@@ -6842,7 +7122,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6842
7122
|
description: 'Add a video to a playlist, remove an entry, or move an entry to a new position. ADD takes playlistId + videoId and returns the new entry’s `itemId`. REMOVE and MOVE take that `itemId` — NOT the videoId — because one video can sit in a playlist more than once, so a videoId does not identify which row to touch; list_youtube_playlists with a playlistId returns the itemId of every entry. Removing an entry does NOT delete the video, only its place in the playlist. Positions are zero-based. 0 credits. Needs a connected YouTube channel.',
|
|
6843
7123
|
inputSchema: { action: z.enum(['add', 'remove', 'move']).describe('add | remove | move'), playlistId: z.string().optional().describe('required for add'), videoId: z.string().optional().describe('required for add'), itemId: z.string().optional().describe('the playlist-ENTRY id — required for remove and move (from list_youtube_playlists)'), position: z.number().optional().describe('zero-based position; required for move, optional on add (default: appended)') },
|
|
6844
7124
|
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), itemId: z.string().optional(), playlistId: z.string().optional(), videoId: z.string().optional(), title: z.string().optional(), position: z.number().optional(), previousPosition: z.number().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
6845
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7125
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6846
7126
|
}, wrap(async (a) => { const d = await apiPost('/api/youtube/playlist-items', a); return ok(d.note, d); }));
|
|
6847
7127
|
// ── YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES (2026-08-20) ────────────────────────────────────────────────
|
|
6848
7128
|
// Both on the held `youtube.force-ssl`. channelSections.list LAGS a write in BOTH directions (measured), so
|
|
@@ -6881,7 +7161,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6881
7161
|
imageId: z.string().optional().describe("for action:'delete' — from action:'list'"),
|
|
6882
7162
|
},
|
|
6883
7163
|
outputSchema: { action: z.string().optional(), playlistId: z.string().optional(), count: z.number().optional(), images: z.array(z.any()).optional(), image: z.any().optional(), bytes: z.number().optional(), deleted: z.boolean().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
6884
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7164
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6885
7165
|
}, wrap(async (a) => {
|
|
6886
7166
|
const d = await apiPost('/api/youtube/playlist-image', a);
|
|
6887
7167
|
const rows = (d.images || []).map(i => `${i.type} · ${i.width || '?'}x${i.height || '?'} · ${i.id}`);
|
|
@@ -7492,7 +7772,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7492
7772
|
server.group('channels');
|
|
7493
7773
|
server.registerTool('post_to_tiktok', {
|
|
7494
7774
|
title: 'Post a video or photo post to TikTok',
|
|
7495
|
-
description: 'Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings > Connectors > TikTok).',
|
|
7775
|
+
description: 'Use this when the user wants to post a video or photo post to TikTok now. Publishing costs no credits. Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings > Connectors > TikTok).',
|
|
7496
7776
|
inputSchema: {
|
|
7497
7777
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one tiktok account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
7498
7778
|
...HOOK_ATTR,
|
|
@@ -7607,7 +7887,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7607
7887
|
productCatalogId: z.string().optional().describe('CATALOG (dynamic product) ADS — the PRODUCT CATALOG this campaign advertises, from list_meta_catalogs. This is the CAMPAIGN-level half of a catalog ad; the AD SET takes a productSetId and the creative takes the same set. Passing a product SET id here is refused by name with the level it belongs on. The binding is READ BACK from Meta before it is reported, so a 200 is never narrated as “bound”.'),
|
|
7608
7888
|
},
|
|
7609
7889
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), status: z.string().optional(), dailyBudgetUsd: z.number().optional() },
|
|
7610
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7890
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7611
7891
|
}, wrap(async (a) => {
|
|
7612
7892
|
const d = await apiPost('/api/meta/campaign', a);
|
|
7613
7893
|
return ok(`Created campaign ${d.campaignId} (PAUSED${d.dailyBudgetUsd ? `, $${d.dailyBudgetUsd}/day` : ''}). ${d.note || ''}`, d);
|
|
@@ -7621,7 +7901,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7621
7901
|
confirm: z.boolean().optional().describe('REQUIRED true to activate (real spend) — set only after the user explicitly approved the budget'),
|
|
7622
7902
|
},
|
|
7623
7903
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), status: z.string().optional() },
|
|
7624
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7904
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
7625
7905
|
}, wrap(async (a) => {
|
|
7626
7906
|
const d = await apiPost('/api/meta/campaign/status', a);
|
|
7627
7907
|
return ok(d.note || `Campaign ${a.campaignId} → ${a.status}.`, d);
|
|
@@ -7720,7 +8000,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7720
8000
|
pageId: z.string().optional().describe('Page id from list_meta_pages; omit = first Page'),
|
|
7721
8001
|
},
|
|
7722
8002
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), adSetId: z.string().optional(), count: z.number().optional(), status: z.string().optional(), dailyBudgetUsd: z.number().optional(), summary: z.string().optional(), previews: z.array(z.any()).optional(), previewExpiresHours: z.number().optional() },
|
|
7723
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8003
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7724
8004
|
}, wrap(async (a) => {
|
|
7725
8005
|
const { imageUrls, ...rest } = a;
|
|
7726
8006
|
const d = await apiPost('/api/meta/ad', imageUrls?.length ? { ...rest, urls: imageUrls } : rest);
|
|
@@ -7740,7 +8020,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7740
8020
|
pageId: z.string().optional().describe('Page id; omit = first Page'),
|
|
7741
8021
|
},
|
|
7742
8022
|
outputSchema: { ok: z.boolean().optional(), adSetId: z.string().optional(), campaignId: z.string().optional(), summary: z.string().optional() },
|
|
7743
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8023
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7744
8024
|
}, wrap(async (a) => {
|
|
7745
8025
|
const d = await apiPost('/api/meta/adset', a);
|
|
7746
8026
|
return ok(d.summary || `Meta returned ad set ${d.adSetId} but no verified summary.`, d);
|
|
@@ -7905,7 +8185,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7905
8185
|
title: 'Create or edit an Instagram DM automation',
|
|
7906
8186
|
description: 'Create (no id) or edit (id) an automation that DMs people automatically: a comment with a keyword on one post / the next post / any post gets a private reply, or a DM / story reply / ig.me link gets an answer. Optional link buttons, public comment replies, follow gate and dry run. Once per person per post; Meta allows private replies within 7 days of the comment. It DMs real people, so confirm the text first. Free, no AI on the send path.',
|
|
7907
8187
|
inputSchema: { id: z.string().optional(), ...dmaRuleShape },
|
|
7908
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8188
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7909
8189
|
}, wrap(async (a) => {
|
|
7910
8190
|
const d = await apiPost('/api/dm-automations', { ...a, channel: 'instagram' });
|
|
7911
8191
|
return ok(d.summary, d);
|
|
@@ -7968,9 +8248,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7968
8248
|
}));
|
|
7969
8249
|
server.registerTool('save_dm_automation', {
|
|
7970
8250
|
title: 'Create or edit a DM automation (Instagram, Facebook, X)',
|
|
7971
|
-
description: 'Create (no id) or edit (id)
|
|
8251
|
+
description: 'Create (no id) or edit (id) an AUTO-DM / AUTO-REPLY automation: "when someone comments GUIDE on my posts, DM them my guide link", "reply to anyone who DMs PRICE with our pricing page", "reply to people who mention us on X". PAUSE one with {id, enabled:false}; PRACTICE MODE (dryRun:true) logs what it would send and sends nothing. Instagram/Facebook: a keyword comment (one post, the next post, any post; on Facebook also ads) gets a private reply in DMs/Messenger, a DM keyword or ig.me/m.me ref link gets a message, with optional link buttons and rotated public replies. X: a keyword @mention or a reply under your post gets a public reply, a DM keyword gets a DM (X allows an automated DM only after the person DMed you). Once per person per post; a DM saying STOP opts that person out. Instagram and Facebook are free; X is billed in credits for every mention, reply or DM X delivers to the account plus each reply or DM sent, and running out stops the X rules. It messages real people, so confirm the text first.',
|
|
7972
8252
|
inputSchema: { id: z.string().optional(), ...dmaAnyShape },
|
|
7973
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8253
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7974
8254
|
}, wrap(async (a) => {
|
|
7975
8255
|
const d = await apiPost('/api/dm-automations', a);
|
|
7976
8256
|
return ok(`${d.summary}${d.notes?.length ? `\n${d.notes.join('\n')}` : ''}`, d);
|
|
@@ -8025,7 +8305,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8025
8305
|
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
8026
8306
|
},
|
|
8027
8307
|
outputSchema: { igId: z.string().optional(), account: z.string().optional(), done: z.array(z.string()).optional(), failed: z.array(z.string()).optional(), summary: z.string().optional(), adPermissions: z.any().optional(), tagApproval: z.any().optional(), warning: z.string().optional(), note: z.string().optional(), access: z.string().optional() },
|
|
8028
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8308
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
8029
8309
|
}, wrap(async (a) => {
|
|
8030
8310
|
const d = await apiPost('/api/meta/partnership/creator', a);
|
|
8031
8311
|
return ok(`${d.summary}${d.metaWriteErrorNote ? `\n\nℹ Meta answered the write itself with an error and performed it anyway — Meta said: “${d.metaWriteError}”. ${d.metaWriteErrorNote} The line above is read back from Meta’s own list, so report THAT, not the error.` : ''}${d.warning ? `\n\n⚠ ${d.warning}` : ''}${d.adPermissions ? `\n\nAd-permission list now: ${d.adPermissions.summary}` : ''}${d.tagApproval ? `\nTag-approval list now: ${d.tagApproval.summary}` : ''}${d.note ? `\n\n${d.note}` : ''}`, d);
|
|
@@ -8103,7 +8383,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8103
8383
|
// routes, so there is no second set of rules here to drift.
|
|
8104
8384
|
server.registerTool('list_meta_catalogs', {
|
|
8105
8385
|
title: 'List the merchant’s Meta product catalogs',
|
|
8106
|
-
description: 'The PRODUCT CATALOGS this Meta login can advertise from — id, name, how many products each holds, and which business portfolio owns it, WITH THAT PORTFOLIO’S NUMERIC ID. It also lists every business portfolio this login administers (`portfolios`), whether or not it holds a catalog yet — so this is where the businessId create_meta_catalog needs comes from, and the two tools chain with no trip to Business Manager. A catalog is the input to Advantage+ catalog ads (dynamic product ads)
|
|
8386
|
+
description: 'The PRODUCT CATALOGS this Meta login can advertise from — id, name, how many products each holds, and which business portfolio owns it, WITH THAT PORTFOLIO’S NUMERIC ID. It also lists every business portfolio this login administers (`portfolios`), whether or not it holds a catalog yet — so this is where the businessId create_meta_catalog needs comes from, and the two tools chain with no trip to Business Manager. A catalog is the input to Advantage+ catalog ads (dynamic product ads): one campaign that shows each person the products they are most likely to buy, with the image, name and price taken from the catalog rather than from a render. START HERE for any "advertise my products" ask, then list_meta_product_sets to pick what to advertise. AN EMPTY LIST IS A FACT ABOUT WHAT THIS LOGIN ADMINISTERS, NEVER ABOUT THE MERCHANT: Meta serves catalogs off the BUSINESS PORTFOLIO, so a full Shopify-synced catalog is invisible here if the connected user is not an admin of the portfolio that owns it — read the `note` and say that, never "you have no catalog". A portfolio that could not be read is reported as `unreadable` rather than dropped. Read-only, 0 credits.',
|
|
8107
8387
|
inputSchema: {},
|
|
8108
8388
|
outputSchema: { count: z.number().optional(), catalogs: z.array(z.any()).optional(), portfolios: z.array(z.any()).optional(), unreadable: z.array(z.any()).optional(), note: z.string().optional(), warning: z.string().optional(), flow: z.string().optional() },
|
|
8109
8389
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -8186,7 +8466,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8186
8466
|
partnerIntegration: z.record(z.any()).optional().describe('partner-integration settings object'),
|
|
8187
8467
|
additionalVerticalOption: z.enum(['LOCAL_DA_CATALOG', 'LOCAL_PRODUCTS']).optional().describe('additional vertical option'),
|
|
8188
8468
|
},
|
|
8189
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8469
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
8190
8470
|
}, wrap(async (a) => {
|
|
8191
8471
|
const d = await apiPost('/api/meta/catalog/update', a);
|
|
8192
8472
|
return ok(d.summary, d);
|
|
@@ -8464,7 +8744,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8464
8744
|
}));
|
|
8465
8745
|
server.registerTool('create_meta_lead_form', {
|
|
8466
8746
|
title: 'Create a Meta instant (lead) form',
|
|
8467
|
-
description: 'Create an INSTANT LEAD FORM on a connected Facebook Page — the in-app form a Meta lead ad opens instead of sending someone to a website,
|
|
8747
|
+
description: 'Create an INSTANT LEAD FORM on a connected Facebook Page — the in-app form a Meta lead ad opens instead of sending someone to a website, so a person can sign up without leaving the app. `questions` takes Meta’s own type names, e.g. ["FULL_NAME","EMAIL","PHONE"]; for anything bespoke pass {type:"CUSTOM", label:"What size fleet do you run?"} and add options:[…] to make it a dropdown. Ask FEWER questions than you think — every extra field costs completions. privacyPolicyUrl is REQUIRED: the form collects real people’s contact details and has to say where their data goes. Optional: headline, contextCard {title, bullets[], buttonText} for the why-should-I screen, thankYou {title, body, buttonType, buttonText, websiteUrl}, locale, followUpActionUrl. CREATING A FORM SPENDS NOTHING and publishes nothing — it is invisible to the public until an ad points at it (create_meta_ad with objective:"OUTCOME_LEADS" + leadFormId), and that ad is created PAUSED. Read the submissions with read_meta_leads. NEEDS pages_manage_ads — see list_meta_lead_forms for what to do if Meta refuses it.',
|
|
8468
8748
|
inputSchema: {
|
|
8469
8749
|
name: z.string().describe('internal name for the form — not shown to the person filling it in'),
|
|
8470
8750
|
questions: z.array(z.any()).describe('Meta question types as strings ("FULL_NAME","EMAIL","PHONE","COMPANY_NAME","JOB_TITLE","CITY","ZIP","WORK_EMAIL","DATE_TIME"…) or objects {type,label,key,options[]} for CUSTOM'),
|
|
@@ -8576,7 +8856,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8576
8856
|
endTime: z.string().optional(),
|
|
8577
8857
|
dryRun: z.boolean().optional(),
|
|
8578
8858
|
},
|
|
8579
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8859
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
8580
8860
|
}, wrap(async (a) => { const d = await apiPost('/api/meta/marketing-messages/campaign', a); return ok(d.note, d); }));
|
|
8581
8861
|
server.registerTool('list_messenger_marketing_campaigns', {
|
|
8582
8862
|
title: 'List Messenger marketing-message campaigns',
|
|
@@ -8786,7 +9066,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8786
9066
|
}));
|
|
8787
9067
|
server.registerTool('create_merchant_promotion', {
|
|
8788
9068
|
title: 'Create a Merchant Center promotion (sale badge)',
|
|
8789
|
-
description: "PUT A SALE BADGE ON THE MERCHANT'S SHOPPING LISTINGS — the most seasonal thing a retail advertiser does
|
|
9069
|
+
description: "PUT A SALE BADGE ON THE MERCHANT'S SHOPPING LISTINGS — the most seasonal thing a retail advertiser does. It creates the promotion and resolves (or creates) the promotion DATA SOURCE that country/language needs automatically; a promotion data source is its own kind and cannot be the product feed. IT IS NOT LIVE ON CREATE: Google validates promotions asynchronously and can take days to approve one, so report it as SUBMITTED and re-read with list_merchant_promotions. Everything Google marks Required is refused up front by name, including the pairings its own 400 does not mention — PERCENT_OFF needs percentOff, MONEY_OFF needs moneyOffAmount, GENERIC_CODE needs genericRedemptionCode — because otherwise Google accepts the promotion and disapproves it hours later where nobody is watching. 0 credits.",
|
|
8790
9070
|
inputSchema: {
|
|
8791
9071
|
merchantCenterId: z.string().describe('from list_merchant_accounts'),
|
|
8792
9072
|
promotionId: z.string().describe("YOUR OWN id for the promotion, e.g. 'black-friday-2026'"),
|
|
@@ -9206,7 +9486,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9206
9486
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
9207
9487
|
},
|
|
9208
9488
|
outputSchema: { ok: z.boolean().optional(), campaignResourceName: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), budgetResourceName: z.string().optional(), note: z.string().optional() },
|
|
9209
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9489
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9210
9490
|
}, wrap(async (a) => {
|
|
9211
9491
|
const d = await apiPost('/api/google-ads/campaign', a);
|
|
9212
9492
|
// d.note is written from a READ-BACK of the whole tree (the route 502s rather than returning an unverified id),
|
|
@@ -9229,7 +9509,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9229
9509
|
loginCustomerId: z.string().optional(),
|
|
9230
9510
|
},
|
|
9231
9511
|
outputSchema: { ok: z.boolean().optional(), adGroupId: z.string().optional(), adGroupResourceName: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
9232
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9512
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9233
9513
|
}, wrap(async (a) => {
|
|
9234
9514
|
const d = await apiPost('/api/google-ads/ad-group', a);
|
|
9235
9515
|
return ok(d.note, d);
|
|
@@ -9247,7 +9527,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9247
9527
|
loginCustomerId: z.string().optional(),
|
|
9248
9528
|
},
|
|
9249
9529
|
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), adResourceName: z.string().optional(), type: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
9250
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9530
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9251
9531
|
}, wrap(async (a) => {
|
|
9252
9532
|
const d = await apiPost('/api/google-ads/ad', a);
|
|
9253
9533
|
return ok(d.note, d);
|
|
@@ -9305,7 +9585,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9305
9585
|
loginCustomerId: z.string().optional(),
|
|
9306
9586
|
},
|
|
9307
9587
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), biddingStrategyType: z.string().optional(), note: z.string().optional() },
|
|
9308
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9588
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9309
9589
|
}, wrap(async (a) => {
|
|
9310
9590
|
const d = await apiPost('/api/google-ads/bidding', a);
|
|
9311
9591
|
return ok(d.note, d);
|
|
@@ -9337,7 +9617,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9337
9617
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
9338
9618
|
},
|
|
9339
9619
|
outputSchema: { ok: z.boolean().optional(), budgetResourceName: z.string().optional(), dailyBudgetUsd: z.number().optional() },
|
|
9340
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9620
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9341
9621
|
}, wrap(async (a) => {
|
|
9342
9622
|
const d = await apiPost('/api/google-ads/budget', a);
|
|
9343
9623
|
return ok(`Budget set to $${d.dailyBudgetUsd}/day (${d.budgetResourceName}).`, d);
|
|
@@ -9549,7 +9829,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9549
9829
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
9550
9830
|
},
|
|
9551
9831
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), status: z.string().optional(), assetGroupResourceName: z.string().optional(), brandGuidelinesEnabled: z.boolean().optional(), note: z.string().optional() },
|
|
9552
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9832
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9553
9833
|
}, wrap(async (a) => {
|
|
9554
9834
|
const d = await apiPost('/api/google-ads/performance-max', a);
|
|
9555
9835
|
return ok(`${d.note || 'Performance Max campaign created PAUSED.'}${d.dryRun ? '' : ' To make it spend, use set_google_ads_status(confirm:true) after the user approves.'}`, d);
|
|
@@ -11162,7 +11442,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11162
11442
|
description: "Cancel a Stripe subscription: at the end of the current period by default (the customer keeps access until then), or immediately:true to end it now. ALWAYS needs confirm:true, test keys included. The read-back distinguishes the two — a period-end cancel reports the date it will end and the status it keeps until then, an immediate one reports status canceled. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
11163
11443
|
inputSchema: { subscriptionId: z.string(), immediately: z.boolean().optional(), confirm: z.boolean().optional() },
|
|
11164
11444
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
11165
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11445
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
11166
11446
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription/cancel', a); return ok(d.note, d); }));
|
|
11167
11447
|
server.registerTool('refund_stripe_charge', {
|
|
11168
11448
|
title: 'refund stripe charge',
|
|
@@ -11354,7 +11634,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11354
11634
|
adGroup: z.object({ name: z.string().optional(), cpcBid: z.number().optional(), language: z.string().optional(), status: z.enum(['Active', 'Paused']).optional(), ad: z.object(msAdShape).optional(), keywords: msKeywordShape.optional() }).optional().describe('build the serving tree in the same call — WITHOUT this you get a campaign shell that can never show an ad'),
|
|
11355
11635
|
},
|
|
11356
11636
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
11357
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11637
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11358
11638
|
}, wrap(async (a) => {
|
|
11359
11639
|
const d = await apiPost('/api/microsoft-ads/campaign', a);
|
|
11360
11640
|
return ok(`${d.note} To make it spend, use set_microsoft_ads_status(confirm:true) after the user approves.`, d);
|
|
@@ -11374,7 +11654,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11374
11654
|
confirm: z.boolean().optional().describe('REQUIRED true to create this Active under a LIVE campaign'),
|
|
11375
11655
|
},
|
|
11376
11656
|
outputSchema: { ok: z.boolean().optional(), adGroupId: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
11377
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11657
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11378
11658
|
}, wrap(async (a) => {
|
|
11379
11659
|
const d = await apiPost('/api/microsoft-ads/ad-group', a);
|
|
11380
11660
|
return ok(d.note, d);
|
|
@@ -11390,7 +11670,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11390
11670
|
confirm: z.boolean().optional().describe('REQUIRED true to create this Active in a LIVE ad group'),
|
|
11391
11671
|
},
|
|
11392
11672
|
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), adGroupId: z.string().optional(), status: z.string().optional(), editorialStatus: z.string().optional(), note: z.string().optional() },
|
|
11393
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11673
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11394
11674
|
}, wrap(async (a) => {
|
|
11395
11675
|
const d = await apiPost('/api/microsoft-ads/ad', a);
|
|
11396
11676
|
return ok(d.note, d);
|
|
@@ -11453,7 +11733,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11453
11733
|
confirm: z.boolean().optional().describe('REQUIRED true to change the budget of a LIVE campaign'),
|
|
11454
11734
|
},
|
|
11455
11735
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), dailyBudget: z.number().optional(), note: z.string().optional() },
|
|
11456
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11736
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
11457
11737
|
}, wrap(async (a) => {
|
|
11458
11738
|
const d = await apiPost('/api/microsoft-ads/budget', a);
|
|
11459
11739
|
return ok(d.note, d);
|
|
@@ -11471,7 +11751,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11471
11751
|
confirm: z.boolean().optional().describe('REQUIRED true to set Active (real spend)'),
|
|
11472
11752
|
},
|
|
11473
11753
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), status: z.string().optional(), verifiedStatus: z.string().optional(), note: z.string().optional() },
|
|
11474
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11754
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
11475
11755
|
}, wrap(async (a) => {
|
|
11476
11756
|
const d = await apiPost('/api/microsoft-ads/status', a);
|
|
11477
11757
|
// d.note is written from the READ-BACK and says so when Microsoft reports a status different to the one we asked
|
|
@@ -11702,7 +11982,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11702
11982
|
biddingStrategy: z.enum(['AUTO_BIDDING_WITH_CPM_BILLING', 'TARGET_GOAL_WITH_CPI_BILLING']).optional(),
|
|
11703
11983
|
},
|
|
11704
11984
|
outputSchema: { ...alOut, campaign: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
|
|
11705
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11985
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11706
11986
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/campaign', a); return ok(d.note, d); }));
|
|
11707
11987
|
server.registerTool('update_applovin_ads_campaign', {
|
|
11708
11988
|
title: 'Update an AppLovin Ads campaign',
|
|
@@ -11721,7 +12001,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11721
12001
|
description: 'LIVE or PAUSED for an AppLovin campaign (campaignId) or creative set (creativeSetId), read back. LIVE starts real ad spend: show the user the budget, get an explicit yes, then call with confirm:true. Pausing is always safe. AppLovin’s API has no delete; pause instead.' + AL_CM_NOTE,
|
|
11722
12002
|
inputSchema: { level: z.enum(['campaign', 'creative_set']).optional().describe('inferred from the id you pass'), campaignId: z.string().optional(), creativeSetId: z.string().optional(), status: z.enum(['LIVE', 'PAUSED']), confirm: z.boolean().optional().describe('required true for LIVE') },
|
|
11723
12003
|
outputSchema: { ...alOut, level: z.string().optional(), status: z.string().nullable().optional(), unchanged: z.boolean().optional() },
|
|
11724
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12004
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
11725
12005
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/status', a); return ok(d.note, d); }));
|
|
11726
12006
|
server.registerTool('list_applovin_ads_creative_sets', {
|
|
11727
12007
|
title: 'List AppLovin creative sets',
|
|
@@ -11833,7 +12113,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11833
12113
|
});
|
|
11834
12114
|
server.registerTool('list_openai_ads_campaigns', {
|
|
11835
12115
|
title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
|
|
11836
|
-
description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected
|
|
12116
|
+
description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected under Settings > Connectors > ChatGPT Ads in the Hermoso app; one connection covers one ad account.',
|
|
11837
12117
|
inputSchema: {
|
|
11838
12118
|
campaignId: z.string().optional().describe('list this campaign’s ad groups'),
|
|
11839
12119
|
adGroupId: z.string().optional().describe('list this ad group’s ads'),
|
|
@@ -11922,7 +12202,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11922
12202
|
endTime: z.string().optional(),
|
|
11923
12203
|
},
|
|
11924
12204
|
outputSchema: { campaignId: z.string().optional(), accountId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
11925
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12205
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
11926
12206
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/campaign', a); return ok(d.note, d); }));
|
|
11927
12207
|
server.registerTool('set_x_ads_status', {
|
|
11928
12208
|
title: 'Pause or activate an X campaign or line item',
|
|
@@ -11935,7 +12215,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11935
12215
|
confirm: z.boolean().optional().describe('required to set ACTIVE — real money'),
|
|
11936
12216
|
},
|
|
11937
12217
|
outputSchema: { campaignId: z.string().nullable().optional(), lineItemId: z.string().nullable().optional(), status: z.string().optional(), parentStatus: z.string().nullable().optional(), note: z.string().optional() },
|
|
11938
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12218
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
11939
12219
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/status', a); return ok(d.note, d); }));
|
|
11940
12220
|
// ── MICROSOFT BULK SERVICE + MEASUREMENT (2026-08-20) ──────────────────────────────────────────────────────────
|
|
11941
12221
|
// The last unbuilt Bing Ads service. The DOWNLOAD half reaches ~185 record types nothing else in the product can
|
|
@@ -12208,7 +12488,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12208
12488
|
budgetOrderIds: z.array(z.string()).optional().describe('budget order id(s) from list_apple_ads_budget_orders to assign this campaign to — a budget order is a spend CEILING SHARED across every campaign assigned to it, and it works ALONGSIDE dailyBudget rather than instead of it. Without this a budget order can never be drawn from.'),
|
|
12209
12489
|
},
|
|
12210
12490
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), bornPaused: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
12211
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12491
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12212
12492
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/campaign', a); return ok(`Apple Ads campaign created PAUSED:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
12213
12493
|
|
|
12214
12494
|
server.registerTool('create_apple_ads_ad_group', {
|
|
@@ -12240,7 +12520,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12240
12520
|
}).optional().describe('Who and where this ad group reaches. For Apple Maps, locationGroupIds is what says which shops it promotes.'),
|
|
12241
12521
|
},
|
|
12242
12522
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), bornPaused: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
12243
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12523
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12244
12524
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/adgroup', a); return ok(`Apple Ads ad group created PAUSED:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
12245
12525
|
|
|
12246
12526
|
server.registerTool('add_apple_ads_keywords', {
|
|
@@ -12300,7 +12580,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12300
12580
|
confirm: z.boolean().optional().describe('REQUIRED to ENABLE — it arms real spend. Pausing needs none.'),
|
|
12301
12581
|
},
|
|
12302
12582
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), armsSpend: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
12303
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12583
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12304
12584
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/status', a); return ok(`Apple Ads ${d.level} ${d.id} → ${d.requested?.status} (verified: ${d.verified}${d.armsSpend ? ', SPEND ARMED' : ''}):\n${d.summary}\n${d.note || ''}`, d); }));
|
|
12305
12585
|
|
|
12306
12586
|
server.registerTool('delete_apple_ads_object', {
|
|
@@ -12397,7 +12677,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12397
12677
|
defaultLocale: z.string().optional().describe('Apple Maps only, e.g. "en-US".'),
|
|
12398
12678
|
},
|
|
12399
12679
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), systemStatus: z.string().optional(), spendsNothing: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
12400
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12680
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12401
12681
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/creative', a); return ok(`Apple Ads creative created:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
12402
12682
|
|
|
12403
12683
|
server.registerTool('list_apple_ads_ads', {
|
|
@@ -12423,7 +12703,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12423
12703
|
name: z.string().describe('REQUIRED.'),
|
|
12424
12704
|
},
|
|
12425
12705
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), bornPaused: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
12426
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12706
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12427
12707
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/ad', a); return ok(`Apple Ads ad created PAUSED:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
12428
12708
|
|
|
12429
12709
|
server.registerTool('upload_apple_ads_asset', {
|
|
@@ -12895,7 +13175,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12895
13175
|
endTime: z.string().optional(),
|
|
12896
13176
|
},
|
|
12897
13177
|
outputSchema: { lineItemId: z.string().optional(), accountId: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
12898
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13178
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12899
13179
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/line-item', a); return ok(d.note, d); }));
|
|
12900
13180
|
server.registerTool('create_x_ads_promoted_tweet', {
|
|
12901
13181
|
title: 'Attach a post to an X line item (paused)',
|
|
@@ -12905,7 +13185,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12905
13185
|
tweetIds: z.array(z.string()).describe('post ids — digits only, from the end of the post URL'),
|
|
12906
13186
|
},
|
|
12907
13187
|
outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), promotedTweetIds: z.array(z.string()).optional(), verifiedCount: z.number().nullable().optional(), note: z.string().optional() },
|
|
12908
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13188
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12909
13189
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/promoted-tweet', a); return ok(d.note, d); }));
|
|
12910
13190
|
server.registerTool('add_x_ads_targeting', {
|
|
12911
13191
|
title: 'Target an X ads line item',
|
|
@@ -13099,7 +13379,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13099
13379
|
}).optional().describe('build the ad group (and its ad) in the same call — a campaign alone cannot serve'),
|
|
13100
13380
|
},
|
|
13101
13381
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), adGroupId: z.string().optional(), tree: z.any().optional(), note: z.string().optional() },
|
|
13102
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13382
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13103
13383
|
}, wrap(async (a) => {
|
|
13104
13384
|
const d = await apiPost('/api/openai-ads/campaign', a);
|
|
13105
13385
|
return ok(`${d.note} To make it spend, use set_openai_ads_status(confirm:true) after the user approves.`, d);
|
|
@@ -13119,7 +13399,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13119
13399
|
confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE under a live campaign (real spend)'),
|
|
13120
13400
|
},
|
|
13121
13401
|
outputSchema: { ok: z.boolean().optional(), adGroupId: z.string().optional(), tree: z.any().optional(), note: z.string().optional() },
|
|
13122
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13402
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13123
13403
|
}, wrap(async (a) => {
|
|
13124
13404
|
const d = await apiPost('/api/openai-ads/ad-group', a);
|
|
13125
13405
|
return ok(d.note, d);
|
|
@@ -13135,7 +13415,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13135
13415
|
confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE in a live ad group (real spend)'),
|
|
13136
13416
|
},
|
|
13137
13417
|
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), ad: z.any().optional(), note: z.string().optional() },
|
|
13138
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13418
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13139
13419
|
}, wrap(async (a) => {
|
|
13140
13420
|
const d = await apiPost('/api/openai-ads/ad', a);
|
|
13141
13421
|
return ok(d.note, d);
|
|
@@ -13205,7 +13485,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13205
13485
|
description: 'Change a ChatGPT Ads campaign’s budget — a daily cap, a lifetime cap, or both, in the ad account’s currency. A LIFETIME (total) budget is PACED by ChatGPT Ads to spread spend evenly across the campaign’s dates (actual daily spend still varies with available delivery), so a total budget is not a first-come-first-served pool. ChatGPT Ads’ own minimum for a DAILY budget is 25.00 (measured live 2026-08-05; a LIFETIME budget has no such floor, so use one to spend less than that in total). Raising it on a LIVE (active) campaign increases real spend immediately, so you MUST show the user the old and new amounts, get an explicit yes, then call with confirm:true. Lowering it or changing a paused campaign is safe. The campaign is READ BACK after the change and the note is built from that.',
|
|
13206
13486
|
inputSchema: { campaignId: z.string(), dailyBudget: z.number().optional(), lifetimeBudget: z.number().optional(), confirm: z.boolean().optional().describe('REQUIRED true when the campaign is live') },
|
|
13207
13487
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), campaign: z.any().optional(), note: z.string().optional() },
|
|
13208
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13488
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13209
13489
|
}, wrap(async (a) => {
|
|
13210
13490
|
const d = await apiPost('/api/openai-ads/budget', a);
|
|
13211
13491
|
return ok(d.note, d);
|
|
@@ -13438,7 +13718,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13438
13718
|
server.registerTool('set_openai_ads_daily_spend_limit', {
|
|
13439
13719
|
title: 'Set the ChatGPT Ads account daily spending limit', description: 'Create or change the ACCOUNT-WIDE DAILY SPENDING LIMIT on ChatGPT Ads: the most all campaigns together may spend each day, renewing at midnight in the account timezone, until it is removed or reaches its optional end date. OpenAI offers it only on ad accounts with postpaid INVOICE billing and it needs billing-management permission; a card-billed account is refused whatever key is used. amount is per day in the account currency and is required even when only the end date changes (0 stops account spend). A NEW limit starts tomorrow or later: list_openai_ads_spend_windows names the earliest start date. On an existing limit leave startDate out, because its start cannot change. An account can hold SEVERAL non-overlapping daily rules (list_openai_ads_spend_windows returns each with a windowId): pass windowId to change one, or newRule:true (with startDate) to add another; with more than one rule OpenAI requires the windowId. endDate is exclusive; noEndDate:true clears it. A daily limit and a date-range window cannot overlap. Lowering it can stop delivery and raising it lets campaigns spend up to their own budgets, so show the user the current and new values first: without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply states what would. The result is read back from ChatGPT Ads.',
|
|
13440
13720
|
inputSchema: { amount: z.number(), windowId: z.string().optional().describe('the daily rule to change, from list_openai_ads_spend_windows; required when the account has more than one'), newRule: z.boolean().optional().describe('true adds another non-overlapping daily rule instead of editing one'), expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), startDate: z.string().optional().describe('YYYY-MM-DD, first day of a NEW daily limit (tomorrow or later)'), endDate: z.string().optional().describe('YYYY-MM-DD, exclusive'), noEndDate: z.boolean().optional().describe('true removes the end date'), confirm: z.boolean().optional() },
|
|
13441
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13721
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
13442
13722
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/daily-spend-limit', a))));
|
|
13443
13723
|
server.registerTool('remove_openai_ads_daily_spend_limit', {
|
|
13444
13724
|
title: 'Remove the ChatGPT Ads account daily spending limit', description: 'Remove the account-wide DAILY spending limit on ChatGPT Ads. This takes a spending CEILING off the whole account, so campaigns may then spend up to their own budgets every day; date-range spend-limit windows are not touched. Like every account spending-limit request it needs postpaid INVOICE billing and billing-management permission; a card-billed account is refused whatever key is used. Without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply names the limit that would be removed. With several daily rules, pass windowId to name the one to remove (OpenAI requires it then). The result is read back from ChatGPT Ads.',
|
|
@@ -13577,7 +13857,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13577
13857
|
adGroup: z.object({ ...pinAdGroupShape, name: z.string().optional(), ad: z.object(pinAdShape).optional() }).optional().describe('build the serving tree in the same call — WITHOUT this you get a campaign shell that can never show an ad'),
|
|
13578
13858
|
},
|
|
13579
13859
|
outputSchema: { ok: z.boolean().optional(), adAccountId: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
13580
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13860
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13581
13861
|
}, wrap(async (a) => {
|
|
13582
13862
|
const d = await apiPost('/api/pinterest/ads-campaign', a);
|
|
13583
13863
|
return ok(`${d.note} To make it spend, use set_pinterest_ads_status(confirm:true) after the user approves.`, d);
|
|
@@ -13593,7 +13873,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13593
13873
|
confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE under a LIVE campaign'),
|
|
13594
13874
|
},
|
|
13595
13875
|
outputSchema: { ok: z.boolean().optional(), adGroupId: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
13596
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13876
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13597
13877
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-ad-group', a); return ok(d.note, d); }));
|
|
13598
13878
|
server.registerTool('create_pinterest_ads_ad', {
|
|
13599
13879
|
title: 'Create a Pinterest ad',
|
|
@@ -13606,7 +13886,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13606
13886
|
confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE in a live ad group'),
|
|
13607
13887
|
},
|
|
13608
13888
|
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), adGroupId: z.string().optional(), status: z.string().optional(), reviewStatus: z.string().optional(), note: z.string().optional() },
|
|
13609
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13889
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13610
13890
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-ad', a); return ok(d.note, d); }));
|
|
13611
13891
|
// EDITING (2026-08-11) — note this is deliberately NOT a `// ── banner ──` comment: tools/docs-data.mjs treats one
|
|
13612
13892
|
// as a new docs SECTION, and these two belong in PINTEREST ADS, not in a group of their own.
|
|
@@ -13687,7 +13967,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13687
13967
|
confirm: z.boolean().optional().describe('REQUIRED true to change the budget of a LIVE campaign'),
|
|
13688
13968
|
},
|
|
13689
13969
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), dailyBudget: z.number().nullable().optional(), lifetimeBudget: z.number().nullable().optional(), note: z.string().optional() },
|
|
13690
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13970
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13691
13971
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-budget', a); return ok(d.note, d); }));
|
|
13692
13972
|
server.registerTool('set_pinterest_ads_status', {
|
|
13693
13973
|
title: 'Activate, pause or archive a Pinterest campaign / ad group / ad',
|
|
@@ -13913,7 +14193,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13913
14193
|
useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog (create_reddit_ads_ad_group with productSetId + shoppingType). Frozen once created'),
|
|
13914
14194
|
},
|
|
13915
14195
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), useCatalog: z.boolean().optional(), adAccountId: z.string().optional() },
|
|
13916
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
14196
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
13917
14197
|
}, wrap(async (a) => {
|
|
13918
14198
|
const d = await apiPost('/api/reddit-ads/campaigns', a);
|
|
13919
14199
|
return ok(`Created Reddit campaign "${d.name}" (${d.id}) with objective ${d.objective}, status ${d.status} \u2014 PAUSED and spending nothing. Next: create_reddit_ads_post for the creative, then create_reddit_ads_ad_group under this campaign, then create_reddit_ads_ad.`, d);
|
|
@@ -13967,7 +14247,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13967
14247
|
})).optional().describe('weekly dayparting windows — omit to run all week'),
|
|
13968
14248
|
},
|
|
13969
14249
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), effectiveStatus: z.string().optional(), budget: z.number().nullable().optional(), optimizationGoal: z.string().nullable().optional(), viewThroughConversionType: z.string().nullable().optional(), productSetId: z.string().nullable().optional(), shoppingType: z.string().nullable().optional(), note: z.string().optional() },
|
|
13970
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
14250
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
13971
14251
|
}, wrap(async (a) => {
|
|
13972
14252
|
const d = await apiPost('/api/reddit-ads/ad-groups', a);
|
|
13973
14253
|
return ok(`${d.note} To make it spend, use set_reddit_ads_status(confirm:true) after the user approves.`, d);
|
|
@@ -14011,7 +14291,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14011
14291
|
eventTrackers: z.array(z.object({ type: z.enum(['CLICK', 'VIEW']), url: z.string() })).optional().describe('third-party measurement URLs; only Reddit-approved providers are accepted'),
|
|
14012
14292
|
},
|
|
14013
14293
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), effectiveStatus: z.string().optional(), postId: z.string().nullable().optional(), note: z.string().optional() },
|
|
14014
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
14294
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14015
14295
|
}, wrap(async (a) => {
|
|
14016
14296
|
const d = await apiPost('/api/reddit-ads/ads', a);
|
|
14017
14297
|
return ok(`${d.note} To make it spend, use set_reddit_ads_status(confirm:true) after the user approves.`, d);
|
|
@@ -14064,7 +14344,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14064
14344
|
supplementaryText: z.string().optional().describe('up to 100 characters'),
|
|
14065
14345
|
userGeneratedContent: z.enum(['OPT_IN', 'OPT_OUT']).optional().describe('let Reddit add user-generated content enhancements'),
|
|
14066
14346
|
},
|
|
14067
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
14347
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14068
14348
|
}, wrap(async (a) => { const d = await apiPost('/api/reddit-ads/max-campaign', a); return ok(d.note, d); }));
|
|
14069
14349
|
server.registerTool('get_reddit_ads_max_template', {
|
|
14070
14350
|
title: 'Read a Reddit Max template ad',
|
|
@@ -14530,11 +14810,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14530
14810
|
title: 'List the TikTok identities an ad can post as',
|
|
14531
14811
|
description: 'List the IDENTITIES on a TikTok advertiser account — the TikTok accounts an ad is allowed to appear as. THIS IS A HARD PREREQUISITE, not a convenience: a TikTok ad carries no identity of its own and there is NO DEFAULT, so create_tiktok_ads_ad refuses without an id from here. Call it, show the user the list, and let them CHOOSE — an ad runs publicly under whichever account is named, so picking one for them is a public mistake on somebody else’s profile. If it comes back empty, no TikTok account has been authorised on this advertiser yet and nothing can be advertised from it; say that rather than guessing an id. Read-only, free.',
|
|
14532
14812
|
inputSchema: {
|
|
14533
|
-
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
14534
|
-
eventSourceType: z.enum(['PIXEL', 'APP']).optional().describe('PIXEL (default) or APP — TikTok publishes no other value'),
|
|
14535
|
-
eventSourceId: z.string().optional().describe('the pixel id (list_tiktok_ads_pixels) or app id. Omit and a single pixel is resolved for you; with several you are asked which.'),
|
|
14536
|
-
searchKeyword: z.string().optional().describe('filter by Custom Conversion name (fuzzy) or id'),
|
|
14537
|
-
pageSize: z.number().optional().describe('1-1000, default 10'),
|
|
14813
|
+
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit to use the connected default'),
|
|
14538
14814
|
},
|
|
14539
14815
|
outputSchema: { advertiserId: z.string().optional(), identities: z.array(z.any()).optional() },
|
|
14540
14816
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -14744,7 +15020,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14744
15020
|
}));
|
|
14745
15021
|
server.registerTool('set_tiktok_post_ad_authorization', {
|
|
14746
15022
|
title: "Turn Spark-Ads authorization on or off for the brand’s own post",
|
|
14747
|
-
description: "Turn TikTok's 'Ad authorization' setting ON or OFF for a post the authorized account owns — THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM.
|
|
15023
|
+
description: "Turn TikTok's 'Ad authorization' setting ON or OFF for a post the authorized account owns — THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM. For somebody ELSE's post the code still comes from that creator, copied out of the TikTok app. Turning it on mints the code, which you then hand to authorize_tiktok_ads_spark_post so an ad account may promote the post. IT IS CONFIRM-GATED ON THE WAY ON, and not because it spends: TikTok changes the post's privacy to 'Available for Ads', sends it to their ad review team, says the post 'may also appear as an ad on third party platforms', and treats the call as accepting their Advertising Content Terms on the owner's behalf. Turning it OFF needs no confirm — but TikTok REFUSES to turn it off while an active Spark Ad is using the post, so pause those campaigns first. authorizationDays must be one of 7, 30, 60, 180, 365. THE ANSWER IS THE READ-BACK: the reply carries the post's real authorization status and its code, because TikTok's own response body is empty. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).",
|
|
14748
15024
|
inputSchema: {
|
|
14749
15025
|
itemId: z.string().describe('the TikTok post id'),
|
|
14750
15026
|
enabled: z.boolean().describe('true turns Ad authorization ON (and mints the Spark Ads code); false turns it off'),
|
|
@@ -14752,7 +15028,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14752
15028
|
confirm: z.boolean().optional().describe('REQUIRED true when enabling \u2014 it makes the post publicly promotable and accepts TikTok\u2019s advertising terms on the owner\u2019s behalf'),
|
|
14753
15029
|
},
|
|
14754
15030
|
outputSchema: { itemId: z.string().optional(), adPromotable: z.boolean().optional(), authorizationDays: z.number().nullable().optional(), status: z.any().nullable().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
14755
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15031
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14756
15032
|
}, wrap(async (a) => {
|
|
14757
15033
|
const d = await apiPost('/api/tiktok-account/post-authorization', a);
|
|
14758
15034
|
return ok(`${d.note}${d.status?.authCode ? `\nAuthorization code: ${d.status.authCode}` : ''}`, d);
|
|
@@ -15031,7 +15307,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15031
15307
|
appPromotionType: z.enum(['APP_INSTALL', 'APP_RETARGETING', 'MINIS']).optional().describe('REQUIRED when the objective is APP_PROMOTION. MINIS promotes a Series or mini game'),
|
|
15032
15308
|
},
|
|
15033
15309
|
outputSchema: { campaignId: z.string().optional(), advertiserId: z.string().optional(), summary: z.string().optional() },
|
|
15034
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15310
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
15035
15311
|
}, wrap(async (a) => { const d = await apiPost('/api/tiktok-ads/smart/campaign', a); return ok(d.summary, d); }));
|
|
15036
15312
|
|
|
15037
15313
|
server.registerTool('create_tiktok_smart_ad_group', {
|
|
@@ -15058,7 +15334,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15058
15334
|
optimizationEvent: z.string().optional().describe('the conversion event, required whenever pixelId is set (e.g. SHOPPING, ON_WEB_ORDER)'),
|
|
15059
15335
|
},
|
|
15060
15336
|
outputSchema: { adGroupId: z.string().optional(), summary: z.string().optional() },
|
|
15061
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15337
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
15062
15338
|
}, wrap(async (a) => { const d = await apiPost('/api/tiktok-ads/smart/adgroup', a); return ok(d.summary, d); }));
|
|
15063
15339
|
|
|
15064
15340
|
server.registerTool('create_tiktok_smart_ad', {
|
|
@@ -15075,7 +15351,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15075
15351
|
advertiserId: z.string().optional(),
|
|
15076
15352
|
},
|
|
15077
15353
|
outputSchema: { adIds: z.array(z.string()).optional(), summary: z.string().optional() },
|
|
15078
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15354
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
15079
15355
|
}, wrap(async (a) => { const d = await apiPost('/api/tiktok-ads/smart/ad', a); return ok(d.summary, d); }));
|
|
15080
15356
|
|
|
15081
15357
|
server.registerTool('list_tiktok_smart_campaigns', {
|
|
@@ -15125,7 +15401,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15125
15401
|
budget: z.number().optional().describe('campaign budget in the advertiser’s own currency — not needed with BUDGET_MODE_INFINITE'),
|
|
15126
15402
|
},
|
|
15127
15403
|
outputSchema: { id: z.string().optional(), advertiserId: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
15128
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15404
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
15129
15405
|
}, wrap(async (a) => {
|
|
15130
15406
|
const d = await apiPost('/api/tiktok-ads/campaign', a);
|
|
15131
15407
|
return ok(`Created TikTok campaign "${d.name}" (${d.id}) — objective ${d.objective}, status ${d.status}. ${d.note} Next: create_tiktok_ads_ad_group under it, then list_tiktok_ads_identities and create_tiktok_ads_ad.`, d);
|
|
@@ -15163,7 +15439,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15163
15439
|
conversionBid: z.number().optional().describe('target cost per conversion for oCPM — required by TikTok when bidType is BID_TYPE_CUSTOM and billingEvent is OCPM'),
|
|
15164
15440
|
},
|
|
15165
15441
|
outputSchema: { id: z.string().optional(), advertiserId: z.string().optional(), campaignId: z.string().optional(), name: z.string().optional(), status: z.string().optional(), optimizationGoal: z.string().optional(), pixelId: z.string().nullable().optional(), optimizationEvent: z.string().nullable().optional(), billingEvent: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
15166
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15442
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
15167
15443
|
}, wrap(async (a) => {
|
|
15168
15444
|
const d = await apiPost('/api/tiktok-ads/adgroup', a);
|
|
15169
15445
|
const conv = d.pixelId ? ` toward ${d.optimizationEvent || 'a conversion'} on pixel ${d.pixelId}` : '';
|
|
@@ -15302,7 +15578,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15302
15578
|
confirm: z.boolean().optional().describe('REQUIRED to book. Without it nothing is created and you get the sentence to show the user.'),
|
|
15303
15579
|
},
|
|
15304
15580
|
outputSchema: { created: z.boolean().optional(), confirmRequired: z.boolean().optional(), id: z.string().optional(), advertiserId: z.string().optional(), campaignId: z.string().optional(), name: z.string().optional(), rfPurchasedType: z.string().optional(), budget: z.number().optional(), purchasedImpressionThousands: z.number().optional(), purchasedReachThousands: z.number().optional(), optimizationGoal: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
15305
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15581
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
15306
15582
|
}, wrap(async (a) => {
|
|
15307
15583
|
const d = await apiPost('/api/tiktok-ads/rf-adgroup', a);
|
|
15308
15584
|
if (!d.created) return ok(d.note, d);
|
|
@@ -15932,7 +16208,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15932
16208
|
landingPageUrl: z.string().optional().describe('where the ad sends people'),
|
|
15933
16209
|
},
|
|
15934
16210
|
outputSchema: { id: z.string().optional(), advertiserId: z.string().optional(), adgroupId: z.string().optional(), name: z.string().optional(), status: z.string().nullable().optional(), identityId: z.string().optional(), videoId: z.string().nullable().optional(), tiktokItemId: z.string().nullable().optional(), spark: z.boolean().optional(), imageIds: z.array(z.string()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
15935
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16211
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
15936
16212
|
}, wrap(async (a) => {
|
|
15937
16213
|
const d = await apiPost('/api/tiktok-ads/ad', a);
|
|
15938
16214
|
return ok(`Created TikTok ${d.spark ? 'SPARK ad promoting organic post ' + d.tiktokItemId : 'ad'} "${d.name}" (${d.id}) in ad group ${d.adgroupId}, posting as identity ${d.identityId} — TikTok stored it as ${d.status || 'an unreported status'}. ${d.note} TikTok still has to review it before it can show.`, d);
|
|
@@ -16194,7 +16470,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16194
16470
|
confirm: z.boolean().optional().describe('REQUIRED true — call without it first to see exactly what would change'),
|
|
16195
16471
|
},
|
|
16196
16472
|
outputSchema: { advertiserId: z.string().optional(), level: z.string().optional(), id: z.string().optional(), requested: z.number().optional(), read: z.number().nullable().optional(), verified: z.boolean().optional() },
|
|
16197
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16473
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
16198
16474
|
}, wrap(async (a) => {
|
|
16199
16475
|
const d = await apiPost('/api/tiktok-ads/budget', a);
|
|
16200
16476
|
return ok(d.verified
|
|
@@ -16393,7 +16669,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16393
16669
|
businessAccountHandle: z.string().optional().describe('allowlist-only at TikTok, and non-EU only: lets creators message the brand from the invite link'),
|
|
16394
16670
|
},
|
|
16395
16671
|
outputSchema: { ttoAccountId: z.string().optional(), campaignId: z.string().optional(), campaignType: z.string().nullable().optional(), inviteLink: z.string().nullable().optional(), invited: z.array(z.string()).optional(), failedHandles: z.array(z.string()).optional(), note: z.string().optional() },
|
|
16396
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16672
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
16397
16673
|
}, wrap(async (a) => {
|
|
16398
16674
|
const d = await apiPost('/api/tiktok-ads/tto-campaign', a);
|
|
16399
16675
|
return ok(d.note, d);
|
|
@@ -16425,7 +16701,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16425
16701
|
action: z.enum(['LINK', 'REVOKE']).optional().describe('default LINK; a second LINK is a REMINDER'),
|
|
16426
16702
|
},
|
|
16427
16703
|
outputSchema: { ttoAccountId: z.string().optional(), videoId: z.string().optional(), status: z.string().nullable().optional(), statusMeans: z.string().nullable().optional(), requestsSent: z.number().nullable().optional(), remindersLeft: z.number().nullable().optional(), note: z.string().optional() },
|
|
16428
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16704
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
16429
16705
|
}, wrap(async (a) => {
|
|
16430
16706
|
const d = await apiPost('/api/tiktok-ads/tto-link', a);
|
|
16431
16707
|
return ok(d.note, d);
|
|
@@ -16794,7 +17070,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16794
17070
|
endTime: z.string().optional().describe('ISO 8601'),
|
|
16795
17071
|
},
|
|
16796
17072
|
outputSchema: { id: z.string().optional(), adAccountId: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
16797
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17073
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
16798
17074
|
}, wrap(async (a) => {
|
|
16799
17075
|
const d = await apiPost('/api/snapchat-ads/campaign', a);
|
|
16800
17076
|
return ok(`Created Snapchat campaign "${d.name}" (${d.id})${d.objective ? ` — objective ${d.objective}` : ''}. Snapchat stored it as ${d.status || 'an unreported status'}. ${d.note} Next: create_snapchat_ads_ad_squad under it, then upload_snapchat_ads_creative and create_snapchat_ads_ad.`, d);
|
|
@@ -16849,7 +17125,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16849
17125
|
type: z.string().optional().describe('default SNAP_AD. Others include REMOTE_WEBPAGE, APP_INSTALL, STORY, COLLECTION, LEAD_GENERATION.'),
|
|
16850
17126
|
},
|
|
16851
17127
|
outputSchema: { id: z.string().optional(), adAccountId: z.string().optional(), adSquadId: z.string().optional(), creativeId: z.string().optional(), name: z.string().optional(), status: z.string().optional(), type: z.string().optional(), reviewStatus: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
16852
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17128
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
16853
17129
|
}, wrap(async (a) => {
|
|
16854
17130
|
const d = await apiPost('/api/snapchat-ads/ad', a);
|
|
16855
17131
|
return ok(`Created Snapchat ad "${d.name}" (${d.id}) in ad squad ${d.adSquadId} from creative ${d.creativeId} — stored as ${d.status || 'an unreported status'}. ${d.note}`, d);
|
|
@@ -16868,7 +17144,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16868
17144
|
confirm: z.boolean().optional().describe('REQUIRED true — without it nothing changes and you get the sentence to show the user'),
|
|
16869
17145
|
},
|
|
16870
17146
|
outputSchema: { adAccountId: z.string().optional(), level: z.string().optional(), id: z.string().optional(), requestedMicro: z.number().optional(), readMicro: z.number().nullable().optional(), read: z.number().nullable().optional(), currency: z.string().nullable().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
16871
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17147
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
16872
17148
|
}, wrap(async (a) => {
|
|
16873
17149
|
const d = await apiPost('/api/snapchat-ads/budget', a);
|
|
16874
17150
|
return ok(`Snapchat ad squad ${d.id} — requested ${d.requestedMicro} micro, READ BACK from Snapchat as ${d.readMicro ?? '(not returned)'} micro${d.read != null ? ` (${d.read} ${d.currency || ''})` : ''}.${d.note ? ' ' + d.note : ''}`, d);
|
|
@@ -16884,7 +17160,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16884
17160
|
confirm: z.boolean().optional().describe('REQUIRED true — without it nothing changes and you get the sentence to show the user'),
|
|
16885
17161
|
},
|
|
16886
17162
|
outputSchema: { adAccountId: z.string().optional(), level: z.string().optional(), requested: z.string().optional(), results: z.array(z.any()).optional(), read: z.array(z.any()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
16887
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17163
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
16888
17164
|
}, wrap(async (a) => {
|
|
16889
17165
|
const d = await apiPost('/api/snapchat-ads/status', a);
|
|
16890
17166
|
const read = (d.read || []).map(r => `${r.id} → ${r.status ?? '(unreadable)'}`).join(', ') || '(Snapchat returned no rows on the read-back)';
|
|
@@ -16937,7 +17213,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16937
17213
|
}));
|
|
16938
17214
|
server.registerTool('post_to_linkedin_page', {
|
|
16939
17215
|
title: 'Publish to a LinkedIn company Page',
|
|
16940
|
-
description: 'Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. ORGANIC CAROUSELS ARE COMPANY-PAGE ONLY — a personal profile cannot publish one and is refused by name, so send a deck here rather than to post_to_linkedin. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
|
|
17216
|
+
description: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. ORGANIC CAROUSELS ARE COMPANY-PAGE ONLY — a personal profile cannot publish one and is refused by name, so send a deck here rather than to post_to_linkedin. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
|
|
16941
17217
|
inputSchema: {
|
|
16942
17218
|
...HOOK_ATTR,
|
|
16943
17219
|
organizationId: z.string().optional().describe('numeric Page id from list_linkedin_pages'),
|
|
@@ -17078,7 +17354,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17078
17354
|
}));
|
|
17079
17355
|
server.registerTool('linkedin_ads_report', {
|
|
17080
17356
|
title: 'LinkedIn ads performance report',
|
|
17081
|
-
description: 'LinkedIn ad performance — impressions, clicks, cost, website conversions, leads and social actions — pivoted by CAMPAIGN (default), CAMPAIGN_GROUP, CREATIVE, ACCOUNT, CONVERSION, PLACEMENT_NAME, IMPRESSION_DEVICE_TYPE, SERVING_LOCATION… or by AUDIENCE DEMOGRAPHICS: MEMBER_COMPANY_SIZE, MEMBER_INDUSTRY, MEMBER_SENIORITY, MEMBER_JOB_TITLE, MEMBER_JOB_FUNCTION, MEMBER_COUNTRY_V2, MEMBER_REGION_V2, MEMBER_COMPANY. The MEMBER_* pivots
|
|
17357
|
+
description: 'LinkedIn ad performance — impressions, clicks, cost, website conversions, leads and social actions — pivoted by CAMPAIGN (default), CAMPAIGN_GROUP, CREATIVE, ACCOUNT, CONVERSION, PLACEMENT_NAME, IMPRESSION_DEVICE_TYPE, SERVING_LOCATION… or by AUDIENCE DEMOGRAPHICS: MEMBER_COMPANY_SIZE, MEMBER_INDUSTRY, MEMBER_SENIORITY, MEMBER_JOB_TITLE, MEMBER_JOB_FUNCTION, MEMBER_COUNTRY_V2, MEMBER_REGION_V2, MEMBER_COMPANY. The MEMBER_* pivots report job title, seniority, company size and the other member demographics. LinkedIn allows exactly ONE pivot per report, so ask for them one at a time and join the answers yourself. An unknown pivot or granularity is refused BY NAME rather than forwarded. On a demographic pivot LinkedIn returns only the top 100 values, DROPS any value under 3 events (so the rows will not sum to the campaign total) and lags 12–24 hours behind the performance numbers — the note says so, every time. Window via since/until (YYYY-MM-DD). ZERO rows genuinely means no delivery in that window; say exactly that and never present zeros as measured performance. A LinkedIn TEST ad account NEVER returns analytics, and the note says so when that is what you are looking at. Read-only, free.',
|
|
17082
17358
|
inputSchema: {
|
|
17083
17359
|
adAccountId: z.string().optional(),
|
|
17084
17360
|
campaignIds: z.array(z.string()).optional(),
|
|
@@ -17180,7 +17456,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17180
17456
|
confirm: z.boolean().optional().describe('REQUIRED true to create it ACTIVE under a LIVE campaign group'),
|
|
17181
17457
|
},
|
|
17182
17458
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), campaignGroupId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
17183
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17459
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
17184
17460
|
}, wrap(async (a) => {
|
|
17185
17461
|
const d = await apiPost('/api/linkedin/ads-campaign', a);
|
|
17186
17462
|
return ok(`${d.note} To make it spend, use set_linkedin_ads_status(confirm:true) after the user approves.`, d);
|
|
@@ -17197,7 +17473,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17197
17473
|
confirm: z.boolean().optional().describe('REQUIRED true to change the budget of a LIVE campaign'),
|
|
17198
17474
|
},
|
|
17199
17475
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), dailyBudget: z.string().nullable().optional(), totalBudget: z.string().nullable().optional(), note: z.string().optional() },
|
|
17200
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17476
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
17201
17477
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-budget', a); return ok(d.note, d); }));
|
|
17202
17478
|
server.registerTool('set_linkedin_ads_status', {
|
|
17203
17479
|
title: 'Activate or pause a LinkedIn campaign group / campaign / ad',
|
|
@@ -17249,7 +17525,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17249
17525
|
confirm: z.boolean().optional().describe('REQUIRED true to create it ACTIVE under a LIVE campaign (real spend)'),
|
|
17250
17526
|
},
|
|
17251
17527
|
outputSchema: { ok: z.boolean().optional(), creativeId: z.string().optional(), campaignId: z.string().optional(), intendedStatus: z.string().optional(), isServing: z.boolean().optional(), reference: z.string().optional(), note: z.string().optional() },
|
|
17252
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17528
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
17253
17529
|
}, wrap(async (a) => {
|
|
17254
17530
|
const d = await apiPost('/api/linkedin/ads-creative', a);
|
|
17255
17531
|
return ok(`${d.note} To make it spend, use set_linkedin_ads_status(level:"creative", status:"ACTIVE", confirm:true) after the user approves.`, d);
|
|
@@ -17346,7 +17622,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17346
17622
|
confirm: z.boolean().optional().describe('REQUIRED true ONLY to set status ACTIVE (real spend)'),
|
|
17347
17623
|
},
|
|
17348
17624
|
outputSchema: { ok: z.boolean().optional(), objectId: z.string().optional(), updated: z.array(z.string()).optional(), euTransparency: z.string().optional().describe('the EU advertiser / payer names set on this edit, and where they came from') },
|
|
17349
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17625
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
17350
17626
|
}, wrap(async (a) => {
|
|
17351
17627
|
const d = await apiPost('/api/meta/object/update', a);
|
|
17352
17628
|
return ok(`Updated ${a.objectId} (${(d.updated || []).join(', ')}).${d.euTransparency ? ` ${d.euTransparency}` : ''}`, d);
|
|
@@ -17674,7 +17950,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17674
17950
|
server.group('files');
|
|
17675
17951
|
server.registerTool('export_swipefile_deck', {
|
|
17676
17952
|
title: 'Swipefile to Google Slides',
|
|
17677
|
-
description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team
|
|
17953
|
+
description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets, Docs and Slides).',
|
|
17678
17954
|
inputSchema: {
|
|
17679
17955
|
collection: z.string().optional().describe('the swipefile collection to export, by name or id (default: the first collection)'),
|
|
17680
17956
|
title: z.string().optional().describe('deck title (default: the collection name)'),
|
|
@@ -17827,7 +18103,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17827
18103
|
}));
|
|
17828
18104
|
server.registerTool('update_doc', {
|
|
17829
18105
|
title: 'Edit a Google Doc in place',
|
|
17830
|
-
description: 'EDIT a Google Doc — the correction append_to_doc cannot make
|
|
18106
|
+
description: 'EDIT a Google Doc — the correction append_to_doc cannot make. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:"…"` replaces the ENTIRE body (rewrite:"" empties it), or `dropdowns:[{title, value}]` sets a dropdown chip (e.g. Status -> Approved — read_doc lists every chip with its options; pass dropdownId when two share a title; an unknown title or option is refused with the real list and nothing changes); the same entry with options:[…] (the COMPLETE new list, 2–50) and/or newTitle changes the dropdown itself for every chip built from it, and an option you drop that a chip shows needs replace:{"Old":"New"}. Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.',
|
|
17831
18107
|
inputSchema: {
|
|
17832
18108
|
documentId: z.string().optional().describe('the document id (from create_doc, or list_drive_files for one the user picked)'),
|
|
17833
18109
|
docUrl: z.string().optional().describe('a Google Docs URL — the id is extracted from it'),
|
|
@@ -18095,7 +18371,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18095
18371
|
server.group('create');
|
|
18096
18372
|
server.registerTool('generate_image', {
|
|
18097
18373
|
title: 'Generate ad image',
|
|
18098
|
-
description: 'Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). MULTI-BRAND CAUTION: useBrand hydration pulls the SAVED workspace brand — when working a brand that is NOT the saved one (a fresh draft_brand), pass that brand\'s own productImages/logo as refImages (and useBrand:false) or the output composites the WRONG brand\'s product. NOTE that the saved-brand hydration also decides the ENGINE: attaching product photos routes the render to the compositing model, so a `model` you named is only honoured when no references ride — pass raw:true (or useBrand:false) to render on exactly the model you asked for. model = a catalog id from hermoso_capabilities (omit for the default). PUTTING A REAL PRODUCT IN A REAL PERSON’S HANDS, or a garment on them, is a DIFFERENT KIND OF ROW and you must name it: the ids marked `needsRefs` with a `refsMax` in hermoso_capabilities take a person photo first and up to three product/garment photos after it, and they EDIT THE PHOTOGRAPH rather than compositing — THE PERSON IS RE-POSED to hold or wear the thing, so their stance and hands change while their face, clothing, setting and lighting are kept. That is not an object swap in a fixed frame; if you needed the rest of the photograph untouched, this is the wrong tool. Every finished render says which way it went. RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Fast (seconds). Spends credits.',
|
|
18374
|
+
description: 'Use this when the user wants an ad image, product photo or social graphic made. Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). MULTI-BRAND CAUTION: useBrand hydration pulls the SAVED workspace brand — when working a brand that is NOT the saved one (a fresh draft_brand), pass that brand\'s own productImages/logo as refImages (and useBrand:false) or the output composites the WRONG brand\'s product. NOTE that the saved-brand hydration also decides the ENGINE: attaching product photos routes the render to the compositing model, so a `model` you named is only honoured when no references ride — pass raw:true (or useBrand:false) to render on exactly the model you asked for. model = a catalog id from hermoso_capabilities (omit for the default). PUTTING A REAL PRODUCT IN A REAL PERSON’S HANDS, or a garment on them, is a DIFFERENT KIND OF ROW and you must name it: the ids marked `needsRefs` with a `refsMax` in hermoso_capabilities take a person photo first and up to three product/garment photos after it, and they EDIT THE PHOTOGRAPH rather than compositing — THE PERSON IS RE-POSED to hold or wear the thing, so their stance and hands change while their face, clothing, setting and lighting are kept. That is not an object swap in a fixed frame; if you needed the rest of the photograph untouched, this is the wrong tool. Every finished render says which way it went. RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Fast (seconds). Spends credits.',
|
|
18099
18375
|
inputSchema: {
|
|
18100
18376
|
prompt: z.string().optional().describe('REQUIRED on every model EXCEPT the pose rows below. the full image prompt — subject, composition, lighting, and any on-image ad text. ON A POSE MODEL (product-in-hand / try-on) THIS IS EXTRA DIRECTION AND IT IS OPTIONAL — leave it out and the pose is built for you. If you do write one, DESCRIBE THE POSE ("she holds the bottle upright in her right hand at chest height, label to camera"); do NOT phrase it as a swap ("replace the mug with the bottle"), which is REFUSED for free, because the product then comes out the size of whatever it replaced — a 30ml bottle rendered mug-sized in testing.'),
|
|
18101
18377
|
refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in. ON THE POSE MODELS — any row hermoso_capabilities marks `needsRefs` with a `refsMax`, such as putting your product in someone’s hands or a virtual try-on — THE ORDER IS THE CONTRACT AND IT IS NOT A COMPOSITE: refImages[0] is the PERSON photo, and the rest (up to `refsMax` minus one) are the product or garment photos. Reversed, you get the product wearing the person. A 4th product is dropped and the reply says so. ' + LIKENESS_TERMS),
|
|
@@ -18369,7 +18645,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18369
18645
|
server.group('create');
|
|
18370
18646
|
server.registerTool('render_ad', {
|
|
18371
18647
|
title: 'Render ad video',
|
|
18372
|
-
description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), an optional brand end card (only when the user asks), no music unless asked, real product references. UGC and cinematic plans are rendered from a painted STORYBOARD board (cleaned, then bound first, ahead of the creator and the product); a cinematic spot paints a location anchor first (and a hero when someone is on camera). A product-only commercial renders from the real product photo by default; board:true or foundations:\'choose\' adds a product identity sheet, a four-up moodboard and a board. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
|
|
18648
|
+
description: 'Use this when the user wants a finished video ad for their brand. RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), an optional brand end card (only when the user asks), no music unless asked, real product references. UGC and cinematic plans are rendered from a painted STORYBOARD board (cleaned, then bound first, ahead of the creator and the product); a cinematic spot paints a location anchor first (and a hero when someone is on camera). A product-only commercial renders from the real product photo by default; board:true or foundations:\'choose\' adds a product identity sheet, a four-up moodboard and a board. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
|
|
18373
18649
|
inputSchema: {
|
|
18374
18650
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
18375
18651
|
creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
|
|
@@ -18846,7 +19122,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18846
19122
|
|
|
18847
19123
|
server.registerTool('generate_video', {
|
|
18848
19124
|
title: 'Generate video',
|
|
18849
|
-
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
|
|
19125
|
+
description: 'Use this when the user wants a video clip made from a prompt; for a finished, branded video ad use render_ad. Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
|
|
18850
19126
|
inputSchema: {
|
|
18851
19127
|
prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
|
|
18852
19128
|
raw: z.boolean().optional().describe('Send the prompt to the model exactly as written, without the wording Hermoso usually adds (packaging and label guidance, reference lines, colour names for hex codes, quality hints). Use it when you want the model\'s own interpretation rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence (on Veo it is refused with the length and the cap instead). Billing, durable delivery and per-model validation are unchanged.'),
|
|
@@ -20456,7 +20732,7 @@ function memoryNoteVerdict(text) {
|
|
|
20456
20732
|
server.registerTool('research_ads', {
|
|
20457
20733
|
_meta: openaiMeta(AD_SPY_URI, 'Researching ads…', 'Ad research ready'),
|
|
20458
20734
|
title: 'Research ads',
|
|
20459
|
-
description: '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.',
|
|
20735
|
+
description: 'Use this when the user wants to see the ads a brand or competitor is running, or ad examples for a market. 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.',
|
|
20460
20736
|
inputSchema: {
|
|
20461
20737
|
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
20462
20738
|
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'),
|
|
@@ -20622,7 +20898,7 @@ function memoryNoteVerdict(text) {
|
|
|
20622
20898
|
server.registerTool('search_linkedin_ads', {
|
|
20623
20899
|
_meta: openaiMeta(AD_SPY_URI, 'Searching LinkedIn ads…', 'Found LinkedIn ads'),
|
|
20624
20900
|
title: 'Search LinkedIn ads',
|
|
20625
|
-
description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn
|
|
20901
|
+
description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn's library includes impression counts. Spends about a credit.",
|
|
20626
20902
|
inputSchema: {
|
|
20627
20903
|
company: z.string().optional().describe('advertiser company name'),
|
|
20628
20904
|
keyword: z.string().optional().describe('keyword across all advertisers'),
|
|
@@ -21149,7 +21425,7 @@ function memoryNoteVerdict(text) {
|
|
|
21149
21425
|
// described look) runs as a video edit: same engines, same price, same free preservation read-back as edit_video.
|
|
21150
21426
|
server.registerTool('restyle_video', {
|
|
21151
21427
|
title: 'Restyle a video',
|
|
21152
|
-
description: "RESTYLE a clip into a new LOOK: every frame redrawn, shots, motion, camera, framing, timing and sound kept. Looks: `Restyle looks` in hermoso_capabilities (claymation, knitted yarn, cel-shaded CG anime…), your own words, or a look BUILT FROM YOUR IMAGES (styleImages); characters draws your own characters in. engine auto (default) picks by what is in the clip: a real-looking person -> kling (Kling O3 Edit, 3-15s
|
|
21428
|
+
description: "RESTYLE a clip into a new LOOK: every frame redrawn, shots, motion, camera, framing, timing and sound kept. Looks: `Restyle looks` in hermoso_capabilities (claymation, knitted yarn, cel-shaded CG anime…), your own words, or a look BUILT FROM YOUR IMAGES (styleImages); characters draws your own characters in. engine auto (default) picks by what is in the clip: a real-looking person -> kling (Kling O3 Edit, 3-15s); none -> wan (Wan 3.0 Prime, ≤15s) else seedance (Seedance 2.5, 4-30s). Paid; dryRun quotes it. One change only: edit_video.",
|
|
21153
21429
|
inputSchema: {
|
|
21154
21430
|
video: z.string().describe('the source video URL (a render, job result or list_library)'),
|
|
21155
21431
|
style: z.string().optional().describe('a look id from hermoso_capabilities (e.g. claymation, knitted-yarn, cel-shaded-anime-cg)'),
|
|
@@ -21813,7 +22089,7 @@ function memoryNoteVerdict(text) {
|
|
|
21813
22089
|
// fifth (list_meta_posts) is the enumeration Meta never had — see its own note.
|
|
21814
22090
|
server.registerTool('list_meta_posts', {
|
|
21815
22091
|
title: 'List the Page’s / Instagram account’s own posts',
|
|
21816
|
-
description: "List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one
|
|
22092
|
+
description: "List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one. Use it for \"how did our last few posts do\", to find a post the user describes loosely, or before backfill_posts. Pass target:'instagram' for the linked IG account (Stories are excluded — Meta's media edge does not return them); Facebook hides unpublished drafts unless you ask for them. Only ever reads a Page the brand has connected. Read-only, 0 credits.",
|
|
21817
22093
|
inputSchema: {
|
|
21818
22094
|
...MANAGE_BRAND,
|
|
21819
22095
|
target: z.enum(['facebook', 'instagram']).optional().describe("default facebook; 'instagram' reads the Page's linked IG business account"),
|
|
@@ -22028,6 +22304,7 @@ function memoryNoteVerdict(text) {
|
|
|
22028
22304
|
return ok(d.note || `${d.dryRun ? 'Dry run' : 'Imported'} on ${a.channel}.`, d);
|
|
22029
22305
|
}));
|
|
22030
22306
|
registerWidgetHostAliases(rawServer, ctx, opts);
|
|
22307
|
+
registerWidgetHostSplits(rawServer, ctx, opts);
|
|
22031
22308
|
registerChatGPTExtensions(rawServer, ctx, opts);
|
|
22032
22309
|
installHeldToolCalls(rawServer, ctx);
|
|
22033
22310
|
}
|