hermoso 0.1.95 → 0.1.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/tools.mjs +1487 -55
- package/package.json +3 -3
package/mcp/tools.mjs
CHANGED
|
@@ -110,7 +110,7 @@ export const CAPABILITY_MAP = [
|
|
|
110
110
|
'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here; each has its own brand, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
111
111
|
'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.',
|
|
112
112
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this brand).',
|
|
113
|
-
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this brand may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · disconnect_connector (revoke and drop a connection; confirm-gated because RECONNECTING NEEDS A BROWSER and no agent can do it) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING a new account is the one thing that is not headless — it is an OAuth consent screen, so send the user to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · 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) · 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) · 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 / Google Business Profile — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC — the step that finishes the default publish flow; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · 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) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this brand can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_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) · 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). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this brand can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) · 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 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 (post, then actually live with it — the thread is where the value is): post_to_reddit (submit a text, link or native image post to ONE subreddit — Reddit bans near-identical posts across communities, so write for one subreddit and never fan out) · list_reddit_posts (the account’s OWN submissions with their ids — THIS is where the postId every other Reddit tool needs comes from) · reddit_post_stats (score, comments, upvote ratio on a post you made) · list_reddit_comments + reply_to_reddit_comment (read the questions and objections in the community’s own words and answer them as the brand — Reddit judges a brand on how it behaves in comments far more than on what it posts) · edit_reddit_post (rewrite a TEXT post’s body; a link post cannot be edited at all and a TITLE can never be changed by any API, so say that rather than implying otherwise) · delete_reddit_post (take one down — confirm-gated, and note deleting the post does NOT delete the comments under it). REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). 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) · 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). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this brand can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS BRAND, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this brand and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy). 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_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. 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.',
|
|
113
|
+
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this brand may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · disconnect_connector (revoke and drop a connection; confirm-gated because RECONNECTING NEEDS A BROWSER and no agent can do it) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING a new account is the one thing that is not headless — it is an OAuth consent screen, so send the user to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · 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) · 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 / Google Business Profile — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC — the step that finishes the default publish flow; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · 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) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this brand can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · 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). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this brand can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) · 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 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 (post, then actually live with it — the thread is where the value is): post_to_reddit (submit a text, link or native image post to ONE subreddit — Reddit bans near-identical posts across communities, so write for one subreddit and never fan out) · list_reddit_posts (the account’s OWN submissions with their ids — THIS is where the postId every other Reddit tool needs comes from) · reddit_post_stats (score, comments, upvote ratio on a post you made) · list_reddit_comments + reply_to_reddit_comment (read the questions and objections in the community’s own words and answer them as the brand — Reddit judges a brand on how it behaves in comments far more than on what it posts) · edit_reddit_post (rewrite a TEXT post’s body; a link post cannot be edited at all and a TITLE can never be changed by any API, so say that rather than implying otherwise) · delete_reddit_post (take one down — confirm-gated, and note deleting the post does NOT delete the comments under it). REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). 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). Replies and mentions 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) · 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). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this brand can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS BRAND, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this brand and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy). 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. 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.',
|
|
114
114
|
'F) YOUR ROSTER STARTS SLIM, AND YOU CAN WIDEN IT YOURSELF — paid-campaign management (`ads`) is NOT loaded by default. It is 238 tools and about two thirds of the schema weight, and most sessions never touch it. THE MOMENT the user asks to build, budget, target, report on or change a campaign on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads or Apple Search Ads, call enable_tools({groups:[\'ads\']}) — it is free and instant, the tools appear immediately, and you then proceed normally. Do NOT tell the user a campaign cannot be built here; turn the group on. Other groups: research, create, channels, files, workspace, or \'all\'.',
|
|
115
115
|
].join('\n');
|
|
116
116
|
|
|
@@ -629,6 +629,14 @@ export const INBOX_SOURCES = [
|
|
|
629
629
|
{ source: 'reddit', provider: 'reddit', read: '/api/reddit/comments', reply: '/api/reddit/reply', kind: 'comment', label: 'Reddit comment' },
|
|
630
630
|
{ source: 'google_business', provider: 'google_business', read: '/api/google-business/reviews', reply: '/api/google-business/review-reply', kind: 'review', label: 'Google review' },
|
|
631
631
|
{ source: 'x', provider: 'x', read: '/api/x/mentions', reply: null, kind: 'mention', label: 'X mention' },
|
|
632
|
+
// BLUESKY, added 2026-08-17. Publishing (text, images, video, scheduling) shipped that morning with NO read path
|
|
633
|
+
// at all, so a user could post through us and then not see or answer a single reply. Two sources rather than one,
|
|
634
|
+
// exactly as Threads splits: `app.bsky.notification.listNotifications` filters by `reasons`, so asking for one
|
|
635
|
+
// kind costs one call — and a fan-out that sent a fixed query could not ask ONE route for two different kinds.
|
|
636
|
+
// NEITHER declares `needs`: notifications are ACCOUNT-level, so both read live with no post id, and marking them
|
|
637
|
+
// as per-post would hide two sources that work with no arguments at all.
|
|
638
|
+
{ source: 'bluesky', provider: 'bluesky', read: '/api/bluesky/replies', reply: '/api/bluesky/reply', kind: 'reply', label: 'Bluesky reply' },
|
|
639
|
+
{ source: 'bluesky_mention', provider: 'bluesky', read: '/api/bluesky/mentions', reply: '/api/bluesky/reply', kind: 'mention', label: 'Bluesky mention' },
|
|
632
640
|
];
|
|
633
641
|
|
|
634
642
|
export const INBOX_SOURCE_NAMES = INBOX_SOURCES.map((s) => s.source);
|
|
@@ -732,7 +740,13 @@ export const TOOL_GROUPS = {
|
|
|
732
740
|
research: 'Ad spy and competitor research — the ad libraries, organic social search, teardowns, angle mining.',
|
|
733
741
|
create: 'Generation and post-production — plan and render image/video ads, thumbnails, voice, avatars, editing.',
|
|
734
742
|
channels: 'Connected social channels — publish, schedule, engage, and read each channel’s own analytics.',
|
|
735
|
-
|
|
743
|
+
// NO PLATFORM ROSTER HERE, DELIBERATELY (2026-08-17). This blurb used to enumerate seven platforms and had gone
|
|
744
|
+
// stale by four — Apple Search Ads, Snapchat, TikTok and X Ads were all missing while we ship every one of them.
|
|
745
|
+
// An agent reading a roster treats it as the boundary of what exists and refuses the rest, which is the
|
|
746
|
+
// [[prompt-rosters-go-stale]] failure that has already made us decline a shipped capability. A hand-maintained
|
|
747
|
+
// list in prose has no check that can hold it true, so the fix is to stop making a claim that can rot: the tools
|
|
748
|
+
// themselves are the roster, and enabling the group is what reveals them.
|
|
749
|
+
ads: 'Paid campaign management on every connected ad platform — build, budget, target and report on campaigns, ad sets, ads, keywords and audiences. Enable this group to see which platforms this workspace can reach.',
|
|
736
750
|
files: 'Google Drive, Sheets, Docs and OneDrive.',
|
|
737
751
|
workspace:'Brand profile, memory, skills, saved creators, connectors and team.',
|
|
738
752
|
};
|
|
@@ -885,11 +899,14 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
885
899
|
server.group('channels');
|
|
886
900
|
server.registerTool('post_to_bluesky', {
|
|
887
901
|
title: 'Post to Bluesky',
|
|
888
|
-
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.
|
|
902
|
+
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. Returns the post's public bsky.app URL. Connect at Settings ▸ Connectors ▸ Bluesky with a handle and an APP PASSWORD.",
|
|
889
903
|
inputSchema: {
|
|
890
904
|
text: z.string().describe('The post, up to 300 characters / 3000 UTF-8 bytes.'),
|
|
891
|
-
imageUrls: z.array(z.string()).optional().describe('Up to 4 public image URLs to attach.'),
|
|
892
|
-
altText: z.array(z.string()).optional().describe('Alt text per image
|
|
905
|
+
imageUrls: z.array(z.string()).optional().describe('Up to 4 public image URLs to attach. Cannot be combined with videoUrl.'),
|
|
906
|
+
altText: z.union([z.string(), z.array(z.string())]).optional().describe('Alt text \u2014 an ARRAY, one per image in the same order, or a single STRING to describe every image with it. WRITE ONE: Bluesky\u2019s own lexicon makes `alt` a REQUIRED property of every image, so a post without it is undescribed by design rather than by omission, and Bluesky users expect it. No maximum length is published, so nothing is truncated.'),
|
|
907
|
+
videoUrl: z.string().optional().describe('One public MP4 URL. Cannot be combined with imageUrls. Bluesky transcodes it, which takes a minute or two.'),
|
|
908
|
+
videoAlt: z.string().optional().describe('Alt text describing the video, for accessibility.'),
|
|
909
|
+
captions: z.array(z.record(z.any())).optional().describe("Up to 20 WebVTT caption tracks: [{lang:'en', url:'https://…/en.vtt'}] or [{lang:'en', content:'WEBVTT\\n\\n00:00…'}]. Each file is capped at 20000 bytes."),
|
|
893
910
|
langs: z.array(z.string()).optional().describe("BCP-47 language tags, e.g. ['en']."),
|
|
894
911
|
},
|
|
895
912
|
outputSchema: { url: z.string().optional(), uri: z.string().optional(), handle: z.string().optional(), note: z.string() },
|
|
@@ -898,11 +915,35 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
898
915
|
return ok(r.note || `Posted to Bluesky — ${r.url || ''}`, r);
|
|
899
916
|
}));
|
|
900
917
|
|
|
918
|
+
// EVERY channel we publish to has a removal path — delete_x_post, manage_meta_post, manage_linkedin_post,
|
|
919
|
+
// delete_thread, delete_google_business_post, delete_youtube_video, delete_pinterest_pin — and Bluesky had none
|
|
920
|
+
// on any surface. The gate is the delete_youtube_video / delete_pinterest_pin one: confirm proves the caller meant
|
|
921
|
+
// to delete SOMETHING, only the echo proves they aimed at the right post, and friction scales with the loss.
|
|
922
|
+
server.registerTool('delete_bluesky_post', {
|
|
923
|
+
title: 'Delete a post from the connected Bluesky account',
|
|
924
|
+
description: "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings ▸ Connectors ▸ Bluesky).",
|
|
925
|
+
inputSchema: {
|
|
926
|
+
uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, or just its record key (3mtc4n3fibn2x)"),
|
|
927
|
+
confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent and cannot be undone'),
|
|
928
|
+
confirmText: z.string().optional().describe("the post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead."),
|
|
929
|
+
},
|
|
930
|
+
outputSchema: { ok: z.boolean().optional(), uri: z.string().optional(), rkey: z.string().optional(), handle: z.string().optional(), text: z.string().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
931
|
+
// idempotentHint is TRUE and it is the vendor's own word: `com.atproto.repo.deleteRecord` is documented as
|
|
932
|
+
// "Delete a repository record, or ensure it doesn't exist" — re-deleting a gone post is a no-op, not an error.
|
|
933
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
934
|
+
}, wrap(async (a) => {
|
|
935
|
+
const d = await apiPost('/api/bluesky/delete-post', { uri: a.uri, confirm: a.confirm === true, ...(a.confirmText != null ? { confirmText: a.confirmText } : {}) });
|
|
936
|
+
// REPORT THE READ-BACK, never the 2xx — and on this route that is not a nicety: deleteRecord answers 200
|
|
937
|
+
// identically for a record it removed and one that was never there, so `note` is built from re-reading the
|
|
938
|
+
// record afterwards and an accepted delete that did not take says so instead of being narrated as gone.
|
|
939
|
+
return ok(d.note, d);
|
|
940
|
+
}));
|
|
941
|
+
|
|
901
942
|
server.registerTool('list_inbox', {
|
|
902
943
|
title: 'One inbox — comments, replies, mentions and reviews',
|
|
903
944
|
description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
|
|
904
|
-
+ "Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews,
|
|
905
|
-
+ "X mentions. Use this for 'what do I need to reply to', 'any new comments', 'how are people responding'. Each "
|
|
945
|
+
+ "Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews, "
|
|
946
|
+
+ "Bluesky replies and mentions, and X mentions. Use this for 'what do I need to reply to', 'any new comments', 'how are people responding'. Each "
|
|
906
947
|
+ "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
|
|
907
948
|
+ "skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is "
|
|
908
949
|
+ "never mistaken for a quiet week. Free — it only re-reads what the per-channel tools already read.",
|
|
@@ -979,6 +1020,10 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
979
1020
|
else if (t.source === 'youtube') payload.parentId = t.nativeId;
|
|
980
1021
|
else if (t.source === 'reddit') payload.parentId = t.nativeId;
|
|
981
1022
|
else if (t.source === 'google_business') payload.reviewName = t.nativeId;
|
|
1023
|
+
// Bluesky's native id is the post's AT-URI. The route resolves the two strong refs (root AND parent, each a
|
|
1024
|
+
// uri+cid pair) from it with one getPosts call — carrying four values on the wire would mean four ways for a
|
|
1025
|
+
// caller to detach a reply from its thread, and the cid is a content hash nobody but the server can keep fresh.
|
|
1026
|
+
else if (t.source === 'bluesky' || t.source === 'bluesky_mention') payload.uri = t.nativeId;
|
|
982
1027
|
const r = await apiPost(t.route, payload);
|
|
983
1028
|
const note = `Replied to the ${t.label} as the brand.`;
|
|
984
1029
|
return ok(note, { ok: true, source: t.source, id: r?.id || r?.name || undefined, note });
|
|
@@ -1499,18 +1544,23 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1499
1544
|
postId: z.string().describe('post/media id — or a COMMENT id, which returns that comment\u2019s replies'),
|
|
1500
1545
|
pageId: z.string().optional().describe('Page id — omit when only one Page is connected'),
|
|
1501
1546
|
limit: z.number().optional().describe('how many comments (1–50, default 25)'),
|
|
1547
|
+
cursor: z.string().optional().describe('the cursor from a previous call. A post with more comments than one page comes back with hasMore + a truncationNote — counts or sentiment drawn from ONE page describe a sample, not the conversation.'),
|
|
1502
1548
|
},
|
|
1503
1549
|
outputSchema: { count: z.number().optional(), comments: z.array(z.any()).optional() },
|
|
1504
1550
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1505
1551
|
}, wrap(async (a) => {
|
|
1506
|
-
const d = await apiGet('/api/meta/comments', { postId: a.postId, pageId: a.pageId, limit: a.limit });
|
|
1552
|
+
const d = await apiGet('/api/meta/comments', { postId: a.postId, pageId: a.pageId, limit: a.limit, cursor: a.cursor });
|
|
1507
1553
|
// `hidden` is TRI-STATE: true / false / null = "could not tell" (the server returns null when NEITHER
|
|
1508
1554
|
// Facebook's `is_hidden` nor Instagram's `hidden` came back). Rendering null as blank would put the same
|
|
1509
1555
|
// silent "not hidden" back one layer up, which is exactly the defect the server-side table closed.
|
|
1510
1556
|
// replyCount is part of the ANSWER: without it a comment with replies looks identical to one without, so a
|
|
1511
1557
|
// caller asked to find a reply can only guess which parent to open — and a reply's id lives nowhere else.
|
|
1512
1558
|
const lines = (d.comments || []).map(c => `• ${c.author || '(unknown)'}: ${String(c.text).replace(/\s+/g, ' ').slice(0, 90)} — ${c.id}${c.replyCount ? ` [${c.replyCount} repl${c.replyCount === 1 ? 'y' : 'ies'} — call again with postId=${c.id}]` : ''}${c.hidden === true ? ' [hidden]' : c.hidden === null ? ' [hidden state UNKNOWN — could not read it back]' : ''}${c.likes ? ` · ${c.likes} likes` : ''}`);
|
|
1513
|
-
|
|
1559
|
+
// THE TRUNCATION LEADS. `${d.count} comment(s)` is honest about what came back and still reads as the whole
|
|
1560
|
+
// thread — Meta's own summary.total_count is what makes the difference visible, and it rides the same request.
|
|
1561
|
+
const head = d.truncationNote ? `⚠ ${d.truncationNote}\n\n` : '';
|
|
1562
|
+
const of = d.totalCount != null && d.totalCount !== d.count ? ` of ${d.totalCount} total` : '';
|
|
1563
|
+
return ok(`${head}${d.count} comment(s)${of} on ${d.postId}:\n${lines.join('\n') || '(none)'}`, d);
|
|
1514
1564
|
}));
|
|
1515
1565
|
|
|
1516
1566
|
server.registerTool('reply_to_meta_comment', {
|
|
@@ -1804,8 +1854,16 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1804
1854
|
target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account'),
|
|
1805
1855
|
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.'),
|
|
1806
1856
|
locationId: z.string().optional().describe('Threads only — a place id from search_threads_locations, to geotag the post to a physical location (restaurant, storefront)'),
|
|
1807
|
-
|
|
1857
|
+
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first, so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.'),
|
|
1858
|
+
altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order \u2014 that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta\u2019s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY \u2014 Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (AI disclosure is separate and automatic \u2014 every Instagram post Hermoso publishes is flagged is_ai_generated, which is not a caller setting.)'),
|
|
1808
1859
|
pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
|
|
1860
|
+
// ── THREADS PUBLISH KNOBS (2026-08-17). Meta documents ~24 container parameters and we sent five; these are
|
|
1861
|
+
// the five worth having on a marketing surface. All Threads-only — Meta ignores them elsewhere.
|
|
1862
|
+
linkAttachment: z.string().optional().describe('THREADS TEXT POSTS ONLY — a full http(s) URL rendered as a clickable link card. This is how a Threads post carries a destination at all; without it a link is just text. Meta allows at most 5 links per post and refuses a link attachment on a post that also carries an image or video, so Hermoso refuses that combination up front rather than letting Meta silently drop it.'),
|
|
1863
|
+
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply. Default is everyone.'),
|
|
1864
|
+
quotePostId: z.string().optional().describe('THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option.'),
|
|
1865
|
+
topicTag: z.string().optional().describe('THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&".'),
|
|
1866
|
+
countryCodes: z.array(z.string()).optional().describe('THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. ⚠ This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target.'),
|
|
1809
1867
|
},
|
|
1810
1868
|
outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), url: z.string().optional(), target: z.string().optional(), page: z.string().optional(), account: z.string().optional() },
|
|
1811
1869
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -1829,7 +1887,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1829
1887
|
description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, google_business). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word, which is what a Pin headline used to be. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
|
|
1830
1888
|
inputSchema: {
|
|
1831
1889
|
...HOOK_ATTR,
|
|
1832
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business'])).describe('one or more channels to post to at that time'),
|
|
1890
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky'])).describe('one or more channels to post to at that time'),
|
|
1833
1891
|
at: z.string().optional().describe('when to post — ISO timestamp (2026-08-01T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out. Give this OR useQueue, never both.'),
|
|
1834
1892
|
useQueue: z.boolean().optional().describe('instead of naming a minute, drop this into the brand’s POSTING QUEUE: Hermoso takes the earliest of its saved posting times that is still free (skipping any slot another queued post already holds). This is the natural answer to “just queue it” / “post it at my next opening”. Mutually exclusive with `at` — passing both is refused rather than one being silently preferred. If the brand has no posting times set, or every slot for the next 90 days is taken, it is refused by name and nothing is scheduled.'),
|
|
1835
1893
|
timezone: z.string().optional().describe('IANA zone for the queue, e.g. "America/New_York" — only meaningful with useQueue, and it overrides the brand’s saved zone for this one post. A saved slot of "09:00" is a WALL-CLOCK time, so the zone is what turns it into an instant; without either the brand’s saved zone or this, the queue resolves in UTC.'),
|
|
@@ -1855,7 +1913,19 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1855
1913
|
// empty description box and no tags. The caption cannot stand in for them — it becomes the video TITLE.
|
|
1856
1914
|
description: z.string().optional().describe('YOUTUBE — the video DESCRIPTION, max 5000 characters: the box under the video carrying the links, the CTA and everything YouTube search reads. It is NOT the caption — a scheduled YouTube item’s text becomes its TITLE — so omitting this publishes the video with an empty description.'),
|
|
1857
1915
|
tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
|
|
1858
|
-
|
|
1916
|
+
// THREADS' OWN POST OPTIONS — schedulable since 2026-08-17. `threadsPublish` has accepted all five since the
|
|
1917
|
+
// connector-breadth sweep and `threadsPublishExtras` validates them; only the QUEUE could not carry them, so
|
|
1918
|
+
// they worked when you posted now and vanished when you scheduled.
|
|
1919
|
+
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply. Omit for Threads\' own default (everyone).'),
|
|
1920
|
+
quotePostId: z.string().optional().describe('THREADS ONLY — the id of the Threads post this one quotes.'),
|
|
1921
|
+
linkAttachment: z.string().optional().describe('THREADS ONLY — a full http(s) URL rendered as a link card. This is the ONLY way a Threads post carries a destination, and Threads attaches it to TEXT-ONLY posts (a post with media cannot also carry a card).'),
|
|
1922
|
+
topicTag: z.string().optional().describe('THREADS ONLY — one topic tag for the post, without the leading #.'),
|
|
1923
|
+
countryCodes: z.array(z.string()).optional().describe('THREADS ONLY — two-letter country codes to limit who can see the post. Omit to show it everywhere.'),
|
|
1924
|
+
// PINTEREST per-slide copy, the one true per-slide CAPTION in the set. Declared here as well as on the
|
|
1925
|
+
// publish tool because a field an agent can set when posting NOW and not when SCHEDULING is a gap, not a
|
|
1926
|
+
// design ([[mcp-is-the-complete-surface]] — web-only is a defect, and so is publish-only).
|
|
1927
|
+
slideText: z.array(z.object({ title: z.string().optional(), description: z.string().optional(), link: z.string().optional() })).optional().describe('PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order. Every other platform takes ONE caption for the whole carousel.'),
|
|
1928
|
+
altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 the screen-reader description of the attached image. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order \u2014 that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. CARRIED BY: X (max 1000, one per media), Pinterest (max 500 \u2014 PIN-LEVEL only, since its API has no per-item alt text, so slide 1\u2019s description is used for the whole Pin and the result says the others were not sent), LinkedIn COMPANY PAGES (max 4086, one per slide), INSTAGRAM image posts and image slides (max 1000), FACEBOOK photos and albums, and BLUESKY, whose lexicon makes it REQUIRED on every image. The schedule is REFUSED if the LONGEST description exceeds the tightest of the channels on it, rather than truncated on the way out. NOT CARRIED, and none of these is a refusal \u2014 the post still publishes, just undescribed there, and the per-channel result says which: TikTok (its photo post has no alt field at any level), a THREADS CAROUSEL, an INSTAGRAM Reel or video slide, and a LinkedIn PERSONAL-profile post.'),
|
|
1859
1929
|
link: z.string().optional().describe('a link to attach (Facebook)'),
|
|
1860
1930
|
// ── PER-CHANNEL PUBLISH SETTINGS (server-side SCHED_META_FIELDS, 2026-08-03). Every one of these has been on
|
|
1861
1931
|
// the immediate publish helper since the day it was written — tiktokPost's disclosures and interaction
|
|
@@ -1865,6 +1935,8 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1865
1935
|
// 2026-08-03: TikTok's video/photo Post Info tables, Google's v4 LocalPost reference, docs.x.com create-post.
|
|
1866
1936
|
brandedContent: z.boolean().optional().describe('TIKTOK — the PAID-PARTNERSHIP disclosure (TikTok’s brand_content_toggle): true when this post promotes a THIRD-PARTY business. It is a compliance declaration, not a preference — set it whenever the post is sponsored. TikTok only accepts branded content on a public or friends-only post, so it cannot ride a private/SELF_ONLY or draft TikTok item and the schedule is refused with the reason.'),
|
|
1867
1937
|
yourBrand: z.boolean().optional().describe('TIKTOK — the OWN-BRAND disclosure (brand_organic_toggle): true when the post promotes the creator’s own business. TikTok asks for at least one of this and brandedContent once a post is commercial.'),
|
|
1938
|
+
privacyLevel: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']).optional().describe('TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private → SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info.'),
|
|
1939
|
+
commercialContent: z.boolean().optional().describe('TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when the post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent alongside it, and a post declaring itself commercial without naming which kind is refused. Setting either disclosure already implies this, so it is only needed to be explicit.'),
|
|
1868
1940
|
disableComment: z.boolean().optional().describe('TIKTOK — turn comments off on this post.'),
|
|
1869
1941
|
disableDuet: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Duets. TikTok’s photo-post API has no Duets, so this is refused on a photo/slideshow item rather than silently dropped.'),
|
|
1870
1942
|
disableStitch: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet.'),
|
|
@@ -1925,19 +1997,32 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1925
1997
|
at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
|
|
1926
1998
|
message: z.string().optional().describe('replace the caption used for every channel that has no override'),
|
|
1927
1999
|
captions: z.record(z.string()).optional().describe('replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one'),
|
|
1928
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business'])).optional().describe('replaces the channel list'),
|
|
2000
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky'])).optional().describe('replaces the channel list'),
|
|
1929
2001
|
imageUrl: z.string().optional().describe('swap the image; "" removes it'),
|
|
1930
2002
|
videoUrl: z.string().optional().describe('swap the video; "" removes it'),
|
|
1931
2003
|
imageUrls: z.array(z.string()).optional().describe('replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later.'),
|
|
1932
2004
|
title: z.string().optional().describe('PINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption'),
|
|
1933
2005
|
description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it. Remember the caption is the TITLE, not the description.'),
|
|
1934
2006
|
tags: z.array(z.string()).optional().describe('YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.'),
|
|
1935
|
-
|
|
2007
|
+
// The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
|
|
2008
|
+
// and reschedule_post is how an agent corrects a queued post it got wrong.
|
|
2009
|
+
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
|
|
2010
|
+
quotePostId: z.string().optional().describe('THREADS ONLY — the id of the Threads post this one quotes.'),
|
|
2011
|
+
linkAttachment: z.string().optional().describe('THREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination.'),
|
|
2012
|
+
topicTag: z.string().optional().describe('THREADS ONLY — one topic tag, without the leading #.'),
|
|
2013
|
+
countryCodes: z.array(z.string()).optional().describe('THREADS ONLY — two-letter country codes limiting who can see the post.'),
|
|
2014
|
+
slideText: z.array(z.object({ title: z.string().optional(), description: z.string().optional(), link: z.string().optional() })).optional().describe('PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order.'),
|
|
2015
|
+
// TikTok's privacy level is settable at CREATE and must be settable at EDIT. Without it here an agent
|
|
2016
|
+
// that queued the wrong level had to cancel and re-create — and since schedValidate refuses a TikTok
|
|
2017
|
+
// item naming none, a PATCH that dropped it would fail with a rule the caller could not satisfy.
|
|
2018
|
+
privacyLevel: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']).optional().describe('TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private → SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info.'),
|
|
2019
|
+
altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 replace the screen-reader description(s). A STRING describes every slide; an ARRAY describes them one at a time in slide order and REPLACES the whole list. "" clears it.'),
|
|
1936
2020
|
link: z.string().optional(),
|
|
1937
2021
|
// The per-channel publish settings (SCHED_META_FIELDS), on the same omitted-means-keep rule as everything else
|
|
1938
2022
|
// here: send only what changes. A whole-record field (`event`, `offer`, `poll`, `thread`) REPLACES the record.
|
|
1939
2023
|
brandedContent: z.boolean().optional().describe('TIKTOK — the paid-partnership disclosure; false turns it off.'),
|
|
1940
2024
|
yourBrand: z.boolean().optional().describe('TIKTOK — the own-brand disclosure; false turns it off.'),
|
|
2025
|
+
commercialContent: z.boolean().optional().describe('TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when this post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent once it is on, and refuses a post that declares itself commercial without naming which kind. Either disclosure already implies it.'),
|
|
1941
2026
|
disableComment: z.boolean().optional().describe('TIKTOK — comments off on this post.'),
|
|
1942
2027
|
disableDuet: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Duets.'),
|
|
1943
2028
|
disableStitch: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Stitches.'),
|
|
@@ -1985,7 +2070,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1985
2070
|
description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
|
|
1986
2071
|
inputSchema: {
|
|
1987
2072
|
id: z.string().describe('the scheduled post id from list_scheduled'),
|
|
1988
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
|
|
2073
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
|
|
1989
2074
|
at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
|
|
1990
2075
|
allowDuplicate: z.boolean().optional().describe('ONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated.'),
|
|
1991
2076
|
boardId: z.string().optional().describe('CORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed.'),
|
|
@@ -2010,7 +2095,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
2010
2095
|
at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
|
|
2011
2096
|
useQueue: z.boolean().optional().describe('instead of naming a time, take the brand’s next free posting slot'),
|
|
2012
2097
|
timezone: z.string().optional().describe('IANA zone for the queue, e.g. "America/New_York"'),
|
|
2013
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business'])).optional().describe('post the copy to these channels instead of the original’s'),
|
|
2098
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky'])).optional().describe('post the copy to these channels instead of the original’s'),
|
|
2014
2099
|
message: z.string().optional().describe('a different caption for the copy'),
|
|
2015
2100
|
captions: z.record(z.string()).optional().describe('per-channel caption overrides for the copy'),
|
|
2016
2101
|
imageUrl: z.string().optional(), videoUrl: z.string().optional(),
|
|
@@ -2061,7 +2146,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
2061
2146
|
maxImagesPerDay: z.number().optional().describe('how many NEW images a day it may render when the Library runs dry. 0 (default) = none, spend nothing.'),
|
|
2062
2147
|
maxVideosPerDay: z.number().optional().describe('how many NEW videos a day it may render. 0 (default) = none. Video is the expensive one — hundreds of credits each.'),
|
|
2063
2148
|
maxCreditsPerDay: z.number().optional().describe('a hard credit ceiling per day, checked BEFORE any render starts. It binds independently of the counts above.'),
|
|
2064
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest'])).optional().describe('restrict it to these channels. Omit (or send an empty list) to use every connected channel that can carry each post.'),
|
|
2149
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky'])).optional().describe('restrict it to these channels. Omit (or send an empty list) to use every connected channel that can carry each post.'),
|
|
2065
2150
|
boardId: z.string().optional().describe('PINTEREST — which board Pins go on (list_pinterest_boards). Without one, Pinterest is skipped: a Pin on the wrong board is a public mistake, so it is never guessed.'),
|
|
2066
2151
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this brand is ignored rather than failing the whole post.'),
|
|
2067
2152
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page.'),
|
|
@@ -2129,7 +2214,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
2129
2214
|
thread: z.array(z.string()).optional().describe('a thread: each string is one post (≤280 chars each), published in order, each replying to the previous. Max 25.'),
|
|
2130
2215
|
mediaUrl: z.string().optional().describe('a Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media'),
|
|
2131
2216
|
mediaUrls: z.array(z.string()).optional().describe('UP TO FOUR Hermoso-hosted media attached to ONE post — X\u2019s own schema caps media_ids at 4. X renders them as a GRID: every image visible at once, nothing to swipe to. That is NOT a carousel, and a numbered "1/6 \u00b7 SWIPE" slide deck must still not be sent here — it would publish as a grid and the "swipe" instruction would make no sense. Order decides the layout. On a thread the media rides the FIRST post; give each later part its own post to attach more. Mutually exclusive with mediaUrl, and more than 4 is refused before anything is uploaded.'),
|
|
2132
|
-
altText: z.string().optional().describe('accessibility description of the attached media, max 1000 characters
|
|
2217
|
+
altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility description of the attached media, max 1000 characters \u2014 write one whenever you attach a render. Costs a small extra amount: X bills one metadata write PER media. With SEVERAL media, pass an ARRAY aligned to their order \u2014 X attaches alt text per media id, and a single string describes only the FIRST one (X renders up to four media as a GRID, not a carousel, so one sentence would be wrong for the other three).'),
|
|
2133
2218
|
poll: z.object({
|
|
2134
2219
|
options: z.array(z.string()).describe('2-4 choices, max 25 characters each'),
|
|
2135
2220
|
durationMinutes: z.number().optional().describe('5 to 10080 minutes (7 days); default 1440 = one day'),
|
|
@@ -2410,7 +2495,8 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
2410
2495
|
title: z.string().optional().describe('Pin title, max 100 characters'),
|
|
2411
2496
|
description: z.string().optional().describe('Pin description, max 800 characters — this is what Pinterest search reads'),
|
|
2412
2497
|
link: z.string().optional().describe('destination URL the Pin clicks through to'),
|
|
2413
|
-
altText: z.string().optional().describe('accessibility alt text, max 500 characters'),
|
|
2498
|
+
altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text, max 500 characters. PIN-LEVEL: Pinterest\u2019s API has no per-item alt text at all, so on a CAROUSEL the FIRST description is used for the whole Pin and the reply states that the others were not sent.'),
|
|
2499
|
+
slideText: z.array(z.object({ title: z.string().optional(), description: z.string().optional(), link: z.string().optional() })).optional().describe('PINTEREST CAROUSEL ONLY \u2014 per-slide title, description and destination LINK, one object per slide in slide order. This is the ONLY genuine per-slide caption on any channel Hermoso publishes to: slide 4 can send people to the product ON slide 4, where every other platform gives a carousel one shared caption. Omit any field to leave it unset; the Pin\u2019s own title/description/link still describe the Pin as a whole. Pinterest publishes no length limit on these, so nothing is truncated. More entries than slides is refused rather than dropped.'),
|
|
2414
2500
|
coverImageUrl: z.string().optional().describe('video Pins only — a render to use as the cover frame'),
|
|
2415
2501
|
boardSectionId: z.string().optional().describe('optional section within the board'),
|
|
2416
2502
|
},
|
|
@@ -2510,7 +2596,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
2510
2596
|
}));
|
|
2511
2597
|
server.registerTool('delete_pinterest_board', {
|
|
2512
2598
|
title: 'Delete a Pinterest board',
|
|
2513
|
-
description: 'PERMANENTLY delete a board AND EVERY PIN ON IT. This is the heaviest thing that can be done to a Pinterest account and there is no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the board’s real name, how many Pins are on it, how many people FOLLOW it and how many collaborators lose access — all read back from Pinterest. Show the user that, then call again with confirm:true plus confirmName (its exact name) and confirmChildren (the Pin count it reported); those echoes exist because a caller who has not looked at the board cannot supply them, and confirming intent alone does not prove aim. IF THEY ONLY WANT IT OUT OF PUBLIC VIEW
|
|
2599
|
+
description: 'PERMANENTLY delete a board AND EVERY PIN ON IT. This is the heaviest thing that can be done to a Pinterest account and there is no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the board’s real name, how many Pins are on it, how many people FOLLOW it and how many collaborators lose access — all read back from Pinterest. Show the user that, then call again with confirm:true plus confirmName (its exact name) and confirmChildren (the Pin count it reported); those echoes exist because a caller who has not looked at the board cannot supply them, and confirming intent alone does not prove aim. IF THEY ONLY WANT IT OUT OF PUBLIC VIEW: do NOT reach for update_pinterest_board(privacy:"SECRET") — making a board secret is a SEPARATE Pinterest permission (boards:write_secret) that Hermoso does not request, so that call is refused for every user and steering someone onto it turns a survivable delete into a dead end. The reversible option lives in Pinterest’s own app. The unconfirmed refusal says which of the two applies to this connection, read off the live scope set — relay it verbatim rather than guessing. 0 credits. Needs Pinterest connected.',
|
|
2514
2600
|
inputSchema: {
|
|
2515
2601
|
boardId: z.string().describe('numeric board id from list_pinterest_boards'),
|
|
2516
2602
|
confirm: z.boolean().optional().describe('REQUIRED true — the board and its Pins are gone for good'),
|
|
@@ -2972,6 +3058,156 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
2972
3058
|
const d = await apiPost('/api/youtube/reply-comment', a);
|
|
2973
3059
|
return ok('Reply posted.', d);
|
|
2974
3060
|
}));
|
|
3061
|
+
// ── YOUTUBE CHANNEL MANAGEMENT (2026-08-17) ─────────────────────────────────────────────────────────────────
|
|
3062
|
+
// YouTube Data v3 publishes 83 methods and 76 of them accept `youtube.force-ssl`, the scope every connected
|
|
3063
|
+
// channel already granted. We shipped ~10. Everything below is a WIRING gap, not a scope gap: no new permission,
|
|
3064
|
+
// no reconnect, no consent-screen change. Shapes read off Google's own discovery document (rev 20260813).
|
|
3065
|
+
server.registerTool('list_youtube_playlists', {
|
|
3066
|
+
title: 'List the channel’s playlists (and what is in one)',
|
|
3067
|
+
description: 'List the playlists on the connected YouTube channel — id, title, description, privacy and video count. Pass a playlistId to get that ONE playlist plus its entries in order. IMPORTANT: each entry carries BOTH a `videoId` and an `itemId`; `itemId` is the playlist-ENTRY id and is what manage_youtube_playlist_items needs to remove or re-order a row, because one video can appear in a playlist more than once so a videoId does not identify the entry. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
3068
|
+
inputSchema: { playlistId: z.string().optional().describe('one playlist to open, with its entries. Omit to list the channel’s playlists.'), limit: z.number().optional().describe('max rows, default 25, cap 50 (YouTube’s own maximum)'), pageToken: z.string().optional().describe('nextPageToken from a previous call') },
|
|
3069
|
+
outputSchema: { playlists: z.array(z.object({ playlistId: z.string().optional(), title: z.string().optional(), description: z.string().optional(), privacy: z.string().optional(), itemCount: z.number().optional(), url: z.string().optional() })).optional(), count: z.number().optional(), playlistId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), itemCount: z.number().optional(), items: z.array(z.object({ itemId: z.string().optional(), videoId: z.string().optional(), title: z.string().optional(), position: z.number().optional(), privacy: z.string().optional() })).optional(), itemsReturned: z.number().optional(), nextPageToken: z.string().nullable().optional(), note: z.string().optional() },
|
|
3070
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3071
|
+
}, wrap(async (a) => {
|
|
3072
|
+
const d = await apiGet('/api/youtube/playlists', { ...(a.playlistId ? { playlistId: a.playlistId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.pageToken ? { pageToken: a.pageToken } : {}) });
|
|
3073
|
+
if (d.items) return ok(`“${d.title}” (${d.playlistId}) — ${d.privacy}, ${d.itemCount} video(s):\n${d.items.map(i => ` ${i.position}. ${i.title} — videoId ${i.videoId}, itemId ${i.itemId}`).join('\n') || ' (empty)'}\n${d.note}`, d);
|
|
3074
|
+
return ok(`${d.count} playlist(s):\n${(d.playlists || []).map(p => ` • ${p.title} (${p.playlistId}) — ${p.privacy}, ${p.itemCount} video(s)`).join('\n') || ' (none)'}${d.note ? `\n${d.note}` : ''}`, d);
|
|
3075
|
+
}));
|
|
3076
|
+
server.registerTool('manage_youtube_playlist', {
|
|
3077
|
+
title: 'Create, rename or delete a YouTube playlist',
|
|
3078
|
+
description: 'Create, update or delete a playlist on the connected channel. CREATE makes it PRIVATE unless you pass privacy — a public playlist appears on the channel immediately, so publishing is always a deliberate choice. UPDATE merges: an omitted title or description is LEFT ALONE rather than erased. DELETE is confirm-gated and does NOT delete the videos — what dies is the playlist, its ordering and every link or embed pointing at its URL; a playlist that is public or has videos in it additionally needs confirmTitle echoed back exactly, because confirm alone proves you meant to delete something and not that you aimed at the right playlist. To take a playlist out of public view WITHOUT destroying it use action="update" with privacy="private" — that is reversible and delete is not. 0 credits. Needs a connected YouTube channel.',
|
|
3079
|
+
inputSchema: { action: z.enum(['create', 'update', 'delete']).describe('create | update | delete'), playlistId: z.string().optional().describe('required for update and delete'), title: z.string().optional().describe('required on create; on update, omit to leave unchanged'), description: z.string().optional().describe('on update, omit to leave unchanged'), privacy: z.enum(['public', 'unlisted', 'private']).optional().describe('default private on create'), confirm: z.boolean().optional().describe('required to delete'), confirmTitle: z.string().optional().describe('the playlist’s EXACT title — required to delete one that is public or non-empty') },
|
|
3080
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), playlistId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), itemCount: z.number().optional(), url: z.string().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
3081
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
3082
|
+
}, wrap(async (a) => {
|
|
3083
|
+
const d = await apiPost('/api/youtube/playlist', a);
|
|
3084
|
+
return ok(`${d.note}${d.url ? ` ${d.url}` : ''}`, d);
|
|
3085
|
+
}));
|
|
3086
|
+
server.registerTool('manage_youtube_playlist_items', {
|
|
3087
|
+
title: 'Add, remove or re-order a video in a playlist',
|
|
3088
|
+
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.',
|
|
3089
|
+
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)') },
|
|
3090
|
+
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() },
|
|
3091
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
3092
|
+
}, wrap(async (a) => { const d = await apiPost('/api/youtube/playlist-items', a); return ok(d.note, d); }));
|
|
3093
|
+
server.registerTool('list_youtube_captions', {
|
|
3094
|
+
title: 'List (and read) a video’s caption tracks',
|
|
3095
|
+
description: 'List the caption/subtitle tracks on one of the connected channel’s videos, and optionally DOWNLOAD one as text. A caption TRACK is not the same thing as burned-in captions: a track is what YouTube indexes the video by, what a viewer toggles on, and what accessibility depends on. Each row says whether YouTube generated it automatically (`isAutoGenerated`, trackKind ASR) — those are read-only and cannot be edited or deleted. Downloading is also the fastest way to get an existing video’s full script back for repurposing. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
3096
|
+
inputSchema: { videoId: z.string().describe('the YouTube video id — captions are listed per video'), download: z.string().optional().describe('a captionId or a language code to also download as text'), format: z.enum(['srt', 'vtt', 'sbv']).optional().describe('download format, default srt') },
|
|
3097
|
+
outputSchema: { videoId: z.string().optional(), count: z.number().optional(), captions: z.array(z.object({ captionId: z.string().optional(), language: z.string().optional(), name: z.string().optional(), trackKind: z.string().optional(), isAutoGenerated: z.boolean().optional(), isDraft: z.boolean().optional(), status: z.string().optional() })).optional(), downloaded: z.object({ captionId: z.string().optional(), language: z.string().optional(), format: z.string().optional(), text: z.string().optional(), truncated: z.boolean().optional() }).optional(), downloadError: z.string().optional(), note: z.string().optional() },
|
|
3098
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3099
|
+
}, wrap(async (a) => {
|
|
3100
|
+
const d = await apiGet('/api/youtube/captions', { videoId: a.videoId, ...(a.download ? { download: a.download } : {}), ...(a.format ? { format: a.format } : {}) });
|
|
3101
|
+
const list = (d.captions || []).map(c => ` • ${c.language}${c.name ? ` “${c.name}”` : ''} — ${c.captionId}${c.isAutoGenerated ? ' (auto-generated by YouTube, read-only)' : ''}${c.isDraft ? ' [draft]' : ''}${c.status ? ` · ${c.status}` : ''}`);
|
|
3102
|
+
return ok(`${d.count} caption track(s) on ${d.videoId}:\n${list.join('\n') || ' (none)'}${d.downloaded ? `\n\n--- ${d.downloaded.language} (${d.downloaded.format}) ---\n${d.downloaded.text}` : ''}${d.downloadError ? `\n${d.downloadError}` : ''}${d.note ? `\n${d.note}` : ''}`, d);
|
|
3103
|
+
}));
|
|
3104
|
+
server.registerTool('manage_youtube_caption', {
|
|
3105
|
+
title: 'Upload, replace or delete a caption track',
|
|
3106
|
+
description: 'Attach a real subtitle track to one of the connected channel’s videos, replace one, or delete one. Pass the caption file BODY itself as `text` (SRT or WebVTT), not a URL. `language` is required and may not be guessed — YouTube uses it to decide which viewers see the track. A track YouTube generated itself (trackKind ASR) is refused by name: YouTube owns it and it cannot be edited or deleted through any API; to replace it, upload your own track in the same language and YouTube prefers yours. Delete is confirm-gated — viewers with captions on lose them immediately and YouTube stops indexing the video by that text, though it can be re-uploaded if you still have the file. 0 credits. Needs a connected YouTube channel.',
|
|
3107
|
+
inputSchema: { action: z.enum(['upload', 'update', 'delete']).describe('upload | update | delete'), videoId: z.string().optional().describe('required for upload; strongly recommended for update and delete so the track can be resolved and read back'), captionId: z.string().optional().describe('required for update and delete (from list_youtube_captions)'), text: z.string().optional().describe('the caption file body — SRT or WebVTT. Required for upload.'), language: z.string().optional().describe('BCP-47 code such as en, es, pt-BR. Required for upload.'), name: z.string().optional().describe('a label shown in YouTube’s caption picker'), draft: z.boolean().optional().describe('true keeps the track unpublished while you review it'), confirm: z.boolean().optional().describe('required to delete') },
|
|
3108
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), videoId: z.string().nullable().optional(), captionId: z.string().optional(), language: z.string().optional(), name: z.string().optional(), status: z.string().optional(), isDraft: z.boolean().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
3109
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
3110
|
+
}, wrap(async (a) => { const d = await apiPost('/api/youtube/caption', a); return ok(d.note, d); }));
|
|
3111
|
+
server.registerTool('moderate_youtube_comment', {
|
|
3112
|
+
title: 'Hide, reject, spam-report or delete a YouTube comment',
|
|
3113
|
+
description: 'Moderate a comment on the connected channel — the YouTube twin of moderate_meta_comment. ACTIONS: publish (make it visible again) · hold (hide it pending review) · reject (hide it as unfit for display) · spam (report it to YouTube — a REPORT, not a removal, so do not tell the user it is gone) · delete (permanent; YouTube has no undelete for a comment). hold, reject and publish are all reversible with each other, so only delete is confirm-gated. If the goal is just to take a comment out of public view, use reject rather than delete. banAuthor is valid ONLY with action="reject" — that is YouTube’s own restriction — and auto-rejects every future comment that author leaves on the channel. Get commentId from list_youtube_comments. 0 credits. Needs a connected YouTube channel.',
|
|
3114
|
+
inputSchema: { action: z.enum(['publish', 'hold', 'reject', 'spam', 'delete']).describe('publish | hold | reject | spam | delete'), commentId: z.string().describe('id of the comment (from list_youtube_comments)'), banAuthor: z.boolean().optional().describe('reject only — also auto-reject every future comment from this author'), confirm: z.boolean().optional().describe('required for delete') },
|
|
3115
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), commentId: z.string().optional(), moderationStatus: z.string().optional(), banAuthor: z.boolean().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), before: z.object({ author: z.string().optional(), text: z.string().optional(), likes: z.number().optional() }).nullable().optional(), note: z.string().optional() },
|
|
3116
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
3117
|
+
}, wrap(async (a) => { const d = await apiPost('/api/youtube/moderate-comment', a); return ok(d.note, d); }));
|
|
3118
|
+
server.registerTool('list_youtube_categories', {
|
|
3119
|
+
title: 'List the video categories you can publish under',
|
|
3120
|
+
description: 'List the video categories YouTube will accept on an upload in a given country. post_to_youtube takes a `categoryId` and this is the only way to discover a valid one — the id set AND the names differ by country, which is why regionCode is required rather than defaulted. Categories YouTube marks not-assignable are filtered out by default because an upload using one is refused. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
3121
|
+
inputSchema: { regionCode: z.string().describe('ISO 3166-1 alpha-2 country code — US, GB, DE. YouTube’s category ids differ by country, so this cannot be guessed.'), includeUnassignable: z.boolean().optional().describe('also return categories YouTube will refuse on upload (default false)') },
|
|
3122
|
+
outputSchema: { regionCode: z.string().optional(), count: z.number().optional(), categories: z.array(z.object({ categoryId: z.string().optional(), title: z.string().optional(), assignable: z.boolean().optional() })).optional(), note: z.string().optional() },
|
|
3123
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3124
|
+
}, wrap(async (a) => {
|
|
3125
|
+
const d = await apiGet('/api/youtube/categories', { regionCode: a.regionCode, ...(a.includeUnassignable ? { includeUnassignable: 'true' } : {}) });
|
|
3126
|
+
return ok(`${d.count} category(ies) for ${d.regionCode}:\n${(d.categories || []).map(c => ` • ${c.categoryId} — ${c.title}${c.assignable === false ? ' (NOT assignable)' : ''}`).join('\n')}\n${d.note}`, d);
|
|
3127
|
+
}));
|
|
3128
|
+
server.registerTool('google_business_search_keywords', {
|
|
3129
|
+
title: 'What people searched to find this business',
|
|
3130
|
+
description: 'The actual search terms people typed on Google Search and Maps before this business listing appeared — the only keyword data a local business gets for free, and the direct input to their Google Ads keyword set, page titles and profile description. google_business_insights answers HOW MANY people found the listing; this answers WHICH WORDS they used. ⚠ LOW-VOLUME TERMS ARE SUPPRESSED: Google withholds an exact count for them and returns only an upper bound, so those rows come back with impressions=null and below=<threshold>. Report those as “fewer than N” — NEVER as zero and never as the threshold itself, both of which are numbers a marketer would act on and neither is true. Counts are UNIQUE USERS per month summed across the window, not impressions; the two are not comparable. Google keeps roughly 12 months of history. Read-only, 0 credits. Needs Google Business Profile connected.',
|
|
3131
|
+
inputSchema: { locationId: z.string().optional().describe('which listing, when the brand has more than one (list_business_locations)'), months: z.number().optional().describe('how many whole months back, default 3, cap 12. The current month is excluded because it is always partial.'), limit: z.number().optional().describe('max terms, default 100, which is also Google’s maximum'), pageToken: z.string().optional().describe('nextPageToken from a previous call') },
|
|
3132
|
+
outputSchema: { location: z.string().optional(), locationId: z.string().optional(), from: z.string().optional(), to: z.string().optional(), months: z.number().optional(), count: z.number().optional(), keywords: z.array(z.object({ keyword: z.string().optional(), impressions: z.number().nullable().optional(), suppressed: z.boolean().optional(), below: z.number().nullable().optional(), display: z.string().optional() })).optional(), nextPageToken: z.string().nullable().optional(), note: z.string().optional() },
|
|
3133
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3134
|
+
}, wrap(async (a) => {
|
|
3135
|
+
const d = await apiGet('/api/google-business/search-keywords', { ...(a.locationId ? { locationId: a.locationId } : {}), ...(a.months ? { months: a.months } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.pageToken ? { pageToken: a.pageToken } : {}) });
|
|
3136
|
+
return ok(`${d.count} search term(s) for ${d.location} (${d.from} → ${d.to}):\n${(d.keywords || []).map(k => ` • ${k.keyword} — ${k.display}${k.suppressed ? ' unique users (suppressed: Google gives only an upper bound)' : ' unique users'}`).join('\n') || ' (none)'}\n${d.note}`, d);
|
|
3137
|
+
}));
|
|
3138
|
+
server.registerTool('bluesky_post_metrics', {
|
|
3139
|
+
title: 'Read likes, reposts, replies and quotes on your Bluesky posts',
|
|
3140
|
+
description: 'Read live engagement for up to 25 of the connected account’s Bluesky posts — likes, reposts, replies, quotes and bookmarks. Address a post by its AT-URI (the `at://…` value post_to_bluesky returns), not its web URL. Bluesky publishes NO impression or view count in any AT Protocol lexicon, so these are COUNTS with no denominator and no engagement rate can be computed from them — do not present one. A uri Bluesky returns nothing for is reported as MISSING (deleted, or not on the connected account), never as zero engagement. Read-only, 0 credits. Needs Bluesky connected.',
|
|
3141
|
+
inputSchema: { uris: z.array(z.string()).describe('AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call)') },
|
|
3142
|
+
outputSchema: { handle: z.string().optional(), count: z.number().optional(), found: z.number().optional(), missing: z.number().optional(), posts: z.array(z.object({ uri: z.string().optional(), found: z.boolean().optional(), handle: z.string().optional(), text: z.string().optional(), url: z.string().optional(), indexedAt: z.string().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional() })).optional(), note: z.string().optional() },
|
|
3143
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3144
|
+
}, wrap(async (a) => {
|
|
3145
|
+
const d = await apiGet('/api/bluesky/post-metrics', { uris: (a.uris || []).join(',') });
|
|
3146
|
+
const rows = (d.posts || []).map(p => p.found
|
|
3147
|
+
? ` • ${p.text ? `“${p.text.slice(0, 80)}${p.text.length > 80 ? '…' : ''}”` : p.uri} — ${Object.entries(p.metrics || {}).map(([k, v]) => `${v} ${k}`).join(', ') || 'no counts returned'}`
|
|
3148
|
+
: ` • ${p.uri} — MISSING (deleted, or not on this account) — not zero engagement`);
|
|
3149
|
+
return ok(`${d.found} of ${d.count} post(s) on @${d.handle}:\n${rows.join('\n')}\n${d.note}`, d);
|
|
3150
|
+
}));
|
|
3151
|
+
server.registerTool('list_bluesky_convos', {
|
|
3152
|
+
title: 'List Bluesky direct-message conversations',
|
|
3153
|
+
description: 'Read the connected Bluesky account\u2019s DM conversations \u2014 who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no ScrapeCreators credits, no vendor approval: AT Protocol app passwords are not scoped. It DOES need a PRIVILEGED app password \u2014 one created with direct-message access ticked \u2014 and says so precisely if the saved one cannot chat, which is a property of the password and NOT a broken connection. Filters: readState "unread", status "request" or "accepted", kind "direct" or "group". An unknown filter value is refused by name, never silently dropped.',
|
|
3154
|
+
inputSchema: {
|
|
3155
|
+
limit: z.number().optional().describe('how many conversations, 1\u2013100 (default 25)'),
|
|
3156
|
+
cursor: z.string().optional().describe('walk further back \u2014 pass the cursor from a previous call'),
|
|
3157
|
+
readState: z.enum(['unread']).optional().describe('only conversations with unread messages'),
|
|
3158
|
+
status: z.enum(['request', 'accepted']).optional().describe('"request" = from someone this account does not follow'),
|
|
3159
|
+
kind: z.enum(['direct', 'group']).optional(),
|
|
3160
|
+
},
|
|
3161
|
+
outputSchema: { handle: z.string().optional(), convos: z.array(z.any()).optional(), unreadTotal: z.number().optional(), requests: z.number().optional(), cursor: z.string().optional(), hasMore: z.boolean().optional() },
|
|
3162
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3163
|
+
}, wrap(async (a) => {
|
|
3164
|
+
const d = await apiGet('/api/bluesky/convos', a);
|
|
3165
|
+
const rows = (d.convos || []).map(c => ` \u2022 ${c.with}${c.isRequest ? ' [REQUEST]' : ''}${c.unread ? ` \u2014 ${c.unread} unread` : ''}${c.lastMessage ? `: \u201c${c.lastMessage.slice(0, 70)}${c.lastMessage.length > 70 ? '\u2026' : ''}\u201d` : c.lastMessageDeleted ? ': (last message deleted)' : ''}\n convoId ${c.convoId}`);
|
|
3166
|
+
return ok(`@${d.handle} \u2014 ${(d.convos || []).length} conversation(s), ${d.unreadTotal} unread, ${d.requests} request(s):\n${rows.join('\n') || ' (none)'}${d.hasMore ? '\n \u2026 more \u2014 pass cursor to walk back' : ''}`, d);
|
|
3167
|
+
}));
|
|
3168
|
+
server.registerTool('read_bluesky_dm', {
|
|
3169
|
+
title: 'Read the messages in one Bluesky conversation',
|
|
3170
|
+
description: 'Read a Bluesky DM conversation in CHRONOLOGICAL order (Bluesky returns newest-first on the wire; this reverses it so the conversation reads top to bottom). Each message says whether it was sent by this account. A message with no text is reported as DELETED rather than as an empty message \u2014 \u201cthey unsent it\u201d and \u201cthey sent nothing\u201d are different facts. Get a convoId from list_bluesky_convos.',
|
|
3171
|
+
inputSchema: {
|
|
3172
|
+
convoId: z.string().describe('from list_bluesky_convos'),
|
|
3173
|
+
limit: z.number().optional().describe('how many messages, 1\u2013100 (default 30)'),
|
|
3174
|
+
cursor: z.string().optional().describe('walk further back through older messages'),
|
|
3175
|
+
},
|
|
3176
|
+
outputSchema: { convoId: z.string().optional(), messages: z.array(z.any()).optional(), handle: z.string().optional(), cursor: z.string().optional(), hasMore: z.boolean().optional(), truncationNote: z.string().optional() },
|
|
3177
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3178
|
+
}, wrap(async (a) => {
|
|
3179
|
+
const d = await apiGet('/api/bluesky/messages', a);
|
|
3180
|
+
const rows = (d.messages || []).map(m => ` ${m.fromMe ? '\u2192 you' : '\u2190 them'} ${m.sentAt ? `(${m.sentAt})` : ''}: ${m.deleted ? '(deleted)' : m.text}`);
|
|
3181
|
+
return ok(`Conversation ${d.convoId} on @${d.handle}, oldest first:\n${rows.join('\n') || ' (no messages)'}${d.truncationNote ? `\n${d.truncationNote}` : ''}`, d);
|
|
3182
|
+
}));
|
|
3183
|
+
server.registerTool('send_bluesky_dm', {
|
|
3184
|
+
title: 'Send a Bluesky direct message',
|
|
3185
|
+
description: 'Send ONE direct message on the connected Bluesky account, either into an existing conversation (convoId) or to a named handle (a conversation is opened for you \u2014 the handle is resolved to a DID first, because Bluesky identifies chat members by DID). A DM allows 1000 characters, NOT the 300 a post allows. Refusals name the real situation rather than a generic failure: the recipient has DMs off, only accepts DMs from people they follow, has blocked this account, is suspended, or does not exist. There is deliberately no broadcast form \u2014 a recipient is always named.',
|
|
3186
|
+
inputSchema: {
|
|
3187
|
+
convoId: z.string().optional().describe('reply into an existing conversation (from list_bluesky_convos). Omit to start one with `to`.'),
|
|
3188
|
+
to: z.string().optional().describe('the recipient\u2019s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given.'),
|
|
3189
|
+
text: z.string().describe('the message, up to 1000 characters'),
|
|
3190
|
+
replyToMessageId: z.string().optional().describe('reply to a specific message in the conversation'),
|
|
3191
|
+
},
|
|
3192
|
+
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), sentAt: z.string().optional(), text: z.string().optional(), to: z.string().optional(), delivered: z.boolean().optional() },
|
|
3193
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
3194
|
+
}, wrap(async (a) => {
|
|
3195
|
+
const d = await apiPost('/api/bluesky/dm', a);
|
|
3196
|
+
return ok(`${d.delivered ? 'Sent' : 'Bluesky accepted but returned no message id for'} the DM${d.to ? ` to ${d.to}` : ''} \u2014 conversation ${d.convoId}${d.sentAt ? `, recorded at ${d.sentAt}` : ''}.\n\u201c${d.text}\u201d`, d);
|
|
3197
|
+
}));
|
|
3198
|
+
server.registerTool('mark_bluesky_convo_read', {
|
|
3199
|
+
title: 'Mark a Bluesky conversation as read',
|
|
3200
|
+
description: 'Clear the unread count on one Bluesky DM conversation, optionally only up to a specific message. Useful after triaging an inbox so the next list_bluesky_convos does not surface the same thread again. Reports the unread count Bluesky reads back, not the one requested.',
|
|
3201
|
+
inputSchema: {
|
|
3202
|
+
convoId: z.string().describe('from list_bluesky_convos'),
|
|
3203
|
+
messageId: z.string().optional().describe('mark read only up to this message; omit to clear the whole conversation'),
|
|
3204
|
+
},
|
|
3205
|
+
outputSchema: { convoId: z.string().optional(), unread: z.number().optional(), with: z.string().optional() },
|
|
3206
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
3207
|
+
}, wrap(async (a) => {
|
|
3208
|
+
const d = await apiPost('/api/bluesky/mark-read', a);
|
|
3209
|
+
return ok(`Conversation with ${d.with || d.convoId} now reads ${d.unread} unread.`, d);
|
|
3210
|
+
}));
|
|
2975
3211
|
server.registerTool('tiktok_creator_info', {
|
|
2976
3212
|
title: 'Read the connected TikTok creator’s posting options',
|
|
2977
3213
|
description: 'Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).',
|
|
@@ -3151,9 +3387,9 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
3151
3387
|
};
|
|
3152
3388
|
server.registerTool('create_meta_ad', {
|
|
3153
3389
|
title: 'Build a full Meta ad (campaign → ad set → ad, paused)',
|
|
3154
|
-
description: 'Build a complete, ready-to-run Meta ad: campaign → ad set (FULL targeting + budget + schedule + bidding) → creative → ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it now carries Meta-rendered PREVIEW LINKS for the first ad, valid 24 hours — hand them to the user so they can see the ad; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts.',
|
|
3390
|
+
description: 'Build a complete, ready-to-run Meta ad: campaign → ad set (FULL targeting + budget + schedule + bidding) → creative → ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it now carries Meta-rendered PREVIEW LINKS for the first ad, valid 24 hours — hand them to the user so they can see the ad; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — an IG media id and a Facebook post id are both bare digits, so Hermoso will NOT guess which one you meant, and a Facebook boost given an IG media id is refused rather than built against a fabricated id. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
|
|
3155
3391
|
inputSchema: {
|
|
3156
|
-
boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink. Cannot be combined with image/video inputs, and creative fields do not apply — a boost shows the post as published.'),
|
|
3392
|
+
boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs, and creative fields do not apply — a boost shows the post as published.'), boostTarget: z.enum(['facebook','instagram']).optional().describe("Which surface the boosted post lives on. Default facebook. REQUIRED for an Instagram post: an IG media id and a Facebook post id are both bare digits, so this is never inferred — Meta takes a different creative for each (object_story_id for a Page post; object_id + instagram_user_id + source_instagram_media_id for an IG post). list_meta_posts(target:'instagram') returns the ids."),
|
|
3157
3393
|
adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
|
|
3158
3394
|
format: z.enum(['auto', 'carousel']).optional().describe('auto = one ad per asset (image or video); carousel = ONE multi-card ad'),
|
|
3159
3395
|
imageUrl: z.string().optional().describe('public https image URL for the ad creative'),
|
|
@@ -3360,6 +3596,32 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
3360
3596
|
// holding a role on it and 403 for everyone else until App Review grants Advanced Access. A connection made
|
|
3361
3597
|
// before those permissions were requested cannot gain them by retrying — the server's 403 says so and names
|
|
3362
3598
|
// the reconnect, so it is printed verbatim rather than summarised.
|
|
3599
|
+
server.registerTool('list_meta_pixels', {
|
|
3600
|
+
title: 'List Meta Pixels on an ad account',
|
|
3601
|
+
description: 'List the META PIXELS on one of the brand’s ad accounts — id, name, when it was created, and WHEN IT LAST FIRED. This is where the pixelId every conversion tool needs comes from: create_meta_ad takes it (with conversionEvent) to optimise an ad set for OFFSITE_CONVERSIONS instead of link clicks, and create_meta_audience needs it to build a website retargeting audience. Without this tool that id could only be read off a screen in Events Manager. READ lastFiredAt BEFORE YOU TRUST A PIXEL: one that has NEVER FIRED is not installed on the site, so an ad optimising against it will spend and never learn. Pass includeCode:true to get the <script> snippet for installation (it is long, so it is off by default). Read-only, free.',
|
|
3602
|
+
inputSchema: {
|
|
3603
|
+
adAccountId: z.string().describe('the ad account (from list_connector_accounts / list_meta_pages), with or without the act_ prefix'),
|
|
3604
|
+
includeCode: z.boolean().optional().describe('also return the full pixel <script> snippet — only ask when the user is about to install it'),
|
|
3605
|
+
},
|
|
3606
|
+
outputSchema: { adAccountId: z.string().optional(), count: z.number().optional(), totalCount: z.number().optional(), pixels: z.array(z.any()).optional(), note: z.string().optional() },
|
|
3607
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3608
|
+
}, wrap(async (a) => {
|
|
3609
|
+
const d = await apiGet('/api/meta/pixels', a);
|
|
3610
|
+
return ok(d.note, d);
|
|
3611
|
+
}));
|
|
3612
|
+
server.registerTool('create_meta_pixel', {
|
|
3613
|
+
title: 'Create a Meta Pixel',
|
|
3614
|
+
description: 'Create a META PIXEL on one of the brand’s ad accounts, so their ads can optimise for real website conversions instead of link clicks and so website retargeting audiences become possible. A pixel is a DEFINITION: it cannot serve, cannot spend, and RECORDS NOTHING until its snippet is installed on the site — the reply hands back that snippet and says so. TWO THINGS TO SAY OUT LOUD BEFORE CALLING IT: Meta allows exactly ONE pixel per ad account (a second attempt is refused and Hermoso will name the one that already exists — so call list_meta_pixels first), and Meta publishes NO WAY TO DELETE a pixel, so this is permanent. Creating it spends nothing.',
|
|
3615
|
+
inputSchema: {
|
|
3616
|
+
adAccountId: z.string().describe('the ad account (from list_connector_accounts / list_meta_pages), with or without the act_ prefix'),
|
|
3617
|
+
name: z.string().describe('what it will be called in Events Manager — name it after the website or brand it measures'),
|
|
3618
|
+
},
|
|
3619
|
+
outputSchema: { ok: z.boolean().optional(), pixelId: z.string().optional(), adAccountId: z.string().optional(), name: z.string().optional(), verified: z.boolean().optional(), pixel: z.any().optional(), note: z.string().optional() },
|
|
3620
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
3621
|
+
}, wrap(async (a) => {
|
|
3622
|
+
const d = await apiPost('/api/meta/pixel', a);
|
|
3623
|
+
return ok(d.note, d); // print the READ-BACK sentence verbatim — never narrate an object we did not read back
|
|
3624
|
+
}));
|
|
3363
3625
|
server.registerTool('list_meta_lead_forms', {
|
|
3364
3626
|
title: 'List Meta instant (lead) forms',
|
|
3365
3627
|
description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which is at Standard Access on the Hermoso app: it works for people who hold a role on the app and Meta refuses it for everyone else until App Review grants Advanced Access. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta — a connection made before that permission was requested cannot gain it by retrying.',
|
|
@@ -3488,6 +3750,48 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
3488
3750
|
const d = await apiGet('/api/merchant/products', { merchantCenterId: a.merchantCenterId, limit: a.limit });
|
|
3489
3751
|
return ok(`Merchant Center ${d.merchantCenterId}: ${d.count} product(s).\n${JSON.stringify(d.products || []).slice(0, 4000)}`, d);
|
|
3490
3752
|
}));
|
|
3753
|
+
// ── SHOPIFY: the merchant's own storefront (2026-08-19) ──────────────────────────────────────────────────────
|
|
3754
|
+
// These close the LAST web-only capability in the product. `publish_to_product` existed only inside the Shopify
|
|
3755
|
+
// admin iframe, because its route authenticated with a ~60-second App Bridge JWT no headless caller can mint —
|
|
3756
|
+
// so a merchant driving Hermoso from Claude or Cursor could generate an ad FOR their product and then had to go
|
|
3757
|
+
// click it into the listing by hand. Web-only is the one direction the parity law calls a defect.
|
|
3758
|
+
//
|
|
3759
|
+
// There is deliberately no `shop` parameter on either tool. The store is derived server-side from the verified
|
|
3760
|
+
// account (a Shopify merchant's Hermoso account IS `shopify:<shop>`); accepting one from the caller would be a
|
|
3761
|
+
// forgeable instruction to publish into somebody else's storefront.
|
|
3762
|
+
server.registerTool('list_shopify_products', {
|
|
3763
|
+
title: 'List the Shopify catalog',
|
|
3764
|
+
description: "The merchant's real Shopify products — id, title, description, price, images and storefront URL. This is where the productId for publish_to_shopify_product comes from, and it doubles as ground truth about what the brand actually sells (real titles and real photos, not a guess from the website). Newest-updated first. Only works for accounts created by installing Hermoso from the Shopify App Store. Read-only, free.",
|
|
3765
|
+
inputSchema: {
|
|
3766
|
+
limit: z.number().optional().describe('how many products (1–100, default 24)'),
|
|
3767
|
+
cursor: z.string().optional().describe('pageInfo.endCursor from a previous call, to page further'),
|
|
3768
|
+
},
|
|
3769
|
+
outputSchema: { shop: z.string().optional(), products: z.array(z.any()).optional(), pageInfo: z.any().optional() },
|
|
3770
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
3771
|
+
}, wrap(async (a) => {
|
|
3772
|
+
const d = await apiGet('/api/shopify/products', { limit: a.limit, cursor: a.cursor });
|
|
3773
|
+
const list = d.products || [];
|
|
3774
|
+
const lines = list.map((p) => `• ${p.title} — ${p.id}${p.price ? ` (${p.price.currency} ${p.price.min})` : ''}`).join('\n');
|
|
3775
|
+
return ok(`${d.shop}: ${list.length} product(s)${d.pageInfo?.hasNextPage ? ' (more available — pass cursor)' : ''}.\n${lines}`, d);
|
|
3776
|
+
}));
|
|
3777
|
+
|
|
3778
|
+
server.registerTool('publish_to_shopify_product', {
|
|
3779
|
+
title: 'Publish an image onto a Shopify product',
|
|
3780
|
+
description: "Attach a finished image to one of the merchant's Shopify product listings, as product media. Pass productId (from list_shopify_products — the gid://shopify/Product/… form) and a PUBLIC https imageUrl, which is what every Hermoso render returns. Shopify fetches the image server-side and processes it asynchronously, so the media can come back status PROCESSING and appear on the listing a moment later — that is success, not a failure. Only works for accounts created by installing Hermoso from the Shopify App Store. Free — the render was already paid for.",
|
|
3781
|
+
inputSchema: {
|
|
3782
|
+
productId: z.string().describe('gid://shopify/Product/… from list_shopify_products'),
|
|
3783
|
+
imageUrl: z.string().describe('a public https image URL — any Hermoso render URL works'),
|
|
3784
|
+
alt: z.string().optional().describe('alt text for accessibility and SEO; defaults to a generic credit'),
|
|
3785
|
+
},
|
|
3786
|
+
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), productId: z.string().optional(), productUrl: z.string().optional(), media: z.any().optional() },
|
|
3787
|
+
}, wrap(async (a) => {
|
|
3788
|
+
const d = await apiPost('/api/shopify/publish-to-product', { productId: a.productId, imageUrl: a.imageUrl, alt: a.alt });
|
|
3789
|
+
const st = d.media?.status || 'UNKNOWN';
|
|
3790
|
+
return ok(`Published to ${d.productId} on ${d.shop} — media ${d.media?.id || '?'} is ${st}`
|
|
3791
|
+
+ (st === 'PROCESSING' ? ' (Shopify is still processing it; it will appear on the listing shortly).' : '.')
|
|
3792
|
+
+ (d.productUrl ? `\nListing: ${d.productUrl}` : ''), d);
|
|
3793
|
+
}));
|
|
3794
|
+
|
|
3491
3795
|
server.registerTool('list_merchant_issues', {
|
|
3492
3796
|
description: "Read the account-level issues Google reports on a Merchant Center — the answer to \"why is this product not showing?\", which Google Ads reporting CANNOT give you, because a disapproved product has no impressions to report on. Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
|
|
3493
3797
|
inputSchema: { merchantCenterId: z.string().describe('from list_merchant_accounts') },
|
|
@@ -4210,6 +4514,351 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4210
4514
|
if (d.needsConfirm) return ok(d.message || 'Confirmation required.', d);
|
|
4211
4515
|
return ok(d.note || `Removed key event on property ${d.property}.`, d);
|
|
4212
4516
|
}));
|
|
4517
|
+
// ANALYTICS DEPTH (2026-08-18) — CUSTOM METRICS were a pure asymmetry defect: we shipped create + list + archive
|
|
4518
|
+
// for custom DIMENSIONS and nothing for METRICS, though they are the same resource, on the same host,
|
|
4519
|
+
// under the same `analytics.edit` scope we already hold. And checkCompatibility is the free pre-flight
|
|
4520
|
+
// for `analytics_report`, which deliberately forwards names as given and therefore cannot warn about a
|
|
4521
|
+
// pair GA4 refuses to combine.
|
|
4522
|
+
server.registerTool('create_analytics_custom_metric', {
|
|
4523
|
+
title: 'Register a GA4 custom metric',
|
|
4524
|
+
description: "Register an event parameter the site ALREADY SENDS as a GA4 custom METRIC, so reports can SUM or AVERAGE it. This is the other half of the pair: a custom DIMENSION makes a value something you can group BY, a custom METRIC makes it something you can TOTAL — 'order_value' as a dimension gives you a list of prices, as a metric it gives you revenue. TELL THE USER TWO THINGS BEFORE CALLING: a GA4 custom metric CANNOT be deleted, only archived, and a property is capped at 50 (125 on Analytics 360) — a duplicate or a typo permanently burns a slot, so run list_analytics_definitions first, which lists metrics beside dimensions. measurementUnit IS REQUIRED and is never guessed for you: STANDARD for a plain count or quantity, CURRENCY for money, SECONDS/MINUTES/HOURS for durations. CURRENCY additionally REQUIRES restrictedMetricType (REVENUE_DATA for money coming in, COST_DATA for money going out) and every other unit REFUSES it — both directions are refused before the call is made, because Google's own error names neither field. METRICS ARE EVENT-SCOPED ONLY, unlike dimensions, which take EVENT | USER | ITEM. Not retroactive: it collects from creation onward. The result is READ BACK from Google, never echoed from the request. Needs edit access on the property. 0 credits.",
|
|
4525
|
+
inputSchema: {
|
|
4526
|
+
property: z.string().describe('NUMERIC GA4 property id from list_analytics_properties — never the G-XXXXXXX Measurement ID'),
|
|
4527
|
+
parameterName: z.string().describe("the event parameter GA4 already collects, e.g. 'order_value' — NOT the report label. Letters, digits and underscores, starting with a letter, max 40 characters"),
|
|
4528
|
+
displayName: z.string().optional().describe('the label shown in reports; defaults to parameterName, max 82 characters'),
|
|
4529
|
+
measurementUnit: z.enum(['STANDARD', 'CURRENCY', 'FEET', 'METERS', 'KILOMETERS', 'MILES', 'MILLISECONDS', 'SECONDS', 'MINUTES', 'HOURS']).describe('REQUIRED — Google marks it Required and there is no safe default'),
|
|
4530
|
+
restrictedMetricType: z.array(z.enum(['COST_DATA', 'REVENUE_DATA'])).optional().describe('REQUIRED when measurementUnit is CURRENCY, and refused for every other unit'),
|
|
4531
|
+
scope: z.enum(['EVENT']).optional().describe('EVENT is the only scope GA4 accepts for a metric, and it is immutable'),
|
|
4532
|
+
description: z.string().optional().describe('≤150 characters'),
|
|
4533
|
+
},
|
|
4534
|
+
outputSchema: { property: z.string().optional(), customMetric: z.any().optional(), note: z.string().optional() },
|
|
4535
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
4536
|
+
}, wrap(async (a) => {
|
|
4537
|
+
const d = await apiPost('/api/analytics/custom-metric', a);
|
|
4538
|
+
const m = d.customMetric || {};
|
|
4539
|
+
return ok(`Property ${d.property} now has custom METRIC "${m.displayName}" on parameter "${m.parameterName}" (${m.measurementUnit}${(m.restrictedMetricType || []).length ? `, ${m.restrictedMetricType.join('/')}` : ''}, ${m.scope} scope). ${d.note || ''}`.trim(), d);
|
|
4540
|
+
}));
|
|
4541
|
+
server.registerTool('archive_analytics_custom_metric', {
|
|
4542
|
+
title: 'Archive a GA4 custom metric (one-way)',
|
|
4543
|
+
description: "THE ONLY WAY TO RETIRE A GA4 CUSTOM METRIC, and it is ONE-WAY: GA4 publishes no delete and no un-archive for custom metrics anywhere in its API, exactly as for dimensions, so archiving is permanent through every programmatic surface. It frees the slot against the 50-per-property cap, and reports lose the ability to sum or average it. WITHOUT `confirm` IT ARCHIVES NOTHING and instead describes the metric read back from Google — check that against what the user asked for before confirming, because naming the RIGHT metric is the one thing confirm cannot prove. Identify it by parameterName (from list_analytics_definitions), not the report label. A custom DIMENSION of the same name is a DIFFERENT resource and needs archive_analytics_custom_dimension. Needs edit access. 0 credits.",
|
|
4544
|
+
inputSchema: {
|
|
4545
|
+
property: z.string().describe('NUMERIC GA4 property id'),
|
|
4546
|
+
parameterName: z.string().describe("the event parameter of the metric to archive, e.g. 'order_value'"),
|
|
4547
|
+
confirm: z.boolean().optional().describe('must be true to actually archive — without it nothing is archived and the metric is described back to you'),
|
|
4548
|
+
},
|
|
4549
|
+
outputSchema: { property: z.string().optional(), archived: z.boolean().optional(), verified: z.boolean().nullable().optional(), needsConfirm: z.boolean().optional(), customMetric: z.any().optional(), message: z.string().optional(), note: z.string().optional() },
|
|
4550
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
4551
|
+
}, wrap(async (a) => {
|
|
4552
|
+
const d = await apiPost('/api/analytics/custom-metric/archive', a);
|
|
4553
|
+
if (d.needsConfirm) return ok(d.message || 'Confirmation required.', d);
|
|
4554
|
+
return ok(d.note || `Archived the custom metric on property ${d.property}.`, d);
|
|
4555
|
+
}));
|
|
4556
|
+
server.registerTool('check_analytics_compatibility', {
|
|
4557
|
+
title: 'Check a GA4 report will work, before running it',
|
|
4558
|
+
description: "FREE PRE-FLIGHT FOR analytics_report — ask Google whether these dimensions and metrics can appear in ONE report together, BEFORE running the report that would fail. Use it whenever a report combines fields you have not combined before, and especially when one of them is a custom dimension: GA4 has real pairs that are each perfectly valid alone and cannot be queried together, and `analytics_report` deliberately forwards names AS GIVEN (GA4 publishes hundreds of dimensions and every property ships its own custom ones, so validating against a copied list would go stale and start REFUSING names Google accepts). Pass EXACTLY what you would pass to analytics_report — same metrics, same dimensions, same filters — and it returns which fields are INCOMPATIBLE so you can drop one and run the real report once. IT DOES NOT CHECK THAT A NAME EXISTS: an unknown api name is refused by Google outright, and list_analytics_metadata is what resolves names. Read-only, 0 credits.",
|
|
4559
|
+
inputSchema: {
|
|
4560
|
+
property: z.string().describe('NUMERIC GA4 property id'),
|
|
4561
|
+
metrics: z.array(z.string()).optional().describe('the same metrics you would send to analytics_report (defaults to the same three it defaults to)'),
|
|
4562
|
+
dimensions: z.array(z.string()).optional().describe('the same dimensions you would send to analytics_report'),
|
|
4563
|
+
dimensionFilter: z.record(z.any()).optional().describe('the same GA4 FilterExpression you would send to analytics_report'),
|
|
4564
|
+
metricFilter: z.record(z.any()).optional().describe('the same GA4 FilterExpression you would send to analytics_report'),
|
|
4565
|
+
compatibilityFilter: z.enum(['COMPATIBILITY_UNSPECIFIED', 'COMPATIBLE', 'INCOMPATIBLE']).optional().describe('narrow the answer — COMPATIBLE returns only the fields that CAN be added'),
|
|
4566
|
+
},
|
|
4567
|
+
outputSchema: { property: z.string().optional(), compatible: z.boolean().optional(), checked: z.number().optional(), incompatible: z.array(z.any()).optional(), dimensions: z.array(z.any()).optional(), metrics: z.array(z.any()).optional(), scope: z.string().optional(), warning: z.string().optional(), note: z.string().optional() },
|
|
4568
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4569
|
+
}, wrap(async (a) => {
|
|
4570
|
+
const d = await apiPost('/api/analytics/compatibility', a);
|
|
4571
|
+
const row = (x) => `• ${x.kind} ${x.apiName}${x.uiName && x.uiName !== x.apiName ? ` (${x.uiName})` : ''} — ${x.compatibility}`;
|
|
4572
|
+
const head = d.compatible ? `COMPATIBLE — all ${d.checked} field(s) can appear in one report on property ${d.property}.`
|
|
4573
|
+
: `NOT COMPATIBLE on property ${d.property}: ${(d.incompatible || []).length} field(s) cannot be combined with the rest.`;
|
|
4574
|
+
return ok([head, ...(d.incompatible || []).map(row), d.warning ? `⚠ ${d.warning}` : '', d.note || ''].filter(Boolean).join('\n'), d);
|
|
4575
|
+
}));
|
|
4576
|
+
// ---------- SEARCH-ENGINE WEBMASTER SURFACES: Search Console · Bing Webmaster · IndexNow (2026-08-18) ----------
|
|
4577
|
+
// NOT a second analytics dashboard, and reading them as one is how the useful half gets ignored. GA4
|
|
4578
|
+
// answers what happened AFTER the click; Search Console answers what happened BEFORE it, and its
|
|
4579
|
+
// `query` dimension is the literal words this brand's real customers typed with measured impressions,
|
|
4580
|
+
// clicks, CTR and position. That is the only first-party source of demand language in the product —
|
|
4581
|
+
// competitor ad libraries are secondhand and keyword planners are modelled — so these belong beside
|
|
4582
|
+
// mine_angles and plan_ad, not in a reporting tab.
|
|
4583
|
+
//
|
|
4584
|
+
// A SEARCH CONSOLE PROPERTY IS NOT A DOMAIN. It is EITHER "sc-domain:example.com" (a Domain property)
|
|
4585
|
+
// OR a URL-prefix property written in full with its scheme and trailing slash ("https://example.com/"),
|
|
4586
|
+
// those are DIFFERENT properties in Google's eyes, and a bare domain is refused rather than guessed at
|
|
4587
|
+
// — so every one of these says so and list_search_console_sites exists to resolve it.
|
|
4588
|
+
server.registerTool('list_search_console_sites', {
|
|
4589
|
+
title: 'List the Search Console properties shared with this brand',
|
|
4590
|
+
description: 'The Google Search Console properties SHARED WITH THIS BRAND, each with the exact property string every other Search Console tool takes and the connected account\'s permission level on it. CALL THIS FIRST. A property is EITHER "sc-domain:example.com" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form "https://example.com/" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen clients\' properties, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_search_console"); never name or guess a property. A row whose permissionLevel is siteUnverifiedUser will refuse every later call. Read-only, 0 credits. Needs Google Search Console connected.',
|
|
4591
|
+
inputSchema: {},
|
|
4592
|
+
outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), permissionLevel: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
4593
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4594
|
+
}, wrap(async () => {
|
|
4595
|
+
const d = await apiGet('/api/search-console/sites', {});
|
|
4596
|
+
const rows = (d.sites || []).map(s => `• ${s.siteUrl}${s.permissionLevel ? ` — ${s.permissionLevel}` : ''}`);
|
|
4597
|
+
if (rows.length) return ok(`${rows.length} Search Console propert(ies) shared with this brand:\n${rows.join('\n')}`, d);
|
|
4598
|
+
// "nothing ticked" and "the ticked ones vanished" are different problems with different fixes.
|
|
4599
|
+
return ok(d.shared
|
|
4600
|
+
? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this brand ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (verification removed under Settings ▸ Users and permissions, or the property deleted). Ask the user to re-pick under Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts.`
|
|
4601
|
+
: 'No Search Console property is shared with this brand yet, so there is nothing to read. Ask the user to pick which properties belong to this brand under Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts (or call set_connector_accounts with provider "google_search_console"). Do not name or guess one.', d);
|
|
4602
|
+
}));
|
|
4603
|
+
server.registerTool('search_console_performance', {
|
|
4604
|
+
title: 'What people searched for on Google, and what they clicked',
|
|
4605
|
+
description: 'THE REAL SEARCH TERMS — Google Search Console\'s performance report, and the most useful research tool here for writing copy. `dimensions:["query"]` returns the literal words people typed to reach this brand, with impressions, clicks, CTR and average position; `dimensions:["page","query"]` answers "which landing page already wins this demand", i.e. where paid traffic should point. Every other dimension is available too: country, device, searchAppearance, date, hour. USE IT BEFORE mine_angles OR plan_ad — this is first-party MEASURED demand language, not a competitor\'s guess (an ad library) and not a model (a keyword planner), and it exists nowhere else: organic search terms left Google Analytics with "not provided" years ago. startDate and endDate are REQUIRED and are exact YYYY-MM-DD in Pacific time — Google Analytics\' relative forms like "28daysAgo" are NOT accepted anywhere in Search Console. THREE THINGS TO REPEAT WHENEVER YOU REPORT THESE NUMBERS: (1) rows are the TOP ones, not all of them, by Google\'s own statement; (2) anonymised queries are WITHHELD, so clicks summed per query will not equal the property total and that gap is not an error; (3) the default dataState "final" lags roughly 2-3 days, and "all" returns fresh partial data that will still change. Search Console keeps 16 months of history. Read-only, 0 credits.',
|
|
4606
|
+
inputSchema: {
|
|
4607
|
+
siteUrl: z.string().describe('the exact property string from list_search_console_sites — "sc-domain:example.com" or "https://example.com/", never a bare domain, and it must be one SHARED with this brand'),
|
|
4608
|
+
startDate: z.string().describe('YYYY-MM-DD, required, Pacific time, inclusive'),
|
|
4609
|
+
endDate: z.string().describe('YYYY-MM-DD, required, Pacific time, inclusive'),
|
|
4610
|
+
dimensions: z.array(z.string()).optional().describe('group by any of query, page, country, device, searchAppearance, date, hour. Omit for one total row. An unknown name is refused by name rather than dropped.'),
|
|
4611
|
+
type: z.string().optional().describe('web (default), image, video, news, discover or googleNews'),
|
|
4612
|
+
dimensionFilterGroups: z.array(z.record(z.any())).optional().describe('narrow the report, e.g. [{"filters":[{"dimension":"query","operator":"contains","expression":"buy"}]}]. dimension is one of query/page/country/device/searchAppearance — date and hour are NOT filterable, they are the date range. operator is equals/notEquals/contains/notContains/includingRegex/excludingRegex. An empty expression is refused: Google matches it literally, so it would silently return nothing.'),
|
|
4613
|
+
aggregationType: z.string().optional().describe('auto (default), byPage, byProperty or byNewsShowcasePanel'),
|
|
4614
|
+
dataState: z.string().optional().describe('final (default, lags 2-3 days), all (includes fresh partial data that WILL change), or hourlyAll'),
|
|
4615
|
+
rowLimit: z.number().optional().describe('1-25000, default 1000'),
|
|
4616
|
+
startRow: z.number().optional().describe('zero-based, for paging past rowLimit'),
|
|
4617
|
+
},
|
|
4618
|
+
outputSchema: { siteUrl: z.string().optional(), rows: z.array(z.any()).optional(), rowCount: z.number().optional(), dimensions: z.array(z.string()).optional(), rowTotals: z.record(z.any()).optional(), filtered: z.boolean().optional(), dataState: z.string().optional(), caveats: z.string().optional(), firstIncompleteDate: z.string().optional() },
|
|
4619
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4620
|
+
}, wrap(async (a) => {
|
|
4621
|
+
const d = await apiPost('/api/search-console/performance', a);
|
|
4622
|
+
// SAY WHETHER IT WAS FILTERED. A narrow answer and a small property look identical in a row list, so a reader
|
|
4623
|
+
// who did not send the filter cannot otherwise tell which one they are looking at.
|
|
4624
|
+
const head = `${d.rowCount || 0} row(s) for ${d.siteUrl}, ${d.startDate} to ${d.endDate}${d.filtered ? ' (FILTERED — this is a SUBSET of the property, not its total)' : ''}${d.dataState === 'ALL' ? ' (dataState ALL — fresh partial data that will still change)' : ''}.`;
|
|
4625
|
+
// The caveats ride the TEXT block, not only structuredContent: plenty of MCP clients print the text and
|
|
4626
|
+
// nothing else, and a summed-clicks total reported as the property total is documented to be wrong.
|
|
4627
|
+
return ok(`${head}\n${rowLines(d.rows)}\n\n${d.caveats || ''}`, d);
|
|
4628
|
+
}));
|
|
4629
|
+
server.registerTool('search_console_url_inspection', {
|
|
4630
|
+
title: 'Is this page actually in Google?',
|
|
4631
|
+
description: 'THE LIVE INDEX STATUS OF ONE PAGE, straight from Google: the index verdict, coverage state, whether robots.txt allows it, which URL Google picked as canonical versus the one the page itself declares, when it was last crawled and as which crawler, plus rich-result and mobile-usability verdicts. This is the tool for "why is this page getting no traffic" — a page Google cannot crawl, or whose canonical Google has reassigned to a different URL, will never rank however good the copy is. THE URL MUST SIT INSIDE THE PROPERTY: a URL-prefix property covers only its own prefix, while an "sc-domain:" property covers every scheme and subdomain. 🚨 QUOTA — Google allows 2,000 inspections per DAY and 600 per minute PER SITE, and that is the CUSTOMER\'S property quota, not ours: inspect the handful of URLs the question is actually about and NEVER loop this over a sitemap, a page list or a crawl. Read-only, 0 credits.',
|
|
4632
|
+
inputSchema: {
|
|
4633
|
+
siteUrl: z.string().describe('the exact property string from list_search_console_sites, and it must be one SHARED with this brand'),
|
|
4634
|
+
inspectionUrl: z.string().describe('the full URL to inspect, e.g. "https://example.com/pricing" — it must be under the property'),
|
|
4635
|
+
languageCode: z.string().optional().describe('BCP-47 code for translated issue messages, e.g. "de-CH". Default en-US.'),
|
|
4636
|
+
},
|
|
4637
|
+
outputSchema: { siteUrl: z.string().optional(), inspectionUrl: z.string().optional(), verdict: z.string().optional(), coverageState: z.string().optional(), robotsTxtState: z.string().optional(), indexingState: z.string().optional(), lastCrawlTime: z.string().optional(), googleCanonical: z.string().optional(), userCanonical: z.string().optional(), sitemap: z.array(z.string()).optional(), richResults: z.record(z.any()).optional(), mobileUsability: z.record(z.any()).optional(), inspectionResultLink: z.string().optional(), quota: z.string().optional() },
|
|
4638
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4639
|
+
}, wrap(async (a) => {
|
|
4640
|
+
const d = await apiPost('/api/search-console/inspect', a);
|
|
4641
|
+
const bits = [`verdict ${d.verdict || 'unknown'}`, d.coverageState, d.robotsTxtState ? `robots ${d.robotsTxtState}` : '', d.indexingState, d.lastCrawlTime ? `last crawled ${d.lastCrawlTime}` : ''].filter(Boolean);
|
|
4642
|
+
// A CANONICAL MISMATCH IS THE ANSWER TO THE QUESTION THAT WAS ASKED, so it is called out rather than left as
|
|
4643
|
+
// two similar-looking fields a reader has to compare by eye.
|
|
4644
|
+
const canon = (d.googleCanonical && d.userCanonical && d.googleCanonical !== d.userCanonical)
|
|
4645
|
+
? `\n⚠ Google's chosen canonical (${d.googleCanonical}) DIFFERS from the one this page declares (${d.userCanonical}) — that alone can be why the URL does not rank on its own.` : '';
|
|
4646
|
+
return ok(`${d.inspectionUrl} on ${d.siteUrl}: ${bits.join(' · ')}.${canon}${d.richResults ? `\nRich results: ${d.richResults.verdict}` : ''}${d.mobileUsability ? `\nMobile usability: ${d.mobileUsability.verdict}` : ''}${d.inspectionResultLink ? `\n${d.inspectionResultLink}` : ''}\n\n${d.quota || ''}`, d);
|
|
4647
|
+
}));
|
|
4648
|
+
server.registerTool('list_search_console_sitemaps', {
|
|
4649
|
+
title: 'List a property’s sitemaps, with submitted-vs-indexed counts',
|
|
4650
|
+
description: 'The sitemaps registered on a Search Console property: when each was last submitted and last downloaded by Google, whether Google has processed it yet, its error and warning counts, and per content type how many URLs it SUBMITTED against how many are INDEXED. That last pair is the fastest read on whether a site has an indexing problem rather than a ranking one. Pass `feedpath` for one sitemap instead of the list, or `sitemapIndex` to list the sitemaps inside an index file. An empty list is a real answer — the property has no sitemap registered — and is not a failure. Read-only, 0 credits.',
|
|
4651
|
+
inputSchema: {
|
|
4652
|
+
siteUrl: z.string().describe('the exact property string from list_search_console_sites'),
|
|
4653
|
+
feedpath: z.string().optional().describe('the FULL url of one sitemap, e.g. "https://example.com/sitemap.xml"'),
|
|
4654
|
+
sitemapIndex: z.string().optional().describe('the FULL url of a sitemap INDEX, to list the sitemaps it contains'),
|
|
4655
|
+
},
|
|
4656
|
+
outputSchema: { siteUrl: z.string().optional(), sitemaps: z.array(z.any()).optional(), count: z.number().optional() },
|
|
4657
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4658
|
+
}, wrap(async (a) => {
|
|
4659
|
+
const d = await apiGet('/api/search-console/sitemaps', { siteUrl: a.siteUrl, feedpath: a.feedpath, sitemapIndex: a.sitemapIndex });
|
|
4660
|
+
const rows = (d.sitemaps || []).map(s => {
|
|
4661
|
+
const c = (s.contents || []).map(x => `${x.indexed}/${x.submitted} ${String(x.type || 'url').toLowerCase()} indexed`).join(', ');
|
|
4662
|
+
return `• ${s.path}${s.isPending ? ' (NOT YET PROCESSED)' : ''} — submitted ${s.lastSubmitted || '?'}, ${s.errors} error(s), ${s.warnings} warning(s)${c ? `, ${c}` : ''}`;
|
|
4663
|
+
});
|
|
4664
|
+
return ok(rows.length ? `${rows.length} sitemap(s) on ${d.siteUrl}:\n${rows.join('\n')}` : `No sitemaps are registered on ${d.siteUrl}. That is a real answer rather than a failure — submit one with submit_search_console_sitemap.`, d);
|
|
4665
|
+
}));
|
|
4666
|
+
server.registerTool('submit_search_console_sitemap', {
|
|
4667
|
+
title: 'Submit a sitemap to Google',
|
|
4668
|
+
description: 'Tell Google about a sitemap. `feedpath` is the sitemap\'s COMPLETE URL ("https://example.com/sitemap.xml"), never a path — Google addresses a sitemap by its full url. Re-submitting one that is already registered is harmless and simply re-notifies Google. THE ANSWER IS READ BACK FROM GOOGLE, not echoed from the request: it reports whether the sitemap is now actually registered, whether Google has processed it yet, and what errors and warnings it already carries. 0 credits.',
|
|
4669
|
+
inputSchema: {
|
|
4670
|
+
siteUrl: z.string().describe('the exact property string from list_search_console_sites'),
|
|
4671
|
+
feedpath: z.string().describe('the FULL sitemap URL, e.g. "https://example.com/sitemap.xml"'),
|
|
4672
|
+
},
|
|
4673
|
+
outputSchema: { siteUrl: z.string().optional(), feedpath: z.string().optional(), submitted: z.boolean().optional(), sitemap: z.record(z.any()).optional(), note: z.string().optional() },
|
|
4674
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
4675
|
+
}, wrap(async (a) => {
|
|
4676
|
+
const d = await apiPost('/api/search-console/sitemap', a);
|
|
4677
|
+
return ok(`${d.feedpath} → ${d.siteUrl}. ${d.note || ''}`, d);
|
|
4678
|
+
}));
|
|
4679
|
+
server.registerTool('delete_search_console_sitemap', {
|
|
4680
|
+
title: 'Remove a sitemap from a Search Console property',
|
|
4681
|
+
description: 'Remove a sitemap from a property. THIS DOES NOT REMOVE THOSE PAGES FROM GOOGLE\'S INDEX — it stops Google using that file to discover new ones, and it can be submitted again at any time, so it is reversible. CONFIRM-GATED: called without confirm it removes nothing and reports what the sitemap actually IS, read back from Google (its error and warning counts and how many URLs it covers), so you can check you are aiming at the right one. Re-run with confirm:true to proceed. 0 credits.',
|
|
4682
|
+
inputSchema: {
|
|
4683
|
+
siteUrl: z.string().describe('the exact property string from list_search_console_sites'),
|
|
4684
|
+
feedpath: z.string().describe('the FULL sitemap URL to remove'),
|
|
4685
|
+
confirm: z.boolean().optional().describe('true to actually remove it. Without this nothing is removed and the sitemap is described back to you.'),
|
|
4686
|
+
},
|
|
4687
|
+
outputSchema: { siteUrl: z.string().optional(), feedpath: z.string().optional(), deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), verified: z.boolean().optional(), sitemap: z.record(z.any()).optional(), message: z.string().optional(), note: z.string().optional() },
|
|
4688
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
4689
|
+
}, wrap(async (a) => {
|
|
4690
|
+
const d = await apiPost('/api/search-console/sitemap/delete', a);
|
|
4691
|
+
if (d.needsConfirm) return ok(d.message || 'Confirmation required before removing this sitemap.', d);
|
|
4692
|
+
return ok(d.note || `Removed ${d.feedpath} from ${d.siteUrl}.`, d);
|
|
4693
|
+
}));
|
|
4694
|
+
server.registerTool('add_search_console_site', {
|
|
4695
|
+
title: 'Add a property to Search Console',
|
|
4696
|
+
description: 'Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this brand, so someone has to tick it under Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts before any tool here can use it. The permission level is READ BACK from Google, so the answer says which of those two states it is actually in. 0 credits.',
|
|
4697
|
+
inputSchema: {
|
|
4698
|
+
siteUrl: z.string().describe('"sc-domain:example.com" for a Domain property (covers every scheme and subdomain), or the full URL-prefix form "https://example.com/". These are different properties — pick deliberately.'),
|
|
4699
|
+
},
|
|
4700
|
+
outputSchema: { siteUrl: z.string().optional(), added: z.boolean().optional(), permissionLevel: z.string().optional(), shared: z.boolean().optional(), note: z.string().optional() },
|
|
4701
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
4702
|
+
}, wrap(async (a) => {
|
|
4703
|
+
const d = await apiPost('/api/search-console/site', a);
|
|
4704
|
+
return ok(d.note || `${d.siteUrl} added.`, d);
|
|
4705
|
+
}));
|
|
4706
|
+
server.registerTool('delete_search_console_site', {
|
|
4707
|
+
title: 'Remove a property from Search Console',
|
|
4708
|
+
description: 'Remove a property from the connected Google account. THIS LOSES HISTORY: the account loses access to that property\'s stored Search Console performance data, and re-adding it later means verifying ownership from scratch — Google does NOT restore the 16-month window on re-add. Nothing about the site itself, its pages or its ranking changes. CONFIRM-GATED: the unconfirmed call removes nothing and describes what would be lost, including how many sitemaps this account can currently see on it. The property is also dropped from this brand\'s shared list, so it cannot linger as a tick pointing at something unreachable. 0 credits.',
|
|
4709
|
+
inputSchema: {
|
|
4710
|
+
siteUrl: z.string().describe('the exact property string from list_search_console_sites'),
|
|
4711
|
+
confirm: z.boolean().optional().describe('true to actually remove it'),
|
|
4712
|
+
},
|
|
4713
|
+
outputSchema: { siteUrl: z.string().optional(), deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), verified: z.boolean().optional(), untickedFromBrand: z.boolean().optional(), message: z.string().optional(), note: z.string().optional() },
|
|
4714
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
4715
|
+
}, wrap(async (a) => {
|
|
4716
|
+
const d = await apiPost('/api/search-console/site/delete', a);
|
|
4717
|
+
if (d.needsConfirm) return ok(d.message || 'Confirmation required before removing this property.', d);
|
|
4718
|
+
return ok(d.note || `Removed ${d.siteUrl}.`, d);
|
|
4719
|
+
}));
|
|
4720
|
+
// ---------- BING WEBMASTER TOOLS ----------
|
|
4721
|
+
// A SEPARATE connection from Microsoft Advertising and from everything Google. It is the cheapest
|
|
4722
|
+
// credential in the product — the user generates a key in Bing Webmaster Tools ▸ Settings ▸ API Access
|
|
4723
|
+
// and pastes it; there is no app registration, no consent screen and no review. Microsoft issues ONE
|
|
4724
|
+
// key PER USER covering every site that Bing account verified, which is exactly why which of those
|
|
4725
|
+
// sites belong to THIS brand is a tick list.
|
|
4726
|
+
// WHY IT IS WORTH READING BESIDE GOOGLE: Bing's index is what feeds Copilot and ChatGPT web search,
|
|
4727
|
+
// so it is the AEO surface — and its keyword methods give real impression volumes with NO ad spend,
|
|
4728
|
+
// which is the one thing here that has no free Google equivalent.
|
|
4729
|
+
server.registerTool('list_bing_webmaster_sites', {
|
|
4730
|
+
title: 'List the Bing Webmaster sites shared with this brand',
|
|
4731
|
+
description: 'The Bing Webmaster Tools sites SHARED WITH THIS BRAND, each exactly as Bing holds it (e.g. "https://example.com"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — point the user at Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call set_connector_accounts with provider "bing_webmaster") and never name or guess a site. A site flagged not verified will refuse every later call. Read-only, 0 credits.',
|
|
4732
|
+
inputSchema: {},
|
|
4733
|
+
outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), isVerified: z.boolean().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
4734
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4735
|
+
}, wrap(async () => {
|
|
4736
|
+
const d = await apiGet('/api/bing-webmaster/sites', {});
|
|
4737
|
+
const rows = (d.sites || []).map(s => `• ${s.siteUrl}${s.isVerified ? '' : ' (NOT VERIFIED in Bing — every call on it will be refused)'}`);
|
|
4738
|
+
if (rows.length) return ok(`${rows.length} Bing Webmaster site(s) shared with this brand:\n${rows.join('\n')}`, d);
|
|
4739
|
+
return ok(d.shared
|
|
4740
|
+
? `The ${d.shared} site(s) shared with this brand are no longer visible to the connected Bing account. Ask the user to re-pick under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts.`
|
|
4741
|
+
: 'No Bing Webmaster site is shared with this brand yet. Ask the user to pick which of their verified sites belong to this brand under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts. Do not name or guess one.', d);
|
|
4742
|
+
}));
|
|
4743
|
+
server.registerTool('bing_webmaster_traffic', {
|
|
4744
|
+
title: 'Bing clicks and impressions',
|
|
4745
|
+
description: 'Bing clicks and impressions over time — for the whole site (mode "site", the default), for one page (mode "page", needs `url`), or for every page under one directory (mode "directory", needs `url`). Bing is a smaller share of search than Google, but its index is what Copilot and ChatGPT web search read from, so this is the answer-engine visibility read rather than a second traffic report. An unknown mode is refused by name. Read-only, 0 credits.',
|
|
4746
|
+
inputSchema: {
|
|
4747
|
+
siteUrl: z.string().describe('the exact site string from list_bing_webmaster_sites, and it must be one SHARED with this brand'),
|
|
4748
|
+
mode: z.string().optional().describe('site (default) | page | directory'),
|
|
4749
|
+
url: z.string().optional().describe('required for page and directory modes — the full URL'),
|
|
4750
|
+
page: z.number().optional().describe('zero-based results page, directory mode'),
|
|
4751
|
+
},
|
|
4752
|
+
outputSchema: { siteUrl: z.string().optional(), mode: z.string().optional(), method: z.string().optional(), rows: z.array(z.any()).optional(), count: z.number().optional() },
|
|
4753
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4754
|
+
}, wrap(async (a) => {
|
|
4755
|
+
const d = await apiGet('/api/bing-webmaster/traffic', a);
|
|
4756
|
+
return ok(`${d.count || 0} row(s) from Bing (${d.method}) for ${d.siteUrl}.\n${rowLines(d.rows)}`, d);
|
|
4757
|
+
}));
|
|
4758
|
+
server.registerTool('bing_webmaster_query_stats', {
|
|
4759
|
+
title: 'What people searched on Bing, and which pages they landed on',
|
|
4760
|
+
description: 'The Bing twin of search_console_performance, split by what you want pinned. Modes: "queries" (top search terms for the site, the default), "pages" (top pages), "pageQueries" (which queries brought traffic to one `page`), "queryPages" (which pages one `query` landed on), "queryPageDetail" (one query on one page, day by day). Worth reading BESIDE Search Console rather than instead of it — the two engines\' demand language genuinely differs, and Bing is the one feeding Copilot and ChatGPT. MICROSOFT UPDATES THIS DATA WEEKLY by their own statement, so it lags Search Console and will not move day to day; do not report a flat week as a drop. An unknown mode is refused by name. Read-only, 0 credits.',
|
|
4761
|
+
inputSchema: {
|
|
4762
|
+
siteUrl: z.string().describe('the exact site string from list_bing_webmaster_sites'),
|
|
4763
|
+
mode: z.string().optional().describe('queries (default) | pages | pageQueries | queryPages | queryPageDetail'),
|
|
4764
|
+
query: z.string().optional().describe('required for queryPages and queryPageDetail'),
|
|
4765
|
+
page: z.string().optional().describe('the full page URL — required for pageQueries and queryPageDetail'),
|
|
4766
|
+
},
|
|
4767
|
+
outputSchema: { siteUrl: z.string().optional(), mode: z.string().optional(), method: z.string().optional(), rows: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
4768
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4769
|
+
}, wrap(async (a) => {
|
|
4770
|
+
const d = await apiGet('/api/bing-webmaster/query-stats', a);
|
|
4771
|
+
return ok(`${d.count || 0} row(s) from Bing (${d.method}) for ${d.siteUrl}.\n${rowLines(d.rows)}\n\n${d.note || ''}`, d);
|
|
4772
|
+
}));
|
|
4773
|
+
server.registerTool('bing_webmaster_keywords', {
|
|
4774
|
+
title: 'Keyword research with real impression volumes, no ad spend',
|
|
4775
|
+
description: 'REAL SEARCH DEMAND FOR ANY TERM, free. Modes: "keyword" (impressions for exactly this term over a date range), "related" (the same for terms Bing considers related — the one to use for expanding a seed into a list), "history" (the term\'s historical series). This is the closest thing in Hermoso to a free keyword planner: google_ads_keyword_ideas needs Google Ads Basic access, and this needs only the Bing connection anyone can set up in two minutes. NOT SITE-SCOPED — these are Microsoft\'s signatures and they take no site, so it works for ANY term including a competitor\'s brand, and no property tick list applies. `country` AND `language` are BOTH REQUIRED and are deliberately never defaulted: a search volume with no market is unactionable, and picking one for you would answer confidently about the wrong country. Read-only, 0 credits.',
|
|
4776
|
+
inputSchema: {
|
|
4777
|
+
q: z.string().describe('the keyword to look up, e.g. "running shoes"'),
|
|
4778
|
+
mode: z.string().optional().describe('keyword (default) | related | history'),
|
|
4779
|
+
country: z.string().describe('REQUIRED, e.g. "us" — there is no default and one will not be guessed'),
|
|
4780
|
+
language: z.string().describe('REQUIRED, e.g. "en-US"'),
|
|
4781
|
+
startDate: z.string().optional().describe('YYYY-MM-DD — required for keyword and related modes'),
|
|
4782
|
+
endDate: z.string().optional().describe('YYYY-MM-DD — required for keyword and related modes'),
|
|
4783
|
+
},
|
|
4784
|
+
outputSchema: { mode: z.string().optional(), method: z.string().optional(), q: z.string().optional(), country: z.string().optional(), language: z.string().optional(), rows: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
4785
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4786
|
+
}, wrap(async (a) => {
|
|
4787
|
+
const d = await apiGet('/api/bing-webmaster/keywords', a);
|
|
4788
|
+
return ok(`${d.count || 0} keyword row(s) for "${d.q}" (${d.country}/${d.language}, ${d.method}).\n${rowLines(d.rows)}\n\n${d.note || ''}`, d);
|
|
4789
|
+
}));
|
|
4790
|
+
server.registerTool('bing_webmaster_crawl', {
|
|
4791
|
+
title: 'Can Bing crawl this site?',
|
|
4792
|
+
description: 'Whether Bing can actually reach the site\'s pages: crawl statistics (mode "stats"), the URLs with problems (mode "issues", the default), one page\'s index detail (mode "page", needs `url`), a directory\'s (mode "directory"), or the crawl settings themselves (mode "settings"). Issue names are DECODED from Bing\'s bitmask into readable ones — Code301, Code302, Code4xx, Code5xx, BlockedByRobotsTxt, ContainsMalware, ImportantUrlBlockedByRobotsTxt, DnsErrors, TimeOutErrors — because the raw JSON hands back an integer that means nothing on its own, and a bit Microsoft has added since is reported as unknown rather than dropped. Microsoft notes it may take a few days for a FIXED issue to disappear from this list, so a listed issue is not proof it is still live. Read-only, 0 credits.',
|
|
4793
|
+
inputSchema: {
|
|
4794
|
+
siteUrl: z.string().describe('the exact site string from list_bing_webmaster_sites'),
|
|
4795
|
+
mode: z.string().optional().describe('issues (default) | stats | page | directory | settings'),
|
|
4796
|
+
url: z.string().optional().describe('required for page and directory modes'),
|
|
4797
|
+
page: z.number().optional().describe('zero-based results page, directory mode'),
|
|
4798
|
+
},
|
|
4799
|
+
outputSchema: { siteUrl: z.string().optional(), mode: z.string().optional(), method: z.string().optional(), rows: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
4800
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4801
|
+
}, wrap(async (a) => {
|
|
4802
|
+
const d = await apiGet('/api/bing-webmaster/crawl', a);
|
|
4803
|
+
return ok(`${d.count || 0} row(s) from Bing (${d.method}) for ${d.siteUrl}.\n${rowLines(d.rows)}${d.note ? `\n\n${d.note}` : ''}`, d);
|
|
4804
|
+
}));
|
|
4805
|
+
server.registerTool('bing_webmaster_links', {
|
|
4806
|
+
title: 'Inbound links as Bing sees them',
|
|
4807
|
+
description: 'Inbound links according to Bing: counts per page (mode "counts", the default), the inbound links pointing at one page (mode "page", needs `url`), or the pages the site owner has declared as connected (mode "connected"). Read-only, 0 credits.',
|
|
4808
|
+
inputSchema: {
|
|
4809
|
+
siteUrl: z.string().describe('the exact site string from list_bing_webmaster_sites'),
|
|
4810
|
+
mode: z.string().optional().describe('counts (default) | page | connected'),
|
|
4811
|
+
url: z.string().optional().describe('required for page mode — the page whose inbound links you want'),
|
|
4812
|
+
page: z.number().optional().describe('zero-based results page'),
|
|
4813
|
+
},
|
|
4814
|
+
outputSchema: { siteUrl: z.string().optional(), mode: z.string().optional(), method: z.string().optional(), rows: z.array(z.any()).optional(), count: z.number().optional() },
|
|
4815
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4816
|
+
}, wrap(async (a) => {
|
|
4817
|
+
const d = await apiGet('/api/bing-webmaster/links', a);
|
|
4818
|
+
return ok(`${d.count || 0} row(s) from Bing (${d.method}) for ${d.siteUrl}.\n${rowLines(d.rows)}`, d);
|
|
4819
|
+
}));
|
|
4820
|
+
server.registerTool('bing_webmaster_submission_quota', {
|
|
4821
|
+
title: 'How many URLs can still be submitted to Bing today',
|
|
4822
|
+
description: 'How many URLs this site may still submit to Bing today and this month, plus the separate content-submission budget. BING SETS THIS PER SITE AND IT VARIES — Microsoft\'s own documented example allows 5 a day and 24 a month — so it is always read live and never assumed. Check it before promising anyone a bulk submission, because Bing rejects an over-quota batch as a WHOLE rather than taking what fits. Read-only, 0 credits.',
|
|
4823
|
+
inputSchema: { siteUrl: z.string().describe('the exact site string from list_bing_webmaster_sites') },
|
|
4824
|
+
outputSchema: { siteUrl: z.string().optional(), urlSubmission: z.record(z.any()).optional(), contentSubmission: z.record(z.any()).optional(), note: z.string().optional() },
|
|
4825
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
4826
|
+
}, wrap(async (a) => {
|
|
4827
|
+
const d = await apiGet('/api/bing-webmaster/quota', { siteUrl: a.siteUrl });
|
|
4828
|
+
return ok(`${d.siteUrl}: ${d.urlSubmission?.daily ?? '?'} URL submission(s) left today, ${d.urlSubmission?.monthly ?? '?'} this month${d.contentSubmission ? `; content submissions ${d.contentSubmission.daily}/day, ${d.contentSubmission.monthly}/month` : ''}. ${d.note || ''}`, d);
|
|
4829
|
+
}));
|
|
4830
|
+
server.registerTool('submit_bing_webmaster_urls', {
|
|
4831
|
+
title: 'Submit URLs to Bing for indexing',
|
|
4832
|
+
description: 'Submit URLs to Bing for indexing — the fastest way to get a new or changed page crawled by Bing, and therefore into Copilot and ChatGPT web search. THE SITE\'S REMAINING DAILY QUOTA IS READ FIRST and a batch that would exceed it is refused with the real number rather than half-submitted, because Bing rejects an over-quota batch as a whole and a partial failure leaves you unable to say which URLs made it. Bing accepts at most 500 per batch (Microsoft\'s own limit) and the site\'s daily quota is usually far lower. THE CONFIRMATION IS BING\'S QUOTA COUNTER MOVING: its submit methods return no body at all, so the answer reports the counter before and after rather than claiming success from a 200. 0 credits.',
|
|
4833
|
+
inputSchema: {
|
|
4834
|
+
siteUrl: z.string().describe('the exact site string from list_bing_webmaster_sites'),
|
|
4835
|
+
urls: z.array(z.string()).describe('full URLs on THIS site — a URL on another host is refused before anything is submitted'),
|
|
4836
|
+
},
|
|
4837
|
+
outputSchema: { siteUrl: z.string().optional(), submitted: z.number().optional(), urls: z.array(z.string()).optional(), method: z.string().optional(), quotaBefore: z.number().optional(), quotaAfter: z.number().optional(), quotaSpent: z.number().optional(), note: z.string().optional() },
|
|
4838
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
4839
|
+
}, wrap(async (a) => {
|
|
4840
|
+
const d = await apiPost('/api/bing-webmaster/submit', a);
|
|
4841
|
+
return ok(`${d.submitted} URL(s) to Bing for ${d.siteUrl}. ${d.note || ''}`, d);
|
|
4842
|
+
}));
|
|
4843
|
+
// ---------- INDEXNOW ----------
|
|
4844
|
+
// NO CONNECTOR, and that is structural rather than a preference: the key is the CUSTOMER'S, it lives
|
|
4845
|
+
// on the CUSTOMER'S web server, and possession of it is the entire authorisation model. There is
|
|
4846
|
+
// nothing for Hermoso to store, revoke or disconnect.
|
|
4847
|
+
server.registerTool('submit_indexnow', {
|
|
4848
|
+
title: 'Tell the IndexNow search engines a page changed',
|
|
4849
|
+
description: 'Ping the IndexNow search engines that pages were added, updated or deleted — Bing, Yandex, Naver, Seznam.cz and Yep, all reached by ONE submission. 🚨 GOOGLE DOES NOT PARTICIPATE IN INDEXNOW and never has, so say that plainly rather than letting anyone expect a Google effect; for Google use submit_search_console_sitemap and search_console_url_inspection. NO CONNECTION IS NEEDED, because the credential belongs to the USER: they generate a key, host it as a plain text file on their own web server, and any engine fetching that file is the whole ownership check. HERMOSO VERIFIES THAT FILE BEFORE SUBMITTING — that it is reachable and that its contents are exactly the key — and refuses with an explanation if not, because we have no write access to a customer\'s web root and cannot place it for them. If the key file is moved off the site root with `keyLocation`, it authorises ONLY URLs under that directory, and URLs outside it are refused before anything is sent (IndexNow rejects the whole batch for one out-of-scope URL). Up to 10,000 URLs per submission, all on the one host. HTTP 202 is a SUCCESS ("received, validation pending"), not a reason to retry. 0 credits.',
|
|
4850
|
+
inputSchema: {
|
|
4851
|
+
host: z.string().describe('the bare hostname that owns these URLs, e.g. "example.com"'),
|
|
4852
|
+
key: z.string().describe('the IndexNow key — 8-128 letters, numbers and dashes — ALREADY hosted as <key>.txt on that site'),
|
|
4853
|
+
urls: z.array(z.string()).describe('the full URLs that were added, updated or deleted, all on `host`'),
|
|
4854
|
+
keyLocation: z.string().optional().describe('the full URL of the key file if it is NOT at the site root. Moving it NARROWS the key to that directory only.'),
|
|
4855
|
+
},
|
|
4856
|
+
outputSchema: { submitted: z.number().optional(), urls: z.array(z.string()).optional(), host: z.string().optional(), keyLocation: z.string().optional(), status: z.number().optional(), keyFile: z.string().optional(), engines: z.array(z.string()).optional(), note: z.string().optional() },
|
|
4857
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
4858
|
+
}, wrap(async (a) => {
|
|
4859
|
+
const d = await apiPost('/api/indexnow/submit', a);
|
|
4860
|
+
return ok(`${d.submitted} URL(s) submitted to IndexNow for ${d.host} (HTTP ${d.status}). ${d.keyFile || ''}\n${d.note || ''}`, d);
|
|
4861
|
+
}));
|
|
4213
4862
|
// ---------- PRODUCT ANALYTICS: PostHog · Mixpanel · Amplitude (2026-08-10) ----------
|
|
4214
4863
|
// The siblings of google_analytics, and the reason they earn a slot beside it: GA4 answers AGGREGATE
|
|
4215
4864
|
// questions about traffic; these answer WHICH HUMAN DID WHAT — per-user funnels, retention cohorts and
|
|
@@ -4480,6 +5129,120 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4480
5129
|
if (d.available === false) return ok(`⚠ ${d.warning}`, d);
|
|
4481
5130
|
return ok(d.count ? `${d.count} Amplitude ${d.category} definition(s):\n${d.items.slice(0, 60).map(i => `• ${i.event_type || i.name || i.value || JSON.stringify(i).slice(0, 80)}`).join('\n')}` : d.note, d);
|
|
4482
5131
|
}));
|
|
5132
|
+
// ANALYTICS DEPTH (2026-08-18) — three gaps the depth audit found, all of them shaped like the product:
|
|
5133
|
+
// (a) Amplitude was the ONLY one of the three product-analytics connectors with no saved-report lane,
|
|
5134
|
+
// so a user who had already built the chart could not read it;
|
|
5135
|
+
// (b) NOTHING in this whole surface wrote Hermoso's own activity BACK into the user's analytics — the
|
|
5136
|
+
// annotation pair is that closed loop, and it is the one write here;
|
|
5137
|
+
// (c) Mixpanel had no per-user lane at all, though PostHog and Amplitude both do.
|
|
5138
|
+
server.registerTool('amplitude_chart', {
|
|
5139
|
+
title: 'Read a saved Amplitude chart',
|
|
5140
|
+
description: "READ BACK A SAVED AMPLITUDE CHART by its id — the chart the user already built in Amplitude's own UI, with whatever segments, filters and date range they configured there. This is Amplitude's equivalent of posthog_insight and mixpanel_insights, and it is the right lane whenever the report already exists: you inherit their definitions instead of rebuilding them from a segmentation query, and it costs one call rather than several. THE CHART ID MUST COME FROM THE USER — Amplitude publishes no endpoint that lists a project's charts — and it is the segment after '/chart/' in the chart's URL (e.g. 'abc123' in https://analytics.amplitude.com/demo/chart/abc123); pasting the whole URL works too. THE RESPONSE SHAPE VARIES BY CHART TYPE, in Amplitude's own words, so read what comes back rather than assuming a series: it may be JSON or a text/CSV body, and BOTH are correct answers rather than one being an error. Read-only, 0 credits.",
|
|
5141
|
+
inputSchema: { chartId: z.string().describe("the saved chart's id, or its full URL — the id is the segment after /chart/") },
|
|
5142
|
+
outputSchema: { chartId: z.string().optional(), format: z.string().optional(), data: z.any().optional(), text: z.string().nullable().optional(), note: z.string().optional() },
|
|
5143
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5144
|
+
}, wrap(async (a) => {
|
|
5145
|
+
const d = await apiGet('/api/amplitude/chart', a);
|
|
5146
|
+
const body = d.format === 'text' ? String(d.text || '').slice(0, 3000) : JSON.stringify(d.data).slice(0, 3000);
|
|
5147
|
+
return ok(`Amplitude chart ${d.chartId} (${d.format}):\n${body}${d.note ? `\n${d.note}` : ''}`, d);
|
|
5148
|
+
}));
|
|
5149
|
+
server.registerTool('list_amplitude_annotations', {
|
|
5150
|
+
title: 'List Amplitude chart annotations',
|
|
5151
|
+
description: "The markers already on this Amplitude project's charts — releases, campaigns, incidents — with their timestamps, labels and categories. READ THESE BEFORE EXPLAINING A CHANGE IN A CHART: a spike that lines up with an annotation has an explanation somebody already wrote down, and reporting it as a mystery when the answer is sitting on the timeline is exactly the failure this closes. Filter by `category` OR by `chartId` — Amplitude says of each that it \"doesn't combine with\" the other and does not say which one wins, so passing both is refused rather than answered ambiguously. `start` and `end` are ISO 8601 (e.g. '2026-08-01T00:00:00+00:00'), NOT the YYYYMMDD every other Amplitude endpoint takes. Read-only, 0 credits.",
|
|
5152
|
+
inputSchema: {
|
|
5153
|
+
category: z.string().optional().describe('category NAME — does not combine with chartId'),
|
|
5154
|
+
chartId: z.string().optional().describe('only annotations shown on this chart — does not combine with category'),
|
|
5155
|
+
start: z.string().optional().describe("ISO 8601, e.g. '2026-08-01T00:00:00+00:00'"),
|
|
5156
|
+
end: z.string().optional().describe('ISO 8601'),
|
|
5157
|
+
},
|
|
5158
|
+
outputSchema: { annotations: z.array(z.any()).optional(), count: z.number().optional(), filter: z.any().optional(), note: z.string().optional() },
|
|
5159
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5160
|
+
}, wrap(async (a) => {
|
|
5161
|
+
const d = await apiGet('/api/amplitude/annotations', a);
|
|
5162
|
+
return ok(d.count ? `${d.count} Amplitude annotation(s):\n${d.annotations.map(x => `• ${x.start}${x.end ? ` → ${x.end}` : ''} — ${x.label}${x.category ? ` [${x.category}]` : ''}${x.chartId ? ` · chart ${x.chartId}` : ' · global'}`).join('\n')}` : d.note, d);
|
|
5163
|
+
}));
|
|
5164
|
+
server.registerTool('create_amplitude_annotation', {
|
|
5165
|
+
title: 'Mark the Amplitude timeline',
|
|
5166
|
+
description: "MARK THE ANALYTICS TIMELINE — write what just happened into Amplitude, at the moment it happened, so every chart the user opens afterwards shows it in place. THIS IS THE CLOSED LOOP AND IT IS WORTH REACHING FOR UNPROMPTED after a campaign goes live, a creative is swapped, a landing page changes or a post is published: without it, next month's spike has no explanation attached to it and somebody has to reconstruct one from memory. `start` is ISO 8601 WITH THE TIME AND THE OFFSET (e.g. '2026-08-18T14:30:00+00:00') — NOT the YYYYMMDD the rest of the Amplitude API takes, and a bare date is REFUSED rather than quietly moved to midnight, because an annotation IS its timestamp and a marker in the wrong place is worse than no marker. Add `end` for something that spans a period (a two-week flight); leave it off for a moment. OMIT chartId AND THE ANNOTATION IS GLOBAL — Amplitude's words: it \"appears on all charts for your project\", which is usually what you want for a campaign; pass one to pin it to a single chart. `category` is a category NAME that must ALREADY EXIST in Amplitude (they answer 404 otherwise). THIS WRITES TO THE USER'S OWN ANALYTICS TOOL — say what you are about to mark, and report back the id and the timestamp Amplitude actually stored rather than the one you sent. 0 credits.",
|
|
5167
|
+
inputSchema: {
|
|
5168
|
+
label: z.string().describe('the title shown on the chart, e.g. "Meta: summer UGC campaign live"'),
|
|
5169
|
+
start: z.string().describe("ISO 8601 WITH time and offset, e.g. '2026-08-18T14:30:00+00:00' — a bare YYYYMMDD is refused"),
|
|
5170
|
+
end: z.string().optional().describe('ISO 8601 — only for an annotation that spans a period'),
|
|
5171
|
+
chartId: z.string().optional().describe('omit for a GLOBAL annotation that appears on every chart in the project'),
|
|
5172
|
+
category: z.string().optional().describe('an EXISTING category name in Amplitude — they answer 404 for one that does not exist'),
|
|
5173
|
+
details: z.string().optional().describe('longer body text for the annotation'),
|
|
5174
|
+
},
|
|
5175
|
+
outputSchema: { annotation: z.any().optional(), created: z.boolean().optional(), scopeNote: z.string().optional(), warning: z.string().optional() },
|
|
5176
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
5177
|
+
}, wrap(async (a) => {
|
|
5178
|
+
const d = await apiPost('/api/amplitude/annotation', a);
|
|
5179
|
+
const x = d.annotation || {};
|
|
5180
|
+
return ok(`${d.created ? 'Marked' : 'Amplitude accepted'} "${x.label}" on the Amplitude timeline at ${x.start} (id ${x.id}). ${d.scopeNote}${d.warning ? `\n⚠ ${d.warning}` : ''}`, d);
|
|
5181
|
+
}));
|
|
5182
|
+
server.registerTool('list_posthog_annotations', {
|
|
5183
|
+
title: 'List PostHog annotations',
|
|
5184
|
+
description: "The markers already on this PostHog project's charts — releases, experiments, incidents — with their timestamps, scopes and who wrote them. READ THESE BEFORE EXPLAINING A CHANGE IN A CHART: an annotation is an explanation somebody already wrote down. NEEDS THE `annotation:read` SCOPE on the personal API key, and a key created before Hermoso supported annotations will not have it — PostHog's own refusal NAMES the scope, and that is a scope to add in PostHog ▸ Settings ▸ Personal API keys, NOT a broken connection and NOT a reason to reconnect. Returns ONE page and hands back the next offset rather than paging for you, because PostHog's policy is that a third-party connector is not an exporter. Read-only, 0 credits.",
|
|
5185
|
+
inputSchema: {
|
|
5186
|
+
limit: z.number().optional().describe('1–100, default 25'),
|
|
5187
|
+
offset: z.number().optional().describe('for the next page — this tool does not paginate for you'),
|
|
5188
|
+
search: z.string().optional().describe('free-text search over annotation content'),
|
|
5189
|
+
},
|
|
5190
|
+
outputSchema: { project: z.string().optional(), annotations: z.array(z.any()).optional(), count: z.number().optional(), total: z.number().nullable().optional(), nextOffset: z.number().optional(), more: z.string().optional(), note: z.string().optional() },
|
|
5191
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5192
|
+
}, wrap(async (a) => {
|
|
5193
|
+
const d = await apiGet('/api/posthog/annotations', a);
|
|
5194
|
+
return ok(d.count ? `${d.count} PostHog annotation(s) in project ${d.project}:\n${d.annotations.map(x => `• ${x.dateMarker} — ${x.content} [${x.scope}${x.creationType ? `/${x.creationType}` : ''}]${x.createdBy ? ` · ${x.createdBy}` : ''}`).join('\n')}${d.more ? `\n${d.more}` : ''}` : d.note, d);
|
|
5195
|
+
}));
|
|
5196
|
+
server.registerTool('create_posthog_annotation', {
|
|
5197
|
+
title: 'Mark the PostHog timeline',
|
|
5198
|
+
description: "MARK THE POSTHOG TIMELINE — write what just happened into PostHog at the moment it happened, so every chart afterwards shows it in place. The same closed loop as create_amplitude_annotation, and worth reaching for unprompted after a campaign goes live, a creative is swapped or a landing page changes. `dateMarker` is what POSITIONS the marker on the x-axis (ISO 8601); omit it and NOW is used, which is right for \"this just went live\" — and the timestamp PostHog actually STORED is reported back rather than the one sent. `scope` is project (the default — shows on everything) / organization / dashboard / dashboard_item, and `dashboard` and `dashboard_item` each need their id or the annotation attaches to nothing and appears nowhere. PostHog also publishes a `recording` scope which THEY THEMSELVES REJECT, so it is refused by name here rather than sent. `creationType` USR means a person wrote it, GIT means a bot or a deployment did. NEEDS THE `annotation:write` SCOPE on the personal API key — a key made before Hermoso supported annotations will not have it; PostHog names it in their refusal, and it is added in PostHog ▸ Settings ▸ Personal API keys without reconnecting. THIS WRITES TO THE USER'S OWN ANALYTICS TOOL. 0 credits.",
|
|
5199
|
+
inputSchema: {
|
|
5200
|
+
content: z.string().describe('the text shown on the chart, ≤8192 characters'),
|
|
5201
|
+
dateMarker: z.string().optional().describe('ISO 8601 — defaults to now, which is right for "this just went live"'),
|
|
5202
|
+
scope: z.enum(['project', 'organization', 'dashboard', 'dashboard_item']).optional().describe('default project. PostHog\'s `recording` scope is deprecated and rejected by PostHog'),
|
|
5203
|
+
creationType: z.enum(['USR', 'GIT']).optional().describe('USR = a person wrote it, GIT = a bot or deployment did'),
|
|
5204
|
+
dashboardId: z.number().optional().describe('REQUIRED when scope is "dashboard"'),
|
|
5205
|
+
insightId: z.number().optional().describe('REQUIRED when scope is "dashboard_item" — the numeric id of the insight'),
|
|
5206
|
+
},
|
|
5207
|
+
outputSchema: { project: z.string().optional(), created: z.boolean().optional(), annotation: z.any().optional(), warning: z.string().optional() },
|
|
5208
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
5209
|
+
}, wrap(async (a) => {
|
|
5210
|
+
const d = await apiPost('/api/posthog/annotation', a);
|
|
5211
|
+
const x = d.annotation || {};
|
|
5212
|
+
return ok(`${d.created ? 'Marked' : 'PostHog accepted'} "${x.content}" on the PostHog timeline at ${x.dateMarker} (id ${x.id}, ${x.scope} scope).${d.warning ? `\n⚠ ${d.warning}` : ''}`, d);
|
|
5213
|
+
}));
|
|
5214
|
+
server.registerTool('list_mixpanel_cohorts', {
|
|
5215
|
+
title: 'List saved Mixpanel cohorts',
|
|
5216
|
+
description: "THE SAVED COHORTS in the Mixpanel project — each with its id, name, description and CURRENT MEMBER COUNT. Two uses, and both matter for advertising: the counts alone answer \"how big is our converted audience\", and the id is what mixpanel_profiles needs to read the PEOPLE in one. A cohort hidden in Mixpanel's UI is still listed and flagged rather than dropped, because hidden is a display choice and it is still queryable by id. ONE CALL, and the cheapest thing on this connector — worth calling before mixpanel_profiles rather than guessing an id. Remember Mixpanel allows only 60 QUERIES PER HOUR across its entire Query API (5 concurrent), the tightest budget of any connector here, and this shares it with every other Mixpanel tool. Read-only, 0 credits.",
|
|
5217
|
+
inputSchema: {},
|
|
5218
|
+
outputSchema: { project: z.string().optional(), workspace: z.string().optional(), cohorts: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
5219
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5220
|
+
}, wrap(async () => {
|
|
5221
|
+
const d = await apiGet('/api/mixpanel/cohorts', {});
|
|
5222
|
+
return ok(d.count ? `${d.count} Mixpanel cohort(s) in project ${d.project}:\n${d.cohorts.map(c => `• ${c.name} — id ${c.cohortId}, ${c.count ?? '?'} member(s)${c.visible ? '' : ' (hidden in the Mixpanel UI, still queryable)'}${c.description ? ` — ${c.description}` : ''}`).join('\n')}` : d.note, d);
|
|
5223
|
+
}));
|
|
5224
|
+
server.registerTool('mixpanel_profiles', {
|
|
5225
|
+
title: 'Read the people in a Mixpanel cohort',
|
|
5226
|
+
description: "THE PEOPLE THEMSELVES in Mixpanel — the per-user lane, and the question GA4 structurally cannot answer at all. Pass `cohortId` (from list_mixpanel_cohorts) for \"who is in the Converted cohort and what do we know about them\", or `where` for an ad-hoc filter over profile properties, or `distinctId` for one person. PASS `outputProperties` ALMOST ALWAYS: it cuts the payload to the properties you actually need, and Mixpanel says it \"can drastically reduce the amount of data returned\" and \"can speed up queries significantly\". RETURNS ONE PAGE AND DOES NOT PAGINATE FOR YOU — it hands back page, pageSize, total and a sessionId, and Mixpanel REQUIRES that sessionId alongside any page above 0, so a second page is a deliberate second call. DO NOT LOOP IT: Mixpanel allows 60 queries per hour across its ENTIRE Query API and each page spends one of them. REPORT `total` (the whole cohort) SEPARATELY FROM `count` (this page) — quoting only the page size understates the audience, which is the number a targeting decision gets made on. Read-only, 0 credits.",
|
|
5227
|
+
inputSchema: {
|
|
5228
|
+
cohortId: z.string().optional().describe('a numeric cohort id from list_mixpanel_cohorts'),
|
|
5229
|
+
where: z.string().optional().describe('a Mixpanel segmentation expression over profile properties, e.g. properties["$city"] == "London"'),
|
|
5230
|
+
distinctId: z.string().optional().describe('one person'),
|
|
5231
|
+
distinctIds: z.array(z.string()).optional().describe('several people by distinct id'),
|
|
5232
|
+
outputProperties: z.array(z.string()).optional().describe('only return these properties — strongly recommended, it is much faster and much smaller'),
|
|
5233
|
+
dataGroupId: z.string().optional().describe('group key — for GROUP profiles rather than user profiles'),
|
|
5234
|
+
page: z.number().optional().describe('pages start at ZERO; any page above 0 REQUIRES sessionId'),
|
|
5235
|
+
sessionId: z.string().optional().describe('from the previous page — required for page > 0'),
|
|
5236
|
+
includeAllUsers: z.boolean().optional().describe('only applies alongside cohortId; false = only distinct ids that actually have a profile'),
|
|
5237
|
+
},
|
|
5238
|
+
outputSchema: { project: z.string().optional(), workspace: z.string().optional(), profiles: z.array(z.any()).optional(), count: z.number().optional(), total: z.number().nullable().optional(), page: z.number().nullable().optional(), pageSize: z.number().nullable().optional(), sessionId: z.string().optional(), cohortId: z.number().optional(), hasMore: z.boolean().optional(), nextPage: z.number().optional(), pageNote: z.string().optional(), note: z.string().optional() },
|
|
5239
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5240
|
+
}, wrap(async (a) => {
|
|
5241
|
+
const d = await apiPost('/api/mixpanel/profiles', a);
|
|
5242
|
+
const head = `${d.count || 0} profile(s) on this page${d.total != null ? ` of ${d.total} total` : ''} in Mixpanel project ${d.project}${d.cohortId ? ` (cohort ${d.cohortId})` : ''}.`;
|
|
5243
|
+
const rows = (d.profiles || []).slice(0, 40).map(pr => `• ${pr.distinctId} — ${Object.entries(pr.properties || {}).slice(0, 6).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ')}`);
|
|
5244
|
+
return ok([head, ...rows, d.note || '', d.pageNote || ''].filter(Boolean).join('\n'), d);
|
|
5245
|
+
}));
|
|
4483
5246
|
// ---------- Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created
|
|
4484
5247
|
// Paused, only an explicit confirm:true arms real money, and every narration comes from a READ-BACK.
|
|
4485
5248
|
// Microsoft's statuses are Active / Paused (never ENABLED) and it answers HTTP 200 with a PartialErrors
|
|
@@ -4614,7 +5377,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4614
5377
|
name: z.string().describe('campaign name, ≤128 characters'),
|
|
4615
5378
|
dailyBudget: z.number().describe('daily budget in the account’s currency'),
|
|
4616
5379
|
budgetType: z.enum(['DailyBudgetStandard', 'DailyBudgetAccelerated', 'LifetimeBudgetStandard']).optional().describe('default DailyBudgetStandard; Accelerated is Audience-campaign only'),
|
|
4617
|
-
campaignType: z.string().optional().describe('default Search'),
|
|
5380
|
+
campaignType: z.string().optional().describe('Search (default), Shopping, DynamicSearchAds, Audience or PerformanceMax. ONLY "Search" builds the full tree here — the ad group, responsive search ad and keywords below are Search-shaped. Any other type is created as a campaign on its own (finish it in Microsoft Advertising), and passing `adGroup` with it is refused before anything is created.'),
|
|
4618
5381
|
timeZone: z.string().optional().describe('Microsoft time-zone enum — Microsoft requires one; default PacificTimeUSCanadaTijuana'),
|
|
4619
5382
|
locations: z.array(z.string()).optional().describe('where the ads may serve — omit for worldwide (Microsoft’s default, warned about in the read-back), e.g. ["United States"] or ["Seattle, Washington, United States","CA"]. Resolved to Microsoft location ids BEFORE anything is created; an ambiguous or unknown one refuses the whole create and names it'),
|
|
4620
5383
|
excludeLocations: z.array(z.string()).optional().describe('locations to EXCLUDE from the targeted set'),
|
|
@@ -4892,6 +5655,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4892
5655
|
granularity: z.enum(['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY']).optional(),
|
|
4893
5656
|
groupBy: z.array(z.string()).optional().describe('adminArea, ageRange, countryCode, countryOrRegion, deviceClass, gender, locality'),
|
|
4894
5657
|
timeZone: z.enum(['ORTZ', 'UTC']).optional().describe('ORTZ = the organization time zone, Apple’s default'),
|
|
5658
|
+
offset: z.number().optional().describe('row to start at (default 0). A FULL page means there are probably MORE rows, and the default ordering is localSpend DESCENDING — so the omitted rows are the LOW-SPEND tail. Page with offset rather than reading one page as the whole account.'),
|
|
4895
5659
|
limit: z.number().optional(),
|
|
4896
5660
|
},
|
|
4897
5661
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), startTime: z.string().optional(), endTime: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), grandTotals: z.any().optional(), note: z.string().optional() },
|
|
@@ -4931,7 +5695,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4931
5695
|
|
|
4932
5696
|
server.registerTool('create_apple_ads_ad_group', {
|
|
4933
5697
|
title: 'Create an Apple Ads ad group',
|
|
4934
|
-
description: 'Create an ad group inside an Apple Ads campaign — this is where the bid and the keywords live. CREATED PAUSED ALWAYS. REQUIRED: campaignId, name. defaultBid is the most you will pay per tap (decimal string, e.g. "1.50") and applies to every keyword with no bid of its own. startTime is optional here and defaults to now: Apple documents it as optional and then REJECTS the create without it, so Hermoso always supplies one. searchMatch:true opts into Apple’s automated keyword matching, which targets relevant search terms beyond your keyword list. pricingModel defaults to CPT and must match the campaign’s billing event. Keywords cannot be created inline — make the ad group, then call add_apple_ads_keywords. Like the campaign it lives in, it stays PAUSED until set_apple_ads_status(status:"ENABLED", confirm:true) arms it. Free.',
|
|
5698
|
+
description: 'Create an ad group inside an Apple Ads campaign — this is where the bid and the keywords live. CREATED PAUSED ALWAYS. REQUIRED: campaignId, name. defaultBid is the most you will pay per tap (decimal string, e.g. "1.50") and applies to every keyword with no bid of its own. startTime is optional here and defaults to now: Apple documents it as optional and then REJECTS the create without it, so Hermoso always supplies one. searchMatch:true opts into Apple’s automated keyword matching, which targets relevant search terms beyond your keyword list. pricingModel defaults to CPT and must match the campaign’s billing event. Keywords cannot be created inline — make the ad group, then call add_apple_ads_keywords. targeting is who and where it reaches: FOR AN APPLE MAPS AD GROUP, targeting.locationGroupIds is the only field that says WHICH of the brand’s shops this ad group promotes, and an ad group with none stops serving rather than reaching everywhere — build the group first with create_apple_ads_location_group. Radius and geo targeting cannot be combined in one ad group and that is refused here rather than delivered on terms nobody chose. Like the campaign it lives in, it stays PAUSED until set_apple_ads_status(status:"ENABLED", confirm:true) arms it. Free.',
|
|
4935
5699
|
inputSchema: {
|
|
4936
5700
|
campaignId: z.string().describe('REQUIRED.'), name: z.string().describe('REQUIRED.'),
|
|
4937
5701
|
defaultBid: z.string().optional().describe('Max cost per tap, e.g. "1.50".'),
|
|
@@ -4939,6 +5703,23 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4939
5703
|
endTime: z.string().optional().describe('Omit to inherit the campaign end date.'),
|
|
4940
5704
|
searchMatch: z.boolean().optional().describe('Opt into Apple’s automated keyword matching.'),
|
|
4941
5705
|
pricingModel: z.enum(['CPT', 'CPM', 'CPA']).optional().describe('Default CPT; must match the campaign billing event.'),
|
|
5706
|
+
targeting: z.object({
|
|
5707
|
+
locationGroupIds: z.array(z.string()).optional().describe('APPLE MAPS — the location groups whose shops this ad group promotes. Max 25.'),
|
|
5708
|
+
radius: z.enum(['CLOSE', 'MEDIUM', 'FAR']).optional().describe('Apple Maps — how near a person must be. Cannot be combined with adminArea/locality/postalCode.'),
|
|
5709
|
+
adminAreaIds: z.array(z.string()).optional().describe('State/province ids from the Geo API.'),
|
|
5710
|
+
localityIds: z.array(z.string()).optional().describe('City ids from the Geo API.'),
|
|
5711
|
+
postalCodeIds: z.array(z.string()).optional().describe('Postal code ids from the Geo API (Apple Maps).'),
|
|
5712
|
+
countryIds: z.array(z.string()).optional().describe('Country ids from the Geo API (App Store).'),
|
|
5713
|
+
deviceClass: z.array(z.enum(['IPHONE', 'IPAD'])).optional().describe('App Store.'),
|
|
5714
|
+
minAge: z.number().optional().describe('App Store. 18-64.'),
|
|
5715
|
+
maxAge: z.number().optional().describe('App Store. 18-64; omit entirely to reach 65+.'),
|
|
5716
|
+
gender: z.array(z.enum(['M', 'F'])).optional().describe('App Store.'),
|
|
5717
|
+
appCategoryIds: z.array(z.string()).optional().describe('App Store. 100 means "the same category as my app".'),
|
|
5718
|
+
excludeAppCategoryIds: z.array(z.string()).optional(),
|
|
5719
|
+
appDownloaderIds: z.array(z.string()).optional().describe('App Store — reach people who downloaded these Adam IDs.'),
|
|
5720
|
+
excludeAppDownloaderIds: z.array(z.string()).optional().describe('App Store — suppress people who already have your app.'),
|
|
5721
|
+
daypart: z.array(z.number()).optional().describe('Hour slots 0-167, slot 0 = Sunday 12am, in the ad account time zone.'),
|
|
5722
|
+
}).optional().describe('Who and where this ad group reaches. For Apple Maps, locationGroupIds is what says which shops it promotes.'),
|
|
4942
5723
|
},
|
|
4943
5724
|
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() },
|
|
4944
5725
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -4972,15 +5753,19 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4972
5753
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/negative-keywords', a); return ok(`Added ${d.added} negative keyword(s) (${d.scope}, verified: ${d.verified}).\n${(d.keywords || []).join('\n')}\n${d.note || ''}`, d); }));
|
|
4973
5754
|
|
|
4974
5755
|
server.registerTool('update_apple_ads_object', {
|
|
4975
|
-
title: 'Edit an Apple Ads campaign, ad group or
|
|
4976
|
-
description: 'Edit an existing Apple Ads campaign, ad group, keyword, creative or ad. Pass level and id plus ONLY the fields you want to change — Apple leaves every field you omit exactly as it is. Campaign: name, dailyBudget, startTime, endTime, countries, bidStrategyType. Ad group: name, defaultBid, startTime, endTime, searchMatch. Keyword: bid only — its text and match type are immutable, so delete and re-add to change either. Creative: name, plus (Apple Maps creatives only) a new creativeSpec — a creative’s type and destination are locked at creation, so pointing at a different app or product page means creating a NEW creative. Ad: name only — its creative and ad group are locked too, and Apple’s own instruction for serving a different creative is to create a new ad and delete the old one. WARNING
|
|
5756
|
+
title: 'Edit an Apple Ads campaign, ad group, keyword or location group',
|
|
5757
|
+
description: 'Edit an existing Apple Ads campaign, ad group, keyword, creative or ad. Pass level and id plus ONLY the fields you want to change — Apple leaves every field you omit exactly as it is. Campaign: name, dailyBudget, startTime, endTime, countries, bidStrategyType. Ad group: name, defaultBid, startTime, endTime, searchMatch. Keyword: bid only — its text and match type are immutable, so delete and re-add to change either. Creative: name, plus (Apple Maps creatives only) a new creativeSpec — a creative’s type and destination are locked at creation, so pointing at a different app or product page means creating a NEW creative. Ad: name only — its creative and ad group are locked too, and Apple’s own instruction for serving a different creative is to create a new ad and delete the old one. Location group: name, description, and locationIds or rules. WARNING, AND IT APPLIES TO EVERY ARRAY HERE: AN ARRAY REPLACES RATHER THAN MERGES. The countries you send become the campaign’s entire geographic targeting, the targeting dimensions you send become the ad group’s entire setting for those dimensions, and Apple states it for location groups in terms — to add ONE shop to a group, read its current locationIds, append, and send the whole list back, or you have just removed every other shop. Send everything you want to keep, never just the new one. This tool deliberately CANNOT change a status: enabling arms real spend, so it lives behind set_apple_ads_status and its confirmation. Free.',
|
|
4977
5758
|
inputSchema: {
|
|
4978
|
-
level: z.enum(['campaign', 'adgroup', 'keyword', 'creative', 'ad']).describe('REQUIRED.'), id: z.string().describe('REQUIRED.'),
|
|
5759
|
+
level: z.enum(['campaign', 'adgroup', 'keyword', 'creative', 'ad', 'location_group']).describe('REQUIRED.'), id: z.string().describe('REQUIRED.'),
|
|
4979
5760
|
name: z.string().optional(), dailyBudget: z.string().optional().describe('Campaign only.'),
|
|
4980
5761
|
defaultBid: z.string().optional().describe('Ad group only.'), bid: z.string().optional().describe('Keyword only.'),
|
|
4981
5762
|
countries: z.array(z.string()).optional().describe('Campaign only. REPLACES the whole list.'),
|
|
4982
5763
|
bidStrategyType: z.enum(['MANUAL_CPT', 'MAX_CONVERSIONS']).optional().describe('Campaign only.'),
|
|
4983
5764
|
searchMatch: z.boolean().optional().describe('Ad group only.'),
|
|
5765
|
+
targeting: z.record(z.any()).optional().describe('Ad group only. Same shape as create_apple_ads_ad_group.targeting — locationGroupIds, radius, adminAreaIds, daypart and the rest. REPLACES the dimensions you name.'),
|
|
5766
|
+
locationIds: z.array(z.string()).optional().describe('Location group only (STATIC). REPLACES the whole membership.'),
|
|
5767
|
+
rules: z.array(z.object({ field: z.string(), operator: z.string(), value: z.any() })).optional().describe('Location group only (DYNAMIC). REPLACES every rule and puts the group back to PENDING.'),
|
|
5768
|
+
description: z.string().optional().describe('Location group only.'),
|
|
4984
5769
|
startTime: z.string().optional(), endTime: z.string().optional(), currency: z.string().optional(),
|
|
4985
5770
|
},
|
|
4986
5771
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
@@ -4989,7 +5774,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
4989
5774
|
|
|
4990
5775
|
server.registerTool('set_apple_ads_status', {
|
|
4991
5776
|
title: 'Pause or enable an Apple Ads object',
|
|
4992
|
-
description: 'Pause or enable an Apple Ads campaign, ad group, keyword or ad. PAUSING is immediate and needs no confirmation. ENABLING IS THE ONE SWITCH THAT ARMS REAL MONEY: it requires confirm:true, and without it nothing changes and the refusal names the object Apple actually holds under that id — read back from Apple, never echoed from your input, because aiming at the wrong campaign is invisible until money moves. An object serves only when it AND every parent above it are ENABLED, so enabling a keyword inside a paused campaign spends nothing. Prefer pausing to deleting: pausing is reversible and Apple’s delete is not. A CREATIVE
|
|
5777
|
+
description: 'Pause or enable an Apple Ads campaign, ad group, keyword or ad. PAUSING is immediate and needs no confirmation. ENABLING IS THE ONE SWITCH THAT ARMS REAL MONEY: it requires confirm:true, and without it nothing changes and the refusal names the object Apple actually holds under that id — read back from Apple, never echoed from your input, because aiming at the wrong campaign is invisible until money moves. An object serves only when it AND every parent above it are ENABLED, so enabling a keyword inside a paused campaign spends nothing. Prefer pausing to deleting: pausing is reversible and Apple’s delete is not. A CREATIVE, AN ASSET AND A LOCATION GROUP ARE DELIBERATELY ABSENT from the levels here and that is not an oversight: none of them has an advertiser status — a creative and a location group carry only a read-only systemStatus Apple computes — so to stop a creative serving, pause the ADS that reference it, and to stop promoting a set of shops, pause the AD GROUP that targets the location group. The reply reports the status Apple STORED, which is a different claim from the one it accepted. Free.',
|
|
4993
5778
|
inputSchema: {
|
|
4994
5779
|
level: z.enum(['campaign', 'adgroup', 'keyword', 'negative_keyword', 'ad']).describe('REQUIRED.'),
|
|
4995
5780
|
id: z.string().describe('REQUIRED.'), status: z.enum(['ENABLED', 'PAUSED']).describe('REQUIRED.'),
|
|
@@ -5001,9 +5786,9 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
5001
5786
|
|
|
5002
5787
|
server.registerTool('delete_apple_ads_object', {
|
|
5003
5788
|
title: 'Delete an Apple Ads object',
|
|
5004
|
-
description: 'Delete an Apple Ads campaign, ad group, keyword, negative keyword, ad, creative or
|
|
5789
|
+
description: 'Delete an Apple Ads campaign, ad group, keyword, negative keyword, ad, creative, asset, location group or budget order. Requires confirm:true, and optionally confirmName echoed back to prove you aimed at the right object. THIS CASCADES AND CANNOT BE UNDONE: Apple soft-deletes with no undelete, and deleting a campaign takes every ad group, keyword and ad underneath it. THE BLAST RADIUS DIFFERS BY LEVEL and the refusal states the right one for the object you named: deleting a CREATIVE does not delete the ads that use it — it makes every one of them stop serving, permanently, with no way back but a new creative; deleting an ASSET makes any Apple Maps creative using it INVALID; deleting an AD leaves its creative untouched and available; deleting a LOCATION GROUP is permanent with no restore, and every ad group targeting it loses that constraint immediately — an ad group left targeting no location group STOPS serving rather than reaching everywhere. In almost every case set_apple_ads_status(status:"PAUSED") is what you actually want — it stops all spend and is reversible — but a creative, an asset, a location group and a budget order have NO status, so for those the reversible move is pausing whatever references them. DELETING A BUDGET ORDER CASCADES TO NOTHING — it is a spend ceiling, not a parent — but every campaign assigned to it loses that ceiling, and Apple REFUSES the delete outright while any campaign is still assigned, or once the order has started, or if it is cancelled, completed or exhausted; the refusal names whichever applies before anything is sent. The reply is confirmed by RE-READING the object: for a delete, an absent or deleted-flagged row is the proof, never the HTTP 200. Free.',
|
|
5005
5790
|
inputSchema: {
|
|
5006
|
-
level: z.enum(['campaign', 'adgroup', 'keyword', 'negative_keyword', 'ad', 'creative', 'asset']).describe('REQUIRED.'),
|
|
5791
|
+
level: z.enum(['campaign', 'adgroup', 'keyword', 'negative_keyword', 'ad', 'creative', 'asset', 'location_group', 'budget_order']).describe('REQUIRED.'),
|
|
5007
5792
|
id: z.string().describe('REQUIRED.'), confirm: z.boolean().optional().describe('REQUIRED. Nothing is deleted without it.'),
|
|
5008
5793
|
confirmName: z.string().optional().describe('Optional — echo the object’s exact name to prove you aimed at the right one.'),
|
|
5009
5794
|
},
|
|
@@ -5080,7 +5865,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
5080
5865
|
|
|
5081
5866
|
server.registerTool('create_apple_ads_creative', {
|
|
5082
5867
|
title: 'Create an Apple Ads creative',
|
|
5083
|
-
description: 'Create an Apple Ads creative — what a person sees before and after tapping the ad. Pick creativeType: DEFAULT_PRODUCT_PAGE renders the app’s standard App Store listing and needs only adamId; CUSTOM_PRODUCT_PAGE renders a custom page built in App Store Connect and needs adamId AND productPageId (from list_apple_ads_product_pages); LOCAL_ADS_SEARCH_CREATIVE is Apple Maps and needs brandId plus assetIds uploaded with upload_apple_ads_asset. YOU CANNOT SUPPLY YOUR OWN IMAGERY FOR AN APP STORE AD — Apple renders the pre-tap ad from the product page itself, so the way to change how an App Store ad LOOKS is to edit that page (or build a Custom Product Page) in App Store Connect; say that plainly rather than looking for a parameter that does not exist. The destination type is derived from the creative type and never asked for. A creative SPENDS NOTHING and has no status of its own: it serves only once an ad references it and that ad, its ad group and its campaign are all ENABLED. Apple validates asynchronously, so a fresh creative is usually PENDING for a moment and an ad may only reference a VALID one — that is a wait, not a failure. Type and destination are immutable afterwards. Free.',
|
|
5868
|
+
description: 'Create an Apple Ads creative — what a person sees before and after tapping the ad. Pick creativeType: DEFAULT_PRODUCT_PAGE renders the app’s standard App Store listing and needs only adamId; CUSTOM_PRODUCT_PAGE renders a custom page built in App Store Connect and needs adamId AND productPageId (from list_apple_ads_product_pages); LOCAL_ADS_SEARCH_CREATIVE is Apple Maps and needs brandId (from list_apple_ads_brands) plus assetIds you uploaded with upload_apple_ads_asset — that is the one creative type where YOUR OWN IMAGE is the ad. YOU CANNOT SUPPLY YOUR OWN IMAGERY FOR AN APP STORE AD — Apple renders the pre-tap ad from the product page itself, so the way to change how an App Store ad LOOKS is to edit that page (or build a Custom Product Page) in App Store Connect; say that plainly rather than looking for a parameter that does not exist. The destination type is derived from the creative type and never asked for. A creative SPENDS NOTHING and has no status of its own: it serves only once an ad references it and that ad, its ad group and its campaign are all ENABLED. Apple validates asynchronously, so a fresh creative is usually PENDING for a moment and an ad may only reference a VALID one — that is a wait, not a failure. Type and destination are immutable afterwards. Free.',
|
|
5084
5869
|
inputSchema: {
|
|
5085
5870
|
name: z.string().describe('REQUIRED.'),
|
|
5086
5871
|
creativeType: z.enum(['DEFAULT_PRODUCT_PAGE', 'CUSTOM_PRODUCT_PAGE', 'LOCAL_ADS_SEARCH_CREATIVE']).optional().describe('Default DEFAULT_PRODUCT_PAGE.'),
|
|
@@ -5124,7 +5909,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
5124
5909
|
|
|
5125
5910
|
server.registerTool('upload_apple_ads_asset', {
|
|
5126
5911
|
title: 'Upload an Apple Ads image asset',
|
|
5127
|
-
description: 'Upload an image into the Apple Ads asset library for an APPLE MAPS brand creative. APPLE MAPS ONLY, and that is Apple’s rule rather than ours: it refuses uploads for App Store apps outright, because an App Store ad is rendered from the app’s product page in App Store Connect and not from an image anyone uploads. That refusal is stated up front instead of being sent and rejected, so if you want a different-looking App Store ad the answer is App Store Connect, not this tool. PNG, JPG or HEIC. Pass imageUrl (upload_file turns a local file into one) and brandId. Apple processes the upload asynchronously, so re-read it with list_apple_ads_assets until its eligibility says it is ready before referencing it in a creative. Uploading spends nothing. Free.',
|
|
5912
|
+
description: 'Upload an image into the Apple Ads asset library for an APPLE MAPS brand creative. APPLE MAPS ONLY, and that is Apple’s rule rather than ours: it refuses uploads for App Store apps outright, because an App Store ad is rendered from the app’s product page in App Store Connect and not from an image anyone uploads. That refusal is stated up front instead of being sent and rejected, so if you want a different-looking App Store ad the answer is App Store Connect, not this tool. PNG, JPG or HEIC. Pass imageUrl (upload_file turns a local file into one) and brandId, which list_apple_ads_brands returns. Apple processes the upload asynchronously, so re-read it with list_apple_ads_assets until its eligibility says it is ready before referencing it in a creative. Uploading spends nothing. Free.',
|
|
5128
5913
|
inputSchema: {
|
|
5129
5914
|
imageUrl: z.string().describe('REQUIRED — a PNG, JPG or HEIC image Hermoso can fetch.'),
|
|
5130
5915
|
brandId: z.string().describe('REQUIRED — the Apple Maps brand this asset belongs to.'),
|
|
@@ -5148,6 +5933,322 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
5148
5933
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), assets: z.array(z.any()).optional(), note: z.string().optional() },
|
|
5149
5934
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5150
5935
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/assets', a); return ok(`${d.count} Apple Ads asset(s):\n${d.note}`, d); }));
|
|
5936
|
+
|
|
5937
|
+
// ── APPLE ADS: APPLE MAPS — THE LANE WHERE THE USER'S OWN IMAGE IS THE CREATIVE (2026-08-16) ──────────────────
|
|
5938
|
+
// Asset upload and the LOCAL_ADS_SEARCH_CREATIVE builder both shipped on 2026-08-15 and NEITHER was reachable:
|
|
5939
|
+
// the upload requires a brandId nothing in the product could produce, and an ad group had no way to name the
|
|
5940
|
+
// shops the ad promotes. These four close Apple's own chain — Brand → Location → Location Group → ad group
|
|
5941
|
+
// targeting, and Brand → Asset → Creative → Ad.
|
|
5942
|
+
server.registerTool('list_apple_ads_brands', {
|
|
5943
|
+
title: 'List Apple Maps brands',
|
|
5944
|
+
description: 'The Apple Maps brands on this Apple Ads account. A BRAND IS THE ROOT OF EVERY ADS-ON-APPLE-MAPS CAMPAIGN and its id is what three other calls need: it is the promotedObjectId of a BUSINESS_BRAND campaign, the brandId upload_apple_ads_asset uploads against, and the brandId a LOCAL_ADS_SEARCH_CREATIVE carries. A brand must read eligibility ELIGIBLE before Apple will let a campaign use it. YOU CANNOT CREATE A BRAND HERE and that is Apple’s design, not a gap: brands come from Apple Business Connect, and Ads on Apple Maps additionally need the ad account to carry productFeatures BUSINESS_BRAND_MANUAL plus a BUSINESS_BRAND delegation, neither of which any API can grant. AN EMPTY LIST IS THE NORMAL ANSWER FOR AN APP STORE ADVERTISER and does not mean anything is broken — App Store campaigns need no brand at all. Read-only, free.',
|
|
5945
|
+
inputSchema: {
|
|
5946
|
+
brandId: z.string().optional().describe('Fetch one brand by id.'),
|
|
5947
|
+
name: z.string().optional(), countryOrRegion: z.string().optional().describe('Two-letter code, e.g. "US".'),
|
|
5948
|
+
eligibilityStatus: z.string().optional().describe('Usually ELIGIBLE — only an ELIGIBLE brand can be used in a campaign.'),
|
|
5949
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
5950
|
+
},
|
|
5951
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), eligible: z.number().optional(), total: z.number().nullable().optional(), brands: z.array(z.any()).optional(), note: z.string().optional() },
|
|
5952
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5953
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/brands', a); return ok(`${d.count} Apple Maps brand(s):\n${d.note}`, d); }));
|
|
5954
|
+
|
|
5955
|
+
server.registerTool('list_apple_ads_locations', {
|
|
5956
|
+
title: 'List Apple Maps business locations',
|
|
5957
|
+
description: 'The physical business locations behind an Apple Maps brand — the shops, restaurants or service centres an Ads-on-Apple-Maps campaign promotes. Filter by brandId (do: without it Apple returns every location across every brand, which can be a very large page), plus status, eligibility or address. LOCATIONS ARE READ-ONLY THROUGH THIS API BY APPLE’S DESIGN: Apple Business Connect creates and maintains them, so a missing shop, a wrong address or a closure is fixed there and appears here afterwards — there is no create, update or delete to look for. Only a location with status OPEN and eligibility ELIGIBLE is targetable. You do not target locations directly: collect the ids you want, put them in a location group with create_apple_ads_location_group, then point an ad group at that group. Read-only, free.',
|
|
5958
|
+
inputSchema: {
|
|
5959
|
+
id: z.string().optional().describe('Fetch one location.'),
|
|
5960
|
+
brandId: z.string().optional().describe('Strongly recommended — scopes to one brand.'),
|
|
5961
|
+
status: z.enum(['OPEN', 'OPENING_SOON', 'CLOSED', 'MOVED', 'TEMPORARILY_CLOSED']).optional().describe('Usually OPEN — only OPEN locations are targetable.'),
|
|
5962
|
+
eligibilityStatus: z.enum(['ELIGIBLE', 'INELIGIBLE', 'LIMITED', 'PENDING', 'UNDEFINED']).optional(),
|
|
5963
|
+
countryOrRegion: z.string().optional().describe('Two-letter code.'),
|
|
5964
|
+
adminArea: z.string().optional().describe('Full state or province name, e.g. "California".'),
|
|
5965
|
+
locality: z.string().optional().describe('City name.'), postalCode: z.string().optional(),
|
|
5966
|
+
name: z.string().optional(), limit: z.number().optional(), offset: z.number().optional(),
|
|
5967
|
+
},
|
|
5968
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), locations: z.array(z.any()).optional(), note: z.string().optional() },
|
|
5969
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5970
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/locations', a); return ok(`${d.count} Apple Maps location(s):\n${d.note}`, d); }));
|
|
5971
|
+
|
|
5972
|
+
server.registerTool('list_apple_ads_location_groups', {
|
|
5973
|
+
title: 'List Apple Maps location groups',
|
|
5974
|
+
description: 'The location groups on this Apple Ads account — named sets of a brand’s shops that an ad group targets. Each row carries its type (STATIC or DYNAMIC), Apple’s systemStatus and groupTotal, the number of locations currently in it. READ groupTotal BEFORE TARGETING A GROUP: a DYNAMIC group sits at PENDING while Apple evaluates its rules, and a group that evaluated to ZERO is Apple’s one silent failure here — it reports no error, so a rule spelled "CA" instead of "California" builds an empty group that looks fine. Soft-deleted groups are excluded unless you pass includeDeleted. There is no filter for "which groups contain this location": query by brandId and inspect membership. Read-only, free.',
|
|
5975
|
+
inputSchema: {
|
|
5976
|
+
id: z.string().optional().describe('Fetch one group.'), brandId: z.string().optional(),
|
|
5977
|
+
name: z.string().optional().describe('Substring match.'),
|
|
5978
|
+
groupType: z.enum(['STATIC', 'DYNAMIC']).optional(),
|
|
5979
|
+
includeDeleted: z.boolean().optional().describe('Deleted groups are hidden by default.'),
|
|
5980
|
+
eligibilityStatus: z.string().optional(), limit: z.number().optional(), offset: z.number().optional(),
|
|
5981
|
+
},
|
|
5982
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), locationGroups: z.array(z.any()).optional(), note: z.string().optional() },
|
|
5983
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
5984
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/location-groups', a); return ok(`${d.count} Apple Maps location group(s):\n${d.note}`, d); }));
|
|
5985
|
+
|
|
5986
|
+
server.registerTool('create_apple_ads_location_group', {
|
|
5987
|
+
title: 'Create an Apple Maps location group',
|
|
5988
|
+
description: 'Create a location group — the set of a brand’s shops an Apple Maps ad group promotes. TWO KINDS. STATIC takes an explicit locationIds list (from list_apple_ads_locations) and is VALID immediately. DYNAMIC takes rules and maintains itself as the brand opens and closes shops, but starts PENDING while Apple evaluates them — wait for systemStatus VALID before targeting it. A RULE THAT MATCHES NOTHING IS APPLE’S ONE SILENT FAILURE HERE: it returns no error and simply builds a group with groupTotal 0, so adminArea must be the FULL English name ("California", never "CA") and locality must be countryOrRegion|adminArea|locality ("US|New York|Brooklyn", never "Brooklyn") — both shapes are refused here before the group is built. A group SPENDS NOTHING and does not decide who sees the ad or how near they are: it only scopes WHICH shops the ad group promotes, while radius and geo targeting on the ad group decide reach. brandId and the ad account are locked in permanently at creation. Target it afterwards with create_apple_ads_ad_group(targeting.locationGroupIds) or update_apple_ads_object(level:"adgroup"). Free.',
|
|
5989
|
+
inputSchema: {
|
|
5990
|
+
name: z.string().describe('REQUIRED.'),
|
|
5991
|
+
brandId: z.string().describe('REQUIRED — from list_apple_ads_brands. Immutable afterwards.'),
|
|
5992
|
+
groupType: z.enum(['STATIC', 'DYNAMIC']).optional().describe('Default STATIC. Immutable afterwards.'),
|
|
5993
|
+
locationIds: z.array(z.string()).optional().describe('REQUIRED for STATIC — ids from list_apple_ads_locations.'),
|
|
5994
|
+
rules: z.array(z.object({ field: z.enum(['adminArea', 'locality', 'postalCode', 'locationId']), operator: z.enum(['EQUALS', 'NOT_EQUALS', 'IN', 'NOT_IN']), value: z.any() })).optional().describe('REQUIRED for DYNAMIC. value is a string for EQUALS/NOT_EQUALS and an array for IN/NOT_IN.'),
|
|
5995
|
+
description: z.string().optional(),
|
|
5996
|
+
},
|
|
5997
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), spendsNothing: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
5998
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
5999
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/location-group', a); return ok(`Apple Maps location group created:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
6000
|
+
|
|
6001
|
+
// ── SUGGESTIONS · RECOMMENDATIONS · INSIGHTS · CHANGE HISTORY · BUDGET ORDERS · BULK · GEO · AD ACCOUNTS ───────
|
|
6002
|
+
// Eight areas of Apple's Platform API the first two passes never touched (2026-08-17). Only ONE tool here arms
|
|
6003
|
+
// money — apply_apple_ads_recommendation — and its gate names the resolved campaign and the current → recommended
|
|
6004
|
+
// value rather than merely asking whether you meant it.
|
|
6005
|
+
server.registerTool('apple_ads_keyword_suggestions', {
|
|
6006
|
+
title: 'Apple Ads keyword, phrase and category suggestions',
|
|
6007
|
+
description: 'Apple’s OWN search data for an app you advertise — the fastest way to build a real Apple Search Ads keyword list instead of guessing. THE THREE KINDS DO NOT SHARE A FILTER VOCABULARY and Apple 400s any field that is not an attribute of the one you query, so each takes different parameters. kind:"keyword" (the default) returns suggested keyword strings and accepts `terms` + `countriesOrRegions` — it has NO queryType, and passing one is refused. kind:"phrase" returns longer search phrases and REQUIRES `phrases` (the phrases to look up); kind:"category" returns App Store categories and REQUIRES `categories`. Neither accepts `terms`. POPULARITY IS RELATIVE, NOT A VOLUME: it ranks terms against each other and cannot be read as searches per month. queryType defaults to "SEARCH", which looks up phrases/categories you name; "SUGGESTION" asks Apple to discover new ones and IS CURRENTLY BROKEN AT APPLE — measured 2026-08-17, it hangs ~34s and returns a server error for a request identical to Apple’s own published example, so retrying will not clear it. REQUIRED: promotedObjectId — the app’s adamId (search_apple_ads_apps with no arguments lists the apps this organization owns) or an Apple Maps brandId. An empty result is about the APP, not the account. Feed the winners straight into add_apple_ads_keywords or bulk_apple_ads_keywords. Read-only, free, spends nothing.',
|
|
6008
|
+
inputSchema: {
|
|
6009
|
+
kind: z.enum(['keyword', 'phrase', 'category']).optional().describe('Default keyword. Each kind takes DIFFERENT filters — see the description.'),
|
|
6010
|
+
promotedObjectId: z.string().describe('REQUIRED — the app’s adamId, or an Apple Maps brandId.'),
|
|
6011
|
+
promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional().describe('Default APPSTORE_APP.'),
|
|
6012
|
+
terms: z.array(z.string()).optional().describe('kind:"keyword" ONLY. Seed search terms to expand from, e.g. ["productivity","task management"]. Refused on phrase/category.'),
|
|
6013
|
+
phrases: z.array(z.string()).optional().describe('kind:"phrase" ONLY, and REQUIRED there on the SEARCH route — the phrases to look up, e.g. ["best productivity app"].'),
|
|
6014
|
+
categories: z.array(z.string()).optional().describe('kind:"category" ONLY, and REQUIRED there on the SEARCH route — the App Store categories to look up, e.g. ["Utilities"].'),
|
|
6015
|
+
countriesOrRegions: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2, e.g. ["US","GB"].'),
|
|
6016
|
+
queryType: z.enum(['SUGGESTION', 'SEARCH']).optional().describe('phrase/category ONLY (refused on keyword). Default SEARCH. SUGGESTION is measured BROKEN at Apple — ~34s then a server error.'),
|
|
6017
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6018
|
+
},
|
|
6019
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), suggestions: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6020
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6021
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/suggestions', a); return ok(`${d.count} Apple Ads ${d.kind} suggestion(s):\n${d.note}`, d); }));
|
|
6022
|
+
|
|
6023
|
+
server.registerTool('apple_ads_target_cpa_suggestion', {
|
|
6024
|
+
title: 'Apple Ads suggested target CPA',
|
|
6025
|
+
description: 'What Apple thinks an install should cost you. Returns the suggested target CPA for an app — the MAXIMUM tap-install CPI Apple observed across that app’s eligible markets over the last 28 days, together with the market that produced it. Only markets with at least 10 installs in that window qualify, so a brand-new or low-volume app legitimately has no suggestion yet and that is an answer, not a failure. This is the number to put on a MAX_CONVERSIONS campaign, where the campaign-level bid IS the target CPA — it is NOT a per-tap bid and does not belong on a MANUAL_CPT campaign, which refuses a campaign-level bid outright. Scope it with countriesOrRegions to ask about specific markets. Read-only, free, spends nothing.',
|
|
6026
|
+
inputSchema: {
|
|
6027
|
+
promotedObjectId: z.string().describe('REQUIRED — the app’s adamId.'),
|
|
6028
|
+
promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional().describe('Default APPSTORE_APP.'),
|
|
6029
|
+
countriesOrRegions: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2 markets to consider, e.g. ["US","GB","CA"].'),
|
|
6030
|
+
},
|
|
6031
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), count: z.number().optional(), suggestions: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6032
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6033
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/suggestions', { ...a, kind: 'target_cpa' }); return ok(d.note, d); }));
|
|
6034
|
+
|
|
6035
|
+
server.registerTool('list_apple_ads_recommendations', {
|
|
6036
|
+
title: 'List Apple Ads recommendations',
|
|
6037
|
+
description: 'Apple’s own optimisation engine for this account: campaigns it thinks are budget-capped, and campaigns whose target CPA it thinks should move. type:"daily_budget" returns DAILYCAP recommendations, each carrying the current and suggested budget plus the historical installs/taps/spend behind it AND Apple’s projection if you raise it; type:"target_cpa" returns TCPA recommendations, which Apple produces ONLY for campaigns on a Maximize Conversions bid strategy — a MANUAL_CPT campaign will never have one. Defaults to state AVAILABLE because that is the only actionable state: APPLIED and DISMISSED are terminal and can never be re-actioned (pass state:"ALL" to see the history). Apple names only two recommendation categories with endpoints; its enum also mentions keyword and bid categories but this API version publishes no way to query or act on those. Read-only, free — reading changes nothing.',
|
|
6038
|
+
inputSchema: {
|
|
6039
|
+
type: z.enum(['daily_budget', 'target_cpa']).optional().describe('Default daily_budget.'),
|
|
6040
|
+
promotedObjectId: z.string().describe('REQUIRED — the app’s adamId, or an Apple Maps brandId.'),
|
|
6041
|
+
promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional().describe('Default APPSTORE_APP.'),
|
|
6042
|
+
state: z.string().optional().describe('AVAILABLE (default), APPLIED, DISMISSED, DELETE, or "ALL" to skip the filter.'),
|
|
6043
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6044
|
+
},
|
|
6045
|
+
outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), category: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), recommendations: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6046
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6047
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/recommendations', a); return ok(`${d.count} Apple Ads ${d.type} recommendation(s):\n${d.note}`, d); }));
|
|
6048
|
+
|
|
6049
|
+
server.registerTool('apply_apple_ads_recommendation', {
|
|
6050
|
+
title: 'Apply an Apple Ads recommendation',
|
|
6051
|
+
description: 'THIS CHANGES WHAT A LIVE CAMPAIGN MAY SPEND, and it is the only tool in the Apple Ads suggestion/recommendation family that touches money. Applying a daily-budget recommendation raises that campaign’s daily budget to Apple’s suggested amount; applying a target-CPA recommendation moves the CPA its auto-bidder chases. REQUIRES confirm:true, and the refusal you get without it is built from the recommendation READ BACK FROM APPLE — it names the campaign, states current → recommended, and quotes Apple’s own projected spend at the new level — so you are confirming a specific change to a specific campaign rather than confirming that you pressed a button. Pass amount to apply a DIFFERENT number than Apple suggested; omit it to take Apple’s. The campaign’s STATUS IS NOT TOUCHED: this moves a ceiling, it never enables or pauses anything, and it does not make a paused campaign start spending. Only an AVAILABLE recommendation can be applied and applying is TERMINAL. The reply is confirmed by re-reading the CAMPAIGN, not by Apple’s acknowledgement. Free to call; the spending it enables is real.',
|
|
6052
|
+
inputSchema: {
|
|
6053
|
+
type: z.enum(['daily_budget', 'target_cpa']).optional().describe('Default daily_budget.'),
|
|
6054
|
+
id: z.string().describe('REQUIRED — the recommendation id from list_apple_ads_recommendations.'),
|
|
6055
|
+
promotedObjectId: z.string().describe('REQUIRED — the same app or brand id the recommendation was queried for.'),
|
|
6056
|
+
promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional().describe('Default APPSTORE_APP.'),
|
|
6057
|
+
amount: z.string().optional().describe('Override Apple’s suggestion with your own decimal amount, e.g. "500.00". Omit to apply what Apple suggested.'),
|
|
6058
|
+
currency: z.string().optional().describe('ISO 4217; defaults to the ad account’s currency.'),
|
|
6059
|
+
confirm: z.boolean().optional().describe('REQUIRED true. Without it nothing changes and the refusal states the campaign and the exact budget or CPA change.'),
|
|
6060
|
+
},
|
|
6061
|
+
outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), id: z.string().optional(), state: z.string().optional(), campaignId: z.string().nullable().optional(), armsSpend: z.boolean().optional(), verified: z.boolean().optional(), applied: z.any().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
6062
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6063
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/recommendation/apply', a); return ok(`Apple Ads ${d.type} recommendation ${d.id} — ${d.verified ? 'APPLIED' : 'NOT CONFIRMED'}:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
6064
|
+
|
|
6065
|
+
server.registerTool('dismiss_apple_ads_recommendation', {
|
|
6066
|
+
title: 'Dismiss an Apple Ads recommendation',
|
|
6067
|
+
description: 'Decline an Apple Ads recommendation. NOTHING ABOUT THE CAMPAIGN CHANGES — no budget moves, no CPA moves, no status changes — it records that you reviewed the recommendation and said no, and Apple stops offering that particular one (it may generate a new one later). No confirmation is needed and that is deliberate rather than an oversight: dismissing cannot spend money, and gating the safe direction is what teaches people to pass confirm reflexively on the direction that matters. Only an AVAILABLE recommendation can be dismissed; APPLIED and DISMISSED are terminal, and trying to re-action one is refused for free. Free.',
|
|
6068
|
+
inputSchema: {
|
|
6069
|
+
type: z.enum(['daily_budget', 'target_cpa']).optional().describe('Default daily_budget.'),
|
|
6070
|
+
id: z.string().describe('REQUIRED — the recommendation id.'),
|
|
6071
|
+
promotedObjectId: z.string().describe('REQUIRED — the same app or brand id the recommendation was queried for.'),
|
|
6072
|
+
promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional().describe('Default APPSTORE_APP.'),
|
|
6073
|
+
},
|
|
6074
|
+
outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), id: z.string().optional(), state: z.string().optional(), campaignId: z.string().nullable().optional(), spendsNothing: z.boolean().optional(), verified: z.boolean().optional(), dismissed: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
6075
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6076
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/recommendation/dismiss', a); return ok(`${d.summary}\n${d.note || ''}`, d); }));
|
|
6077
|
+
|
|
6078
|
+
server.registerTool('apple_ads_impression_share', {
|
|
6079
|
+
title: 'Apple Ads impression share',
|
|
6080
|
+
description: 'COMPETITIVE INTELLIGENCE: what fraction of the available impressions your app actually captures for each search term, by market and day or week. This is the report that finds where you are being out-bid or out-budgeted — a LOW share on a term with high search popularity means budget, bid or eligibility is capping your reach on a term people are really searching, which is the most actionable row Apple publishes. READ THE ENCODING BEFORE QUOTING A NUMBER: lowImpressionShare and highImpressionShare are TIERED, not continuous. Below 91% the two are equal and read as a single percentage; a row where they diverge to 0.91 and 1.0 means ">90%", not "91 to 100". granularity DAILY covers at most 30 days; WEEKLY_SUN_SAT covers at most 4 weeks and its start date MUST be a Sunday (both are refused for free before any request). reportType FIRST_SLOT (default) is the top ad position only; ALL_SLOTS aggregates every position. Timezone is fixed to UTC by Apple. Read-only, free.',
|
|
6081
|
+
inputSchema: {
|
|
6082
|
+
promotedObjectId: z.string().describe('REQUIRED — the app’s adamId. Apple answers 400 without it.'),
|
|
6083
|
+
countryOrRegion: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2, e.g. ["US"].'),
|
|
6084
|
+
start: z.string().describe('REQUIRED YYYY-MM-DD. Must be a SUNDAY when granularity is WEEKLY_SUN_SAT.'),
|
|
6085
|
+
end: z.string().describe('REQUIRED YYYY-MM-DD.'),
|
|
6086
|
+
granularity: z.enum(['DAILY', 'WEEKLY_SUN_SAT']).optional().describe('Default DAILY (max 30-day window). WEEKLY_SUN_SAT allows at most 4 weeks.'),
|
|
6087
|
+
reportType: z.enum(['FIRST_SLOT', 'ALL_SLOTS']).optional().describe('Default FIRST_SLOT.'),
|
|
6088
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6089
|
+
},
|
|
6090
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), rows: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6091
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6092
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/insights/impression-share', a); return ok(`${d.count} impression-share row(s):\n${d.note}`, d); }));
|
|
6093
|
+
|
|
6094
|
+
server.registerTool('apple_ads_search_term_popularity', {
|
|
6095
|
+
title: 'Apple Ads search term popularity',
|
|
6096
|
+
description: 'The most-searched App Store terms in a genre and market — MARKET-WIDE DEMAND, not your own performance, so it works for finding terms to target before you have spent anything. Cross-read it against apple_ads_impression_share: a term ranking high here where your impression share is low is exactly where budget or bid is leaving installs on the table. genre is a FREE-TEXT App Store genre name (e.g. "PRODUCTIVITY_UTILITIES", "TRAVEL") and Apple publishes no fixed enum, so it is forwarded as given rather than validated against a list that would go stale. granularity WEEKLY_SUN_SAT uses fixed Sunday–Saturday weeks generated Mondays at 07:00 UTC with 65 weeks of retention; MONTHLY uses calendar months refreshed on the 5th UTC with 15 months of retention — an empty result for a very recent window usually means Apple has not generated that period yet rather than that there is no demand. NOTE THE GRANULARITY SETS DIFFER between the two insights reports: DAILY exists on impression share and NOT here. Read-only, free.',
|
|
6097
|
+
inputSchema: {
|
|
6098
|
+
countryOrRegion: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2, e.g. ["US","GB","DE"].'),
|
|
6099
|
+
genre: z.array(z.string()).optional().describe('App Store genre names, free text, e.g. ["PRODUCTIVITY_UTILITIES"].'),
|
|
6100
|
+
start: z.string().describe('REQUIRED YYYY-MM-DD. Must be a SUNDAY when granularity is WEEKLY_SUN_SAT.'),
|
|
6101
|
+
end: z.string().describe('REQUIRED YYYY-MM-DD.'),
|
|
6102
|
+
granularity: z.enum(['WEEKLY_SUN_SAT', 'MONTHLY']).optional().describe('Default WEEKLY_SUN_SAT. DAILY does NOT exist on this report.'),
|
|
6103
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6104
|
+
},
|
|
6105
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), rows: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6106
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6107
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/insights/search-terms', a); return ok(`${d.count} search-term row(s):\n${d.note}`, d); }));
|
|
6108
|
+
|
|
6109
|
+
server.registerTool('apple_ads_change_history', {
|
|
6110
|
+
title: 'Apple Ads change history',
|
|
6111
|
+
description: 'Who changed what on this Apple Ads account, and when. Call it with start and/or end to get one row per TRANSACTION GROUP — a single user action that may have touched several fields, which is what changeCount counts — with the user, the entity and the event type (CREATE / UPDATE / DELETE). Then pass a row’s detailId back as detailId to get the field-by-field before and after. THE TIME BOUND IS MANDATORY: Apple answers 400 without one, so pass start (meaning "since then"), end ("up to then"), or both. Narrow with campaignId, adGroupId, entityType, eventType or userId. Useful for "why did this campaign’s budget change overnight" and for reconstructing what an agent did on the account. On a CREATE event the old values are empty by design; a DELETE is implemented as a record update so its old values ARE populated. Read-only, free.',
|
|
6112
|
+
inputSchema: {
|
|
6113
|
+
detailId: z.string().optional().describe('Fetch field-level detail for ONE change. This is a COMPOSITE id ({transactionId}-{entityType}-{entityId}) that appears on each summary row — not a campaign or ad group id.'),
|
|
6114
|
+
start: z.string().optional().describe('YYYY-MM-DD. Required unless end or an explicit eventTime filter is given.'),
|
|
6115
|
+
end: z.string().optional().describe('YYYY-MM-DD.'),
|
|
6116
|
+
filters: z.array(z.record(z.any())).optional().describe('Extra conditions, e.g. [{"field":"campaignId","operator":"EQUALS","value":"123"}]. Non-time fields accept only EQUALS and IN.'),
|
|
6117
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6118
|
+
},
|
|
6119
|
+
outputSchema: { ok: z.boolean().optional(), mode: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), changes: z.array(z.any()).optional(), details: z.array(z.any()).optional(), detailId: z.string().optional(), note: z.string().optional() },
|
|
6120
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6121
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/change-history', a); return ok(`${d.count} Apple Ads change ${d.mode === 'detail' ? 'field(s)' : 'transaction(s)'}:\n${d.note}`, d); }));
|
|
6122
|
+
|
|
6123
|
+
server.registerTool('list_apple_ads_budget_orders', {
|
|
6124
|
+
title: 'List Apple Ads budget orders',
|
|
6125
|
+
description: 'Budget orders on this Apple Ads account — a spend CEILING SHARED ACROSS CAMPAIGNS, which is how an agency caps a client for a quarter: total spend across every campaign assigned to one cannot exceed its value, however generous the individual daily budgets are. Each row carries the value, the active window, the systemStatus and, when it is blocking, the systemStatusReasons saying why (EXHAUSTED, EXPIRED, CANCELLED). Pass id for one. AN EMPTY LIST IS NORMAL: a budget order is optional, and without one each campaign is capped only by its own dailyBudget. Read-only, free.',
|
|
6126
|
+
inputSchema: {
|
|
6127
|
+
id: z.string().optional().describe('Fetch one budget order, including its invoice details and assigned ad accounts.'),
|
|
6128
|
+
filters: z.array(z.record(z.any())).optional().describe('Query conditions, e.g. [{"field":"name","operator":"CONTAINS","value":"Q3"}].'),
|
|
6129
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6130
|
+
},
|
|
6131
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), budgetOrders: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6132
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6133
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/budget-orders', a); return ok(`${d.count} Apple Ads budget order(s):\n${d.note}`, d); }));
|
|
6134
|
+
|
|
6135
|
+
server.registerTool('create_apple_ads_budget_order', {
|
|
6136
|
+
title: 'Create an Apple Ads budget order',
|
|
6137
|
+
description: 'A BUDGET ORDER NEEDS A LINE OF CREDIT ORGANIZATION. Measured on prod 2026-08-17 against org 19534840 (PAYG): Apple answers 403 “The organization does not have a Line of Credit(LOC). Creating Budget Order is permissible only for LOC Organizations. [FORBIDDEN]” — so on a pay-as-you-go account this whole family is unusable and no request shape fixes it. That is Apple’s account tier, not a Hermoso limit and not something to reconnect over. Create a shared spending ceiling that campaigns can draw from. A BUDGET ORDER SPENDS NOTHING AND CANNOT MAKE ANYTHING SERVE — it is a cap, so creating one can only ever reduce what campaigns may spend, never raise it, which is why it takes no confirmation. Nothing draws from it until you set a campaign’s budgetOrderId. TWO APPLE RULES ARE ENFORCED FOR FREE BEFORE ANYTHING IS SENT, because Apple’s own 400s name the field rather than the rule: startTime must be TOMORROW OR LATER at midnight UTC (today is rejected outright), and exactly ONE ad account may be attached — which is why adAccountId is a single value and defaults to the account this connection is pinned to. Omit endTime for an open-ended budget. Apple requires invoice contact details, so primaryBuyerEmail and billingEmail are required and are never invented. Free.',
|
|
6138
|
+
inputSchema: {
|
|
6139
|
+
name: z.string().describe('REQUIRED.'),
|
|
6140
|
+
startTime: z.string().describe('REQUIRED. Must be TOMORROW or later (midnight UTC) — Apple rejects today.'),
|
|
6141
|
+
endTime: z.string().optional().describe('Omit for an open-ended budget.'),
|
|
6142
|
+
amount: z.string().describe('REQUIRED total budget as a decimal string, e.g. "50000.00".'),
|
|
6143
|
+
currency: z.string().optional().describe('ISO 4217; defaults to the ad account’s currency, which it must match.'),
|
|
6144
|
+
primaryBuyerEmail: z.string().describe('REQUIRED — Apple bills and notifies this address.'),
|
|
6145
|
+
billingEmail: z.string().describe('REQUIRED.'),
|
|
6146
|
+
invoiceName: z.string().optional(), orderNumber: z.string().optional(), clientName: z.string().optional(), primaryBuyerName: z.string().optional(),
|
|
6147
|
+
},
|
|
6148
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
6149
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6150
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/budget-order', a); return ok(`Apple Ads budget order created:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
6151
|
+
|
|
6152
|
+
server.registerTool('update_apple_ads_budget_order', {
|
|
6153
|
+
title: 'Update an Apple Ads budget order',
|
|
6154
|
+
description: 'Change a budget order: its name, window, total value, attached ad account or invoice details. Only the fields you send change; everything omitted keeps its value. RAISING `amount` IS HOW YOU EXTEND A BUDGET APPROACHING EXHAUSTION — an exhausted budget order stops every campaign drawing from it, so this is the tool to reach for when campaigns stop serving with budget left on their own daily caps. AN END DATE ON AN ACTIVE BUDGET ORDER CAN ONLY BE SHORTENED, NEVER EXTENDED; the one exception is openEnded:true, which clears the end date entirely and makes the budget run indefinitely. startTime, if changed, must still be tomorrow or later. Free.',
|
|
6155
|
+
inputSchema: {
|
|
6156
|
+
id: z.string().describe('REQUIRED.'),
|
|
6157
|
+
name: z.string().optional(),
|
|
6158
|
+
startTime: z.string().optional().describe('Must still be TOMORROW or later.'),
|
|
6159
|
+
endTime: z.string().optional().describe('Can only SHORTEN an active budget order’s window.'),
|
|
6160
|
+
openEnded: z.boolean().optional().describe('true clears the end date entirely, making the budget open-ended. This is the only way to extend rather than shorten.'),
|
|
6161
|
+
amount: z.string().optional().describe('New total, e.g. "75000.00". Raise it to extend a budget nearing exhaustion.'),
|
|
6162
|
+
currency: z.string().optional(),
|
|
6163
|
+
adAccountId: z.string().optional().describe('Exactly one — Apple rejects more.'),
|
|
6164
|
+
orderNumber: z.string().optional(), invoiceName: z.string().optional(), clientName: z.string().optional(),
|
|
6165
|
+
primaryBuyerName: z.string().optional(), primaryBuyerEmail: z.string().optional(), billingEmail: z.string().optional(),
|
|
6166
|
+
},
|
|
6167
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), requested: z.any().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
6168
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6169
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/budget-order/update', a); return ok(`Apple Ads budget order ${d.id} updated (verified: ${d.verified}):\n${d.summary}\n${d.note || ''}`, d); }));
|
|
6170
|
+
|
|
6171
|
+
server.registerTool('bulk_apple_ads_keywords', {
|
|
6172
|
+
title: 'Bulk create or update Apple Ads keywords',
|
|
6173
|
+
description: 'Create or update up to 1,000 Apple Ads keywords or negative keywords in ONE call — how you seed a whole campaign structure from apple_ads_keyword_suggestions, or re-bid a list after a performance review, instead of one call per keyword. A single request can span DIFFERENT AD GROUPS. op:"create" takes full keyword rows; op:"update" takes ids and can change ONLY bid and status, because a keyword’s text and matchType are immutable after creation — changing either means deleting it and creating a new one. ALLOW-PARTIAL-SUCCESS MATTERS AND APPLE’S DEFAULT IS ALL-OR-NOTHING: with it unset, one bad item (a duplicate text/matchType, say) rejects the ENTIRE batch and nothing is written; pass allowPartialSuccess:true to let the good ones land and read the per-item errors. The reply is the per-item read-back Apple returns — each row carries the stored id, text, matchType and status, so you can see exactly which items landed rather than trusting a 200. Adding keywords into an ENABLED ad group can serve on the next auction and therefore takes confirm:true, the same guard add_apple_ads_keywords uses. Apple caps 1,000 per call and 5,000 per ad group in total. Free.',
|
|
6174
|
+
inputSchema: {
|
|
6175
|
+
kind: z.enum(['keyword', 'negative_keyword']).optional().describe('Default keyword.'),
|
|
6176
|
+
op: z.enum(['create', 'update']).optional().describe('Default create.'),
|
|
6177
|
+
items: z.array(z.record(z.any())).describe('REQUIRED. create: [{adGroupId,text,matchType,bid?}] for keywords, or [{campaignId|adGroupId,text,matchType}] for negatives. update: [{id,bid?,status?}].'),
|
|
6178
|
+
allowPartialSuccess: z.boolean().optional().describe('true keeps the items that succeeded when others fail. Apple’s default is all-or-nothing.'),
|
|
6179
|
+
currency: z.string().optional().describe('ISO 4217 for bids; defaults to the ad account’s currency.'),
|
|
6180
|
+
confirm: z.boolean().optional().describe('Required only when the target ad group or campaign is already ENABLED, where the new keywords could serve immediately.'),
|
|
6181
|
+
},
|
|
6182
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), op: z.string().optional(), requested: z.number().optional(), succeeded: z.number().optional(), failed: z.number().optional(), verified: z.boolean().optional(), results: z.array(z.any()).optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
6183
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6184
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/keywords/bulk', a); return ok(`${d.summary}\n${d.note || ''}`, d); }));
|
|
6185
|
+
|
|
6186
|
+
server.registerTool('search_apple_ads_geo', {
|
|
6187
|
+
title: 'Search Apple Ads geo locations',
|
|
6188
|
+
description: 'Resolve a place NAME into the geo id an Apple Ads ad group actually targets. THIS IS THE ONLY WAY TO GET THOSE IDS: an ad group’s targeting takes adminAreaIds / localityIds / postalCodeIds / countryIds, it does NOT accept place names, and nothing else in Hermoso produces one — so without this tool those targeting fields cannot be used at all. (A campaign’s own `countries` field is a different thing and takes plain ISO country codes.) supplySource is REQUIRED and is not a formality: APPSTORE has NO PostalCode entity (postal codes are a Maps-level geo), and MAPS has NO Country entity and covers only the US and Canada — both are refused for free with the reason. Pass query with at least 2 characters, or "*" to list everything at an entity level. Rows flagged soft-blocked (low search volume, sparse coverage) are still targetable — Apple returns them by default with their eligibility attached. To go the other way and look UP ids you already have, pass geos:[{id|legacyId, entity}] instead of query. Read-only, free.',
|
|
6189
|
+
inputSchema: {
|
|
6190
|
+
supplySource: z.enum(['APPSTORE', 'MAPS']).describe('REQUIRED. APPSTORE for App Store campaigns, MAPS for Apple Maps. Decides which entity types exist.'),
|
|
6191
|
+
query: z.string().optional().describe('Name to search, minimum 2 characters. "*" returns everything at the entity level.'),
|
|
6192
|
+
entity: z.enum(['Country', 'AdminArea', 'Locality', 'PostalCode']).optional().describe('Narrow to one granularity.'),
|
|
6193
|
+
countryCode: z.string().optional().describe('ISO 3166-1 alpha-2 to scope the search.'),
|
|
6194
|
+
eligible: z.boolean().optional().describe('false excludes soft-blocked geos entirely. Default includes them with eligibility data.'),
|
|
6195
|
+
geos: z.array(z.record(z.any())).optional().describe('Look UP known geos instead of searching by name: [{"id":"12345","entity":"Locality"}] or [{"legacyId":"US|CA|SanFrancisco","entity":"Locality"}]. Exactly one of id or legacyId per entry.'),
|
|
6196
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6197
|
+
},
|
|
6198
|
+
outputSchema: { ok: z.boolean().optional(), mode: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), geos: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6199
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6200
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/geo', a); return ok(`${d.count} geo location(s):\n${d.note}`, d); }));
|
|
6201
|
+
|
|
6202
|
+
server.registerTool('list_apple_ads_supported_languages', {
|
|
6203
|
+
title: 'List Apple Ads supported languages',
|
|
6204
|
+
description: 'Every country and region Apple Ads serves, with the languages supported in each market. Use it to VALIDATE A LOCALE BEFORE setting it on an Apple Maps creative’s localizedText or defaultLocale, or on an ad group — a locale a market does not support makes the creative INVALID after it is built, which surfaces as a serving failure long after the mistake was made. Also the definitive answer to "can we advertise in this market and in what language" when expanding a campaign. Read-only, free.',
|
|
6205
|
+
inputSchema: {
|
|
6206
|
+
filters: z.array(z.record(z.any())).optional().describe('Scope to specific markets, e.g. [{"field":"countryCode","operator":"IN","value":["US","DE"]}].'),
|
|
6207
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
6208
|
+
},
|
|
6209
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), total: z.number().nullable().optional(), markets: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6210
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6211
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/languages', a); return ok(`${d.count} supported market(s):\n${d.note}`, d); }));
|
|
6212
|
+
|
|
6213
|
+
server.registerTool('get_apple_ads_ad_account', {
|
|
6214
|
+
title: 'Get an Apple Ads ad account',
|
|
6215
|
+
description: 'The full record for an Apple Ads ad account — its name, currency, timezone, payment model, productFeatures and the advertiser resources delegated to it. Called with no id it returns the account this connection is pinned to. THE FIELD THAT DECIDES WHAT THIS ACCOUNT CAN DO IS productFeatures, and it is IMMUTABLE: APPSTORE_APP_MANUAL can never run Apple Maps ads and BUSINESS_BRAND_MANUAL can never run App Store ads, so an organization that needs both keeps a separate ad account for each. currency, timeZone and paymentModel are inherited from the organization and are immutable too — which is why a campaign’s amounts must be in this currency and cannot be mixed. Read-only, free.',
|
|
6216
|
+
inputSchema: { id: z.string().optional().describe('Defaults to the ad account this Apple Ads connection resolved.') },
|
|
6217
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), adAccount: z.any().optional(), note: z.string().optional() },
|
|
6218
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6219
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/ad-account', a); return ok(d.note, d); }));
|
|
6220
|
+
|
|
6221
|
+
server.registerTool('list_apple_ads_advertiser_resources', {
|
|
6222
|
+
title: 'List Apple Ads advertiser resources',
|
|
6223
|
+
description: 'The brands and content providers this Apple Ads ORGANIZATION can delegate to an ad account — the ids create_apple_ads_ad_account and update_apple_ads_ad_account need. resourceType CONTENT_PROVIDER returns App Store Connect content providers, each identified by its CPID, which is what links an ad account to App Store advertising; BUSINESS_BRAND returns Apple Maps brands by Brand ID. resourceType is REQUIRED — Apple errors without it. This list is ORGANIZATION-WIDE and is deliberately not scoped to any one ad account, which is why a resource can appear here that no account has claimed yet. Read-only, free.',
|
|
6224
|
+
inputSchema: { resourceType: z.enum(['CONTENT_PROVIDER', 'BUSINESS_BRAND']).describe('REQUIRED. CONTENT_PROVIDER = App Store (CPID); BUSINESS_BRAND = Apple Maps (Brand ID).') },
|
|
6225
|
+
outputSchema: { ok: z.boolean().optional(), resourceType: z.string().optional(), count: z.number().optional(), resources: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6226
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6227
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/advertiser-resources', a); return ok(`${d.count} advertiser resource(s):\n${d.note}`, d); }));
|
|
6228
|
+
|
|
6229
|
+
server.registerTool('create_apple_ads_ad_account', {
|
|
6230
|
+
title: 'Create an Apple Ads ad account',
|
|
6231
|
+
description: 'Create a new ad account under this Apple Ads organization — the container an agency opens per client, or a business opens to run Apple Maps ads alongside App Store ones. AN AD ACCOUNT SPENDS NOTHING: it holds campaigns, and every campaign inside it is still born PAUSED. productFeatures is REQUIRED, is a CHOICE OF ONE, and is IMMUTABLE FOREVER — APPSTORE_APP_MANUAL for App Store advertising or BUSINESS_BRAND_MANUAL for Apple Maps, never both, and an organization needing both keeps two accounts. Delegations must MATCH that choice: an App Store account delegates a CONTENT_PROVIDER (a CPID from list_apple_ads_advertiser_resources), a Maps account delegates a BUSINESS_BRAND — a mismatch is refused for free here, because Apple accepts it and you get an account that cannot advertise anything. currency, timeZone and paymentModel are inherited from the organization and cannot be set. NOTE: Hermoso keeps writing to the ONE ad account this connection is pinned to — reconnect Apple Ads and choose the new account to build inside it. Free.',
|
|
6232
|
+
inputSchema: {
|
|
6233
|
+
name: z.string().describe('REQUIRED.'),
|
|
6234
|
+
productFeatures: z.enum(['APPSTORE_APP_MANUAL', 'BUSINESS_BRAND_MANUAL']).describe('REQUIRED and IMMUTABLE. App Store or Apple Maps, never both.'),
|
|
6235
|
+
delegations: z.array(z.record(z.any())).optional().describe('[{"resourceType":"CONTENT_PROVIDER","resourceId":"<CPID>"}] for App Store, or BUSINESS_BRAND + Brand ID for Maps. Must match productFeatures.'),
|
|
6236
|
+
},
|
|
6237
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
6238
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6239
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/ad-account/create', a); return ok(`Apple Ads ad account created:\n${d.summary}\n${d.note || ''}`, d); }));
|
|
6240
|
+
|
|
6241
|
+
server.registerTool('update_apple_ads_ad_account', {
|
|
6242
|
+
title: 'Update an Apple Ads ad account',
|
|
6243
|
+
description: 'Rename an Apple Ads ad account or change which advertiser resources it delegates — how you link App Store Connect (a CONTENT_PROVIDER / CPID) to an account that cannot yet advertise an app, or attach a brand for Apple Maps. WARNING: DELEGATIONS REPLACE RATHER THAN MERGE. The list you send becomes the account’s ENTIRE delegation set and anything omitted is removed, so to ADD one, read the current set with get_apple_ads_ad_account, append to it, and send the whole list back. productFeatures cannot be changed and Apple ignores it on update — an App Store account can never be converted into an Apple Maps one; create a second ad account instead. currency, timeZone and paymentModel are immutable, inherited from the organization. Free.',
|
|
6244
|
+
inputSchema: {
|
|
6245
|
+
id: z.string().optional().describe('Defaults to the ad account this connection resolved.'),
|
|
6246
|
+
name: z.string().optional(),
|
|
6247
|
+
delegations: z.array(z.record(z.any())).optional().describe('REPLACES the entire set. [{"resourceType":"CONTENT_PROVIDER","resourceId":"<CPID>"}].'),
|
|
6248
|
+
},
|
|
6249
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), requested: z.any().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
6250
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
6251
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/ad-account/update', a); return ok(`Apple Ads ad account ${d.id} updated (verified: ${d.verified}):\n${d.summary}\n${d.note || ''}`, d); }));
|
|
5151
6252
|
// ── X: managing what you built — update, remove, and the reads both depend on (2026-08-05) ────────────────────
|
|
5152
6253
|
// Hermoso could build an X campaign and activate it and then change NOTHING about it, and could remove nothing at
|
|
5153
6254
|
// all: X was the eighth ad platform in the product and the only one with no removal path. Every field offered
|
|
@@ -5339,9 +6440,12 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
5339
6440
|
}));
|
|
5340
6441
|
server.registerTool('create_openai_ads_pixel', {
|
|
5341
6442
|
title: 'Create a ChatGPT Ads pixel',
|
|
5342
|
-
description: 'Create a ChatGPT Ads web pixel — the thing that observes actions on the site. IT RECORDS NOTHING until its snippet is installed on the site, so say that rather than implying tracking is live. A pixel is a measurement definition and cannot spend. The next step is create_openai_ads_conversion_event, which says WHICH observed action counts as a conversion.',
|
|
5343
|
-
inputSchema: {
|
|
5344
|
-
|
|
6443
|
+
description: 'Create a ChatGPT Ads web pixel — the thing that observes actions on the site. IT RECORDS NOTHING until its snippet is installed on the site, so say that rather than implying tracking is live. A pixel is a measurement definition and cannot spend. The next step is create_openai_ads_conversion_event, which says WHICH observed action counts as a conversion. AUTOMATIC ADVANCED MATCHING IS ON BY DEFAULT — that is OpenAI’s own default for pixels created through the Ads API since 2026-08-17, and it means the Pixel COLLECTS AND HASHES CUSTOMER INFORMATION (email, phone) from the page. Tell the user that in plain words, and pass automaticAdvancedMatching:false if they want it off. The reply reports what OpenAI actually stored: there is no endpoint to read a pixel back afterwards, so that one reply is the only record you will ever get.',
|
|
6444
|
+
inputSchema: {
|
|
6445
|
+
name: z.string().describe('a name for the pixel'),
|
|
6446
|
+
automaticAdvancedMatching: z.boolean().optional().describe('default true (OpenAI’s own default). false stops the Pixel collecting and hashing customer information from the page.'),
|
|
6447
|
+
},
|
|
6448
|
+
outputSchema: { pixelId: z.string().optional(), name: z.string().optional(), pixelSnippetId: z.string().optional(), automaticAdvancedMatching: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
5345
6449
|
annotations: { openWorldHint: true },
|
|
5346
6450
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/pixel', a); return ok(d.note, d); }));
|
|
5347
6451
|
server.registerTool('create_openai_ads_conversion_event', {
|
|
@@ -5470,14 +6574,61 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
5470
6574
|
const d = await apiPost('/api/openai-ads/budget', a);
|
|
5471
6575
|
return ok(d.note, d);
|
|
5472
6576
|
}));
|
|
6577
|
+
server.registerTool('openai_ads_conversions', {
|
|
6578
|
+
title: 'ChatGPT Ads attributed conversions',
|
|
6579
|
+
description: 'ATTRIBUTED CONVERSIONS for ChatGPT Ads — the number the whole pixel + conversion-event setup exists to produce, and the one openai_ads_report structurally cannot give you (that endpoint family carries impressions, clicks, spend, CTR, CPC and CPM and no conversions at all). Pass entityIds — the campaign / ad group / ad ids to report on — with a matching level; the default window is the last 30 days. NEVER ADD conversions AND viewThroughConversions TOGETHER: OpenAI states that "conversions is always equal to click_through_conversions" and that view-through is "a separate, supplemental metric" NOT added to that total, and that view-through is reporting-only because CPA, post-click CVR, bidding, billing and conversion optimization all remain click-through-based. NO ROWS means no attributed conversion was recorded, not that data is missing — say exactly that, and check that an event setting exists (list_openai_ads_conversion_events) and that its pixel snippet is actually live on the site. Read-only, free.',
|
|
6580
|
+
inputSchema: {
|
|
6581
|
+
level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('inferred from which id you pass — default ad_account'),
|
|
6582
|
+
entityIds: z.array(z.string()).optional().describe('the ids to report on — REQUIRED (ChatGPT Ads has no "everything" mode)'),
|
|
6583
|
+
campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
|
|
6584
|
+
since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
|
|
6585
|
+
},
|
|
6586
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), note: z.string().optional() },
|
|
6587
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6588
|
+
}, wrap(async (a) => {
|
|
6589
|
+
const d = await apiPost('/api/openai-ads/conversions', a);
|
|
6590
|
+
return ok(`${d.note}\n${(d.rows || []).map(r => `• ${r.entityId} — ${r.conversions ?? 0} conversion(s), ${r.viewThroughConversions ?? 0} view-through`).join('\n')}`, d);
|
|
6591
|
+
}));
|
|
6592
|
+
server.registerTool('preview_openai_ads_ad', {
|
|
6593
|
+
title: 'Preview a ChatGPT Ads ad',
|
|
6594
|
+
description: 'Render a real preview of an existing ChatGPT Ads ad, so a human can LOOK at what will run instead of reading a list of ids back. Returns a hosted preview URL. THE LINK EXPIRES 24 HOURS AFTER IT IS CREATED — state that whenever you hand it to anyone, and generate a fresh one rather than re-sending an old one, because a dead link given to a client is worse than no link. Changes nothing about the ad; free.',
|
|
6595
|
+
inputSchema: { adId: z.string().describe('the ad to preview — list_openai_ads_campaigns with an adGroupId lists them') },
|
|
6596
|
+
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), count: z.number().optional(), previews: z.array(z.any()).optional(), expiresInHours: z.number().optional(), note: z.string().optional() },
|
|
6597
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6598
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/preview', a); return ok(d.note, d); }));
|
|
6599
|
+
server.registerTool('create_openai_ads_conversion_api_key', {
|
|
6600
|
+
title: 'Create a ChatGPT Ads Conversions API key',
|
|
6601
|
+
description: 'Create a ChatGPT Ads Conversions API key — the credential a SERVER uses to send conversion events, which is the only way a conversion that happens off the page (an offline sale, a webhook, a mobile backend) can be counted at all. PERMANENT AND UNREPEATABLE, and both halves of that are why it is confirm-gated even though a key cannot spend: ChatGPT Ads publishes NO way to list, rotate or delete a key, so it exists on the ad account forever and Hermoso cannot clean it up; and OpenAI returns the secret EXACTLY ONCE. Tell the user both facts, get an explicit yes, then call with confirm:true — and tell them to store it in a server-side secret manager and never place it in browser code, client-visible environment variables, logs or source control. If OpenAI answers that key creation is not enabled for this ad account, that is an account-enablement answer from OpenAI (they say to contact your partner representative) — the connection is fine and nothing is broken on Hermoso’s side.',
|
|
6602
|
+
inputSchema: {
|
|
6603
|
+
name: z.string().describe('a descriptive name, 3–1000 characters — it can never be listed or renamed afterwards'),
|
|
6604
|
+
confirm: z.boolean().optional().describe('REQUIRED true — the key is permanent and its secret is shown once'),
|
|
6605
|
+
},
|
|
6606
|
+
outputSchema: { ok: z.boolean().optional(), name: z.string().optional(), apiKey: z.string().optional(), note: z.string().optional() },
|
|
6607
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
6608
|
+
}, wrap(async (a) => {
|
|
6609
|
+
const d = await apiPost('/api/openai-ads/conversion-api-key', a);
|
|
6610
|
+
return ok(`${d.note}${d.apiKey ? `\n\nKEY (store it now — it cannot be retrieved again): ${d.apiKey}` : ''}`, d);
|
|
6611
|
+
}));
|
|
6612
|
+
server.registerTool('update_openai_ads_account', {
|
|
6613
|
+
title: 'Update the ChatGPT Ads account brand (name / URL / favicon)',
|
|
6614
|
+
description: 'Update the ChatGPT Ads AD ACCOUNT’s brand metadata — its display name, its primary destination URL and/or its favicon — and start a new brand review. THIS IS THE GATE IN FRONT OF EVERY IMPRESSION: OpenAI states that an account whose review status is not "approved" CANNOT SERVE ADS, so this is how an account stuck in review gets fixed without leaving Hermoso. Pass any of name, url, faviconUrl (a public image URL — or just the brand’s home page, which OpenAI will resolve a favicon from; minimum 128×128) or faviconFileId. A brand change RESTARTS the review, so re-read the account (list_openai_ads_campaigns returns it) until it says approved before promising that anything will run. If OpenAI answers that programmatic brand updates are not enabled for this ad account, that is their account setting and not a Hermoso failure — they say to contact your partner representative.',
|
|
6615
|
+
inputSchema: {
|
|
6616
|
+
name: z.string().optional().describe('new account display name'),
|
|
6617
|
+
url: z.string().optional().describe('the account’s primary destination — a public http(s) URL. OpenAI accepts this field even though their published parameter table omits it.'),
|
|
6618
|
+
faviconUrl: z.string().optional().describe('public image URL (or the brand home page) to upload and assign as the account favicon — minimum 128×128'),
|
|
6619
|
+
faviconFileId: z.string().optional().describe('an already-uploaded favicon file id, if you have one'),
|
|
6620
|
+
},
|
|
6621
|
+
outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).optional(), faviconFileId: z.string().nullable().optional(), account: z.any().optional(), note: z.string().optional() },
|
|
6622
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
6623
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/account', a); return ok(d.note, d); }));
|
|
5473
6624
|
server.registerTool('set_openai_ads_status', {
|
|
5474
|
-
title: 'Activate, pause or archive a ChatGPT Ads campaign / ad group / ad',
|
|
5475
|
-
description: 'Turn a ChatGPT Ads campaign, AD GROUP or AD on (active), off (paused), or ARCHIVE it. Pass level:"campaign" + campaignId, level:"adGroup" + adGroupId, or level:"ad" + adId. ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the object and its budget, get an explicit yes, then call with status:"active" and confirm:true. Pausing is always safe and stops all spend. ARCHIVING IS IRREVERSIBLE: it is this platform’s only teardown (there is no delete and no un-archive, and OpenAI’s own guidance is "only archive objects you have no further use for"), so it ALSO requires confirm:true — prefer pausing unless the user is certain. Remember an ad only serves when the ad, its ad group AND its campaign are all active and the ad has passed OpenAI’s review. The resulting status is READ BACK from OpenAI before you are told it took.',
|
|
6625
|
+
title: 'Activate, pause or archive a ChatGPT Ads account / campaign / ad group / ad',
|
|
6626
|
+
description: 'Turn a ChatGPT Ads AD ACCOUNT, campaign, AD GROUP or AD on (active), off (paused), or ARCHIVE it. Pass level:"account" (NO id — the connection IS the account), level:"campaign" + campaignId, level:"adGroup" + adGroupId, or level:"ad" + adId. LEVEL "account" IS THE EMERGENCY STOP AND BEHAVES DIFFERENTLY FROM EVERY OTHER LEVEL: BOTH directions take confirm:true, because pausing it stops ALL ChatGPT Ads delivery for the brand at once and activating it un-suppresses EVERY active campaign underneath — Hermoso cannot tell you in advance which of them will start spending. An account cannot be archived. ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the object and its budget, get an explicit yes, then call with status:"active" and confirm:true. Pausing is always safe and stops all spend. ARCHIVING IS IRREVERSIBLE: it is this platform’s only teardown (there is no delete and no un-archive, and OpenAI’s own guidance is "only archive objects you have no further use for"), so it ALSO requires confirm:true — prefer pausing unless the user is certain. Remember an ad only serves when the ad, its ad group AND its campaign are all active and the ad has passed OpenAI’s review. The resulting status is READ BACK from OpenAI before you are told it took.',
|
|
5476
6627
|
inputSchema: {
|
|
5477
|
-
level: z.enum(['campaign', 'adGroup', 'ad']).optional().describe('inferred from which id you pass — default campaign'),
|
|
6628
|
+
level: z.enum(['account', 'campaign', 'adGroup', 'ad']).optional().describe('inferred from which id you pass — default campaign. "account" is the whole ad account and takes no id.'),
|
|
5478
6629
|
campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
|
|
5479
|
-
status: z.enum(['active', 'paused', 'archived']).describe('active = start spending; paused = stop; archived = permanent'),
|
|
5480
|
-
confirm: z.boolean().optional().describe('REQUIRED true to activate (real spend)
|
|
6630
|
+
status: z.enum(['active', 'paused', 'archived']).describe('active = start spending; paused = stop; archived = permanent (not available at level "account")'),
|
|
6631
|
+
confirm: z.boolean().optional().describe('REQUIRED true to activate (real spend), to archive (irreversible), or for EITHER direction at level "account"'),
|
|
5481
6632
|
},
|
|
5482
6633
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), object: z.any().optional(), note: z.string().optional() },
|
|
5483
6634
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -5763,7 +6914,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
5763
6914
|
}));
|
|
5764
6915
|
server.registerTool('reddit_ads_report', {
|
|
5765
6916
|
title: 'Reddit ads performance report',
|
|
5766
|
-
description: 'Performance for a Reddit ad account — impressions, clicks, spend, CTR, CPC, eCPM, reach and any of Reddit’s ~450 metric fields, optionally broken down by campaign, ad group, ad, date, hour, community, country, gender, interest, keyword, placement and more. Money comes back in WHOLE UNITS of the ad account’s currency (Reddit reports micro-currency; the conversion is done for you) — and that currency is not necessarily dollars, so check the account. Reddit only accepts HOURLY window boundaries; plain YYYY-MM-DD dates are accepted here and snapped for you. A report with ZERO rows genuinely means nothing delivered in that window — say exactly that and never present zeros as measured performance. Read-only, free.',
|
|
6917
|
+
description: 'Performance for a Reddit ad account — impressions, clicks, spend, CTR, CPC, eCPM, reach and any of Reddit’s ~450 metric fields, optionally broken down by campaign, ad group, ad, date, hour, community, country, gender, interest, keyword, placement and more. Money comes back in WHOLE UNITS of the ad account’s currency (Reddit reports micro-currency; the conversion is done for you) — and that currency is not necessarily dollars, so check the account. Reddit only accepts HOURLY window boundaries; plain YYYY-MM-DD dates are accepted here and snapped for you. A report with ZERO rows genuinely means nothing delivered in that window — say exactly that and never present zeros as measured performance. ONE DATED LIMIT WORTH PLANNING AROUND: from 2026-10-30 Reddit caps the HOUR breakdown at windows of 7 DAYS OR FEWER and answers 400 beyond that. Hermoso warns before that date and refuses after it rather than relaying a bare vendor 400 — for a longer stretch, run one report per <=7-day window, or use the DATE breakdown for a daily series over the whole period. Read-only, free.',
|
|
5767
6918
|
inputSchema: {
|
|
5768
6919
|
adAccountId: z.string().optional(),
|
|
5769
6920
|
since: z.string().optional().describe('YYYY-MM-DD or full ISO timestamp, default 30 days ago'),
|
|
@@ -6254,7 +7405,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6254
7405
|
}));
|
|
6255
7406
|
server.registerTool('list_reddit_ads_lead_forms', {
|
|
6256
7407
|
title: 'List Reddit lead generation forms',
|
|
6257
|
-
description: 'List the lead generation forms on a Reddit ad account, with the fields each one asks for. Reddit publishes NO endpoint for reading the leads a form has collected — the user downloads those from Reddit’s Ads Manager. Say that plainly if asked for the leads themselves; do not imply they can be fetched. Read-only, free.',
|
|
7408
|
+
description: 'Listing and reading forms KEEPS WORKING past the 2026-09-21 sunset — Reddit deliberately leaves GET/LIST up so advertisers can retain their records — so use this to EXPORT what exists before the deadline. Reddit exposes no API for the LEADS a form collected; those are downloaded from Ads Manager, and existing onsite-form ads are paused on 2026-09-30. List the lead generation forms on a Reddit ad account, with the fields each one asks for. Reddit publishes NO endpoint for reading the leads a form has collected — the user downloads those from Reddit’s Ads Manager. Say that plainly if asked for the leads themselves; do not imply they can be fetched. Read-only, free.',
|
|
6258
7409
|
inputSchema: { adAccountId: z.string().optional(), limit: z.number().optional().describe('default 50, max 100') },
|
|
6259
7410
|
outputSchema: { adAccountId: z.string().optional(), count: z.number().optional(), forms: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6260
7411
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
@@ -6264,7 +7415,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6264
7415
|
}));
|
|
6265
7416
|
server.registerTool('create_reddit_ads_lead_form', {
|
|
6266
7417
|
title: 'Create a Reddit lead generation form',
|
|
6267
|
-
description: 'Create a lead generation form — the in-feed form redditors fill in without leaving Reddit, used by LEAD_GENERATION campaigns. Reddit requires a link to a real privacy policy on every form. Ask for the FEWEST fields that make a lead useful: every extra question costs completions. KNOW THE LIMIT BEFORE YOU PROMISE ANYTHING: Reddit exposes no way to attach a form to an ad through the API — there is no lead-form field on an ad, an ad group or a post — so the user picks this form in Reddit’s Ads Manager when building the creative, and downloads its leads from there. There is also no update and no delete, so get the questions right the first time. Free.',
|
|
7418
|
+
description: 'REDDIT IS SUNSETTING THIS ON 2026-09-21 AND IT CANNOT BE REPLACED LIKE FOR LIKE. Reddit is removing onsite lead forms entirely — the API sunsets 2026-09-21 (their migration guide says 2026-09-30 for the same change; Hermoso stops creating on the earlier date so a form is never built that turns out not to work) and Reddit PAUSES every ad still using an onsite form on 2026-09-30. After that this tool refuses and says so. DO NOT BUILD A NEW FUNNEL ON IT: Reddit’s replacement is an OFFLINE form on the advertiser’s own landing page, measured with the Reddit Pixel or the Conversions API — list_reddit_ads_pixels and send_reddit_ads_conversions, both of which work here today. Create a lead generation form — the in-feed form redditors fill in without leaving Reddit, used by LEAD_GENERATION campaigns. Reddit requires a link to a real privacy policy on every form. Ask for the FEWEST fields that make a lead useful: every extra question costs completions. KNOW THE LIMIT BEFORE YOU PROMISE ANYTHING: Reddit exposes no way to attach a form to an ad through the API — there is no lead-form field on an ad, an ad group or a post — so the user picks this form in Reddit’s Ads Manager when building the creative, and downloads its leads from there. There is also no update and no delete, so get the questions right the first time. Free.',
|
|
6268
7419
|
inputSchema: {
|
|
6269
7420
|
adAccountId: z.string().optional(),
|
|
6270
7421
|
name: z.string().describe('internal name — redditors do not see it'),
|
|
@@ -6338,6 +7489,10 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6338
7489
|
const body = read.length ? read.map(r => `${r.id} → ${r.status}`).join(', ') : '(TikTok returned no rows on the read-back)';
|
|
6339
7490
|
return `TikTok ${d.level} — requested ${d.requested}. Read back from TikTok: ${body}.${d.verified ? '' : ` ⚠ ${d.note || 'The read-back did not return every id, so this is what TikTok ACCEPTED, not what it is confirmed to have stored.'}`}`;
|
|
6340
7491
|
};
|
|
7492
|
+
// TikTok's own web conversion enum ("Conversion events \u25b8 Pixel events"). Twelve values: the other six web
|
|
7493
|
+
// events TikTok publishes are data-connection only and carry no optimization_event name, so they cannot be
|
|
7494
|
+
// optimised toward. Kept beside the tools that name it so the schema copy and the server validator agree.
|
|
7495
|
+
const TIKTOK_WEB_OPT_EVENTS = ['ON_WEB_DETAIL', 'ON_WEB_SEARCH', 'ON_WEB_ADD_TO_WISHLIST', 'ON_WEB_CART', 'INITIATE_ORDER', 'ADD_BILLING', 'SHOPPING', 'CONSULT', 'DOWNLOAD_START', 'FORM', 'ON_WEB_REGISTER', 'ON_WEB_SUBSCRIBE'];
|
|
6341
7496
|
server.registerTool('list_tiktok_ads_accounts', {
|
|
6342
7497
|
title: 'List TikTok advertiser accounts',
|
|
6343
7498
|
description: 'List the TikTok ADVERTISER accounts this brand can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Settings ▸ Connectors ▸ TikTok Ads) — that is a DIFFERENT connection from the TikTok posting connector behind post_to_tiktok, so a brand that publishes to TikTok every day may still have nothing here.',
|
|
@@ -6380,6 +7535,76 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6380
7535
|
if (!list.length) return ok('This TikTok advertiser account has NO identity attached, so no ad can be created on it yet — a TikTok ad must post as a specific TikTok account and TikTok provides no default. The user has to authorise one in TikTok Ads Manager first. Do not guess an identityId.', d);
|
|
6381
7536
|
return ok(`${list.length} TikTok identit${list.length === 1 ? 'y' : 'ies'} — let the USER pick which one the ad posts as, then pass BOTH fields to create_tiktok_ads_ad:\n${list.map(x => `• ${x.name || '(unnamed)'} — identityId ${x.identityId}, identityType ${x.identityType}`).join('\n')}`, d);
|
|
6382
7537
|
}));
|
|
7538
|
+
// ══ SPARK ADS — the TikTok twin of Meta's object_story_id / Pinterest's pin_id ═══════════════════════════════
|
|
7539
|
+
// Two lists, because they answer different questions and are NOT interchangeable: identity posts are what an
|
|
7540
|
+
// account YOU control has published; spark posts are what somebody has AUTHORIZED to this ad account by code.
|
|
7541
|
+
server.registerTool('list_tiktok_ads_identity_posts', {
|
|
7542
|
+
title: 'List the organic TikTok posts under an identity',
|
|
7543
|
+
description: 'List the real ORGANIC TikTok posts published by one identity — the account itself, not the ad library. THIS IS WHERE A SPARK AD STARTS: pass an itemId from here to create_tiktok_ads_ad as tiktokItemId and the ad IS that post, with its own comments, likes and shares, running under the account that made it. Use this for an account the brand controls or has been granted (identityType TT_USER or BC_AUTH_TT); for a CREATOR’s post they authorised with a code, use list_tiktok_ads_spark_posts instead. identityId and identityType both come from list_tiktok_ads_identities, and a BC_AUTH_TT identity ALSO needs the identityAuthorizedBcId reported next to it — TikTok refuses the call without it. TikTok defaults to VIDEO posts only, so pass itemType:"CAROUSEL" to see photo posts; an account that posts only photos otherwise looks empty. Paginated by CURSOR, not page: pass back the cursor while hasMore is true. posterUrl and previewUrl EXPIRE AFTER AN HOUR — show them, never store them. Read-only, free.',
|
|
7544
|
+
inputSchema: {
|
|
7545
|
+
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
7546
|
+
identityId: z.string().describe('REQUIRED — from list_tiktok_ads_identities'),
|
|
7547
|
+
identityType: z.string().describe('REQUIRED — the matching type from list_tiktok_ads_identities. Only AUTH_CODE, TT_USER and BC_AUTH_TT can front a Spark Ad; CUSTOMIZED_USER cannot and is refused here.'),
|
|
7548
|
+
identityAuthorizedBcId: z.string().optional().describe('REQUIRED when identityType is BC_AUTH_TT — reported next to the id by list_tiktok_ads_identities'),
|
|
7549
|
+
itemType: z.enum(['VIDEO', 'CAROUSEL']).optional().describe('TikTok defaults to VIDEO only — pass CAROUSEL for photo posts'),
|
|
7550
|
+
excludeAdsOnly: z.boolean().optional().describe('true hides ads-only posts (ones published as ads and hidden from the profile), returning at most the 500 most recent organic posts'),
|
|
7551
|
+
cursor: z.string().optional().describe('from the previous call, while hasMore is true'),
|
|
7552
|
+
count: z.number().optional().describe('1-20; TikTok ignores anything larger and returns 20'),
|
|
7553
|
+
},
|
|
7554
|
+
outputSchema: { advertiserId: z.string().optional(), identityId: z.string().optional(), identityType: z.string().optional(), posts: z.array(z.any()).optional(), cursor: z.string().nullable().optional(), hasMore: z.boolean().optional(), note: z.string().nullable().optional() },
|
|
7555
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
7556
|
+
}, wrap(async (a) => {
|
|
7557
|
+
const d = await apiGet('/api/tiktok-ads/identity-posts', a);
|
|
7558
|
+
const list = d.posts || [];
|
|
7559
|
+
if (!list.length) return ok(`No posts came back for identity ${d.identityId}.${d.note ? ' ' + d.note : ''} That is an empty RESULT, not a broken connection — the account may simply have no posts of that type.`, d);
|
|
7560
|
+
return ok(`${list.length} organic TikTok post(s) under identity ${d.identityId}${d.hasMore ? ' (more available — pass cursor back)' : ''}. Pass an itemId as tiktokItemId to create_tiktok_ads_ad to run it as a Spark Ad:\n${list.map(p => `• ${p.itemId} — ${p.itemType}${p.durationSeconds ? `, ${p.durationSeconds}s` : ''} — ${(p.text || '(no caption)').slice(0, 80)}`).join('\n')}${d.note ? `\n${d.note}` : ''}`, d);
|
|
7561
|
+
}));
|
|
7562
|
+
server.registerTool('list_tiktok_ads_spark_posts', {
|
|
7563
|
+
title: 'List TikTok posts authorised to this ad account',
|
|
7564
|
+
description: 'List the TikTok posts a post OWNER has authorised this ad account to promote — the creator-collaboration half of Spark Ads. These are posts from accounts the brand does NOT control: the owner generates an authorization code in the TikTok app and authorize_tiktok_ads_spark_post applies it, after which the post appears here with the itemId create_tiktok_ads_ad needs as tiktokItemId. AN EMPTY LIST IS A NORMAL STARTING STATE, not a failure — it means nobody has authorised a post yet, and the fix is to ask a creator for a code, not to retry. Each row carries the authorization WINDOW (authStartTime / authEndTime): a Spark Ad cannot outlive its authorization, so check the end time before building a campaign around one. Read-only, free.',
|
|
7565
|
+
inputSchema: {
|
|
7566
|
+
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
7567
|
+
itemTypes: z.array(z.enum(['VIDEO', 'CAROUSEL'])).optional().describe('defaults to BOTH here (unlike list_tiktok_ads_identity_posts, which defaults to VIDEO alone)'),
|
|
7568
|
+
keyword: z.string().optional().describe('free text (fuzzy, ≤500 chars) or an exact TikTok post id (≥19 digits)'),
|
|
7569
|
+
page: z.number().optional(),
|
|
7570
|
+
pageSize: z.number().optional().describe('1-50, default 20'),
|
|
7571
|
+
},
|
|
7572
|
+
outputSchema: { advertiserId: z.string().optional(), posts: z.array(z.any()).optional(), page: z.number().nullable().optional(), pageSize: z.number().nullable().optional(), totalNumber: z.number().nullable().optional(), totalPage: z.number().nullable().optional(), note: z.string().nullable().optional() },
|
|
7573
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
7574
|
+
}, wrap(async (a) => {
|
|
7575
|
+
const d = await apiGet('/api/tiktok-ads/spark-posts', a);
|
|
7576
|
+
const list = d.posts || [];
|
|
7577
|
+
if (!list.length) return ok(d.note || 'No TikTok posts are authorised to this ad account yet.', d);
|
|
7578
|
+
return ok(`${list.length} authorised Spark Ads post(s). Pass an itemId as tiktokItemId to create_tiktok_ads_ad:\n${list.map(p => `• ${p.itemId} — ${p.itemType}${p.tiktokName ? ` by ${p.tiktokName}` : ''}${p.authEndTime ? `, authorised until ${p.authEndTime}` : ''} — ${(p.text || '(no caption)').slice(0, 70)}`).join('\n')}`, d);
|
|
7579
|
+
}));
|
|
7580
|
+
server.registerTool('authorize_tiktok_ads_spark_post', {
|
|
7581
|
+
title: 'Apply a TikTok Spark Ads authorization code',
|
|
7582
|
+
description: 'Apply an authorization code so this ad account may promote somebody else’s TikTok post. THE CODE CANNOT BE MINTED FROM THE API — only the post’s owner can generate one, in the TikTok app: open the post → ⋯ → Ad settings → toggle Ad authorization → choose a duration → copy the code. Ask them for it; there is no way around this and no endpoint that creates one. Applying a code SPENDS NOTHING and creates nothing that can serve — it only grants permission — so it is deliberately not confirm-gated. If the post is a DUET or STITCH of another post, or mentions one, TikTok requires the ORIGINAL post owner’s code too: pass it as originalPostAuthCode or the call is refused. THE ANSWER IS THE READ-BACK: the reply lists the posts now authorised, because TikTok’s own response body is empty and a bare 200 proves nothing.',
|
|
7583
|
+
inputSchema: {
|
|
7584
|
+
advertiserId: z.string().optional(),
|
|
7585
|
+
authCode: z.string().describe('the code the post owner generated in the TikTok app. Paste it verbatim — Hermoso handles the + / %2B escaping TikTok warns about.'),
|
|
7586
|
+
originalPostAuthCode: z.string().optional().describe('REQUIRED when the post is a duet/stitch of, or mentions, another post — the ORIGINAL post owner’s code'),
|
|
7587
|
+
},
|
|
7588
|
+
outputSchema: { advertiserId: z.string().optional(), applied: z.boolean().optional(), posts: z.array(z.any()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
7589
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
7590
|
+
}, wrap(async (a) => {
|
|
7591
|
+
const d = await apiPost('/api/tiktok-ads/spark-authorize', a);
|
|
7592
|
+
return ok(`${d.note}${(d.posts || []).length ? `\n${d.posts.map(p => `• ${p.itemId} — ${(p.text || '(no caption)').slice(0, 70)}`).join('\n')}` : ''}`, d);
|
|
7593
|
+
}));
|
|
7594
|
+
server.registerTool('unbind_tiktok_ads_spark_post', {
|
|
7595
|
+
title: 'Remove a post from the authorised Spark Ads list',
|
|
7596
|
+
description: 'Remove a TikTok post from this ad account’s authorised Spark Ads list — the cleanup for an authorization that has expired or been revoked. IT IS NOT REVERSIBLE FROM OUR SIDE: re-authorising needs a FRESH code from the post’s owner, who is usually not the person making this call, so getting it wrong costs a conversation with a creator rather than a second API call — which is why it is confirm-gated even though it spends nothing. Ads already built on the post keep running; what is lost is the ability to build a NEW one. Calling it without confirm CHANGES NOTHING and returns the sentence describing exactly what would happen. THE READ-BACK INVERTS: the proof is the post’s ABSENCE from the authorised list afterwards, and an id that is still there is reported as not confirmed rather than done.',
|
|
7597
|
+
inputSchema: {
|
|
7598
|
+
advertiserId: z.string().optional(),
|
|
7599
|
+
itemId: z.string().describe('from list_tiktok_ads_spark_posts'),
|
|
7600
|
+
confirm: z.boolean().optional().describe('REQUIRED true — call without it first to see exactly what would change'),
|
|
7601
|
+
},
|
|
7602
|
+
outputSchema: { advertiserId: z.string().optional(), itemId: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
7603
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
7604
|
+
}, wrap(async (a) => {
|
|
7605
|
+
const d = await apiPost('/api/tiktok-ads/spark-unbind', a);
|
|
7606
|
+
return ok(d.note, d);
|
|
7607
|
+
}));
|
|
6383
7608
|
server.registerTool('search_tiktok_ads_targeting', {
|
|
6384
7609
|
title: 'Resolve TikTok locations / interests / hashtags / languages',
|
|
6385
7610
|
description: 'Look up the exact ids TikTok ad-group targeting expects, so none of them has to be invented. kind:"location" resolves TikTok’s targetable regions — an ad group CANNOT be created without location ids, TikTok refuses it in its own words ("‘location_ids’ or ‘zipcode_ids’ must be specified"). kind:"interest" resolves the interest categories and kind:"interest_keyword" the additional interest KEYWORDS (both attach to an ad group). kind:"hashtag" resolves real targeting HASHTAGS — until 2026-08-11 this kind pointed at TikTok’s interest-keyword endpoint and quietly returned interest categories instead; note that hashtag ids feed TikTok’s actions[] field, which Hermoso does not send yet, so treat hashtag results as RESEARCH rather than targeting you can apply. kind:"language" the language codes. A made-up id either fails the create or, worse, targets somebody else and spends money silently, so always resolve here first and never guess. Read-only, free.',
|
|
@@ -6405,16 +7630,24 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6405
7630
|
endDate: z.string().optional().describe('YYYY-MM-DD'),
|
|
6406
7631
|
metrics: z.array(z.string()).optional().describe('TikTok metric names — default ["spend","impressions","clicks","ctr","cpc","cpm","conversion"]'),
|
|
6407
7632
|
dimensions: z.array(z.string()).optional().describe('TikTok dimensions to group by — default the level’s own id, e.g. ["campaign_id"]. Add "stat_time_day" for a daily breakdown. On an AUDIENCE report these are audience attributes instead, e.g. ["age"].'),
|
|
7633
|
+
page: z.number().optional().describe('1-based page (default 1). A report LONGER than one page comes back with hasMore:true and a truncationNote — totals computed from one page are WRONG.'),
|
|
7634
|
+
pageSize: z.number().optional().describe('rows per page, 1–1000 (default 100). Adding "stat_time_day" overflows a page fast: 30 days x 4 campaigns is already 120 rows.'),
|
|
7635
|
+
filtering: z.array(z.record(z.any())).optional().describe('TikTok’s own filter array, e.g. [{"field_name":"campaign_ids","filter_type":"IN","filter_value":"[\'123\']"}]. Narrows the report AT TIKTOK rather than after truncation.'),
|
|
6408
7636
|
},
|
|
6409
|
-
outputSchema: { advertiserId: z.string().optional(), level: z.string().optional(), reportType: z.string().optional(), rows: z.array(z.any()).optional(), metrics: z.array(z.string()).optional(), dimensions: z.array(z.string()).optional() },
|
|
7637
|
+
outputSchema: { advertiserId: z.string().optional(), level: z.string().optional(), reportType: z.string().optional(), rows: z.array(z.any()).optional(), metrics: z.array(z.string()).optional(), dimensions: z.array(z.string()).optional(), page: z.number().optional(), pageSize: z.number().optional(), totalRows: z.number().nullable().optional(), totalPages: z.number().nullable().optional(), hasMore: z.boolean().optional(), truncationNote: z.string().optional() },
|
|
6410
7638
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6411
7639
|
}, wrap(async (a) => {
|
|
6412
7640
|
// The route takes metrics/dimensions as CSV on the query string; arrays are what an agent naturally holds, so
|
|
6413
7641
|
// join here rather than making every caller remember the wire shape.
|
|
6414
|
-
const d = await apiGet('/api/tiktok-ads/report', { ...a, metrics: a.metrics ? a.metrics.join(',') : undefined, dimensions: a.dimensions ? a.dimensions.join(',') : undefined });
|
|
7642
|
+
const d = await apiGet('/api/tiktok-ads/report', { ...a, metrics: a.metrics ? a.metrics.join(',') : undefined, dimensions: a.dimensions ? a.dimensions.join(',') : undefined, filtering: a.filtering ? JSON.stringify(a.filtering) : undefined });
|
|
6415
7643
|
const rows = d.rows || [];
|
|
6416
7644
|
if (!rows.length) return ok(`NO ROWS for advertiser ${d.advertiserId} at ${d.level} level (${d.reportType || 'BASIC'} report) in that window. That means nothing DELIVERED — it is not a measurement of zero performance, and on a freshly built account it usually means nothing has been activated yet.`, d);
|
|
6417
|
-
|
|
7645
|
+
// THE TRUNCATION LEADS. A partial page rendered as "N row(s)" is how a model ends up narrating spend totals
|
|
7646
|
+
// off a fraction of the data — so if the report is partial, say so BEFORE the numbers, not after.
|
|
7647
|
+
const head = d.truncationNote ? `⚠ ${d.truncationNote}\n\n` : '';
|
|
7648
|
+
const shown = rows.slice(0, 40);
|
|
7649
|
+
const clipped = shown.length < rows.length ? ` (showing ${shown.length} of the ${rows.length} returned)` : '';
|
|
7650
|
+
return ok(`${head}${rows.length} row(s) from TikTok${d.totalRows != null && d.totalRows !== rows.length ? ` of ${d.totalRows} total` : ''} (${d.reportType || 'BASIC'} report, ${d.level} level, metrics ${(d.metrics || []).join(', ')})${clipped}:\n${JSON.stringify(shown)}`, d);
|
|
6418
7651
|
}));
|
|
6419
7652
|
server.registerTool('create_tiktok_ads_campaign', {
|
|
6420
7653
|
title: 'Create a TikTok campaign (forced paused)',
|
|
@@ -6434,7 +7667,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6434
7667
|
}));
|
|
6435
7668
|
server.registerTool('create_tiktok_ads_ad_group', {
|
|
6436
7669
|
title: 'Create a TikTok ad group (targeting, budget, bidding, schedule)',
|
|
6437
|
-
description: 'Create an ad group under an existing TikTok campaign — the tier that holds the budget, the bid, the placements, the schedule and ALL the targeting. CREATED PAUSED with no override (TikTok would otherwise create it ENABLED, and a live ad group under a paused campaign is exactly how spend escapes a one-level pause); it spends nothing until set_tiktok_ads_status(confirm:true). TIKTOK REQUIRES THIRTEEN FIELDS TOGETHER and its error names only the FIRST missing one per round trip, so the server checks the whole set locally and refuses with EVERY absentee at once — which matters here because QPS is 1 and each round trip costs a second. Sensible defaults are supplied for placement, pacing, promotion type, schedule type, optimization goal, billing event, bid type and budget mode, so the ONE thing you must resolve yourself is LOCATION: locationIds is effectively mandatory (TikTok: "‘location_ids’ or ‘zipcode_ids’ must be specified") and comes from search_tiktok_ads_targeting(kind:"location"). Resolve interest and language ids the same way — a guessed id either fails the create or targets the wrong people, silently. The result is READ BACK from TikTok.',
|
|
7670
|
+
description: 'Create an ad group under an existing TikTok campaign — the tier that holds the budget, the bid, the placements, the schedule and ALL the targeting. CREATED PAUSED with no override (TikTok would otherwise create it ENABLED, and a live ad group under a paused campaign is exactly how spend escapes a one-level pause); it spends nothing until set_tiktok_ads_status(confirm:true). TIKTOK REQUIRES THIRTEEN FIELDS TOGETHER and its error names only the FIRST missing one per round trip, so the server checks the whole set locally and refuses with EVERY absentee at once — which matters here because QPS is 1 and each round trip costs a second. Sensible defaults are supplied for placement, pacing, promotion type, schedule type, optimization goal, billing event, bid type and budget mode, so the ONE thing you must resolve yourself is LOCATION: locationIds is effectively mandatory (TikTok: "‘location_ids’ or ‘zipcode_ids’ must be specified") and comes from search_tiktok_ads_targeting(kind:"location"). Resolve interest and language ids the same way — a guessed id either fails the create or targets the wrong people, silently. CONVERSION CAMPAIGNS (WEB_CONVERSIONS / CONVERSIONS / PRODUCT_SALES / LEAD_GENERATION) NEED A PIXEL: set optimizationGoal CONVERT, pass pixelId AND optimizationEvent, and billingEvent OCPM — TikTok pairs a billing event with every goal MANUALLY and derives nothing, so the server supplies the right one if you leave it out. Without a pixel TikTok refuses with "Please select a pixel." The result is READ BACK from TikTok, and the pixel and event it reports are the STORED ones.',
|
|
6438
7671
|
inputSchema: {
|
|
6439
7672
|
advertiserId: z.string().optional(),
|
|
6440
7673
|
campaignId: z.string().describe('the campaign this ad group belongs to'),
|
|
@@ -6458,12 +7691,81 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6458
7691
|
languages: z.array(z.string()).optional().describe('language codes from search_tiktok_ads_targeting(kind:"language")'),
|
|
6459
7692
|
interestCategoryIds: z.array(z.string()).optional().describe('ids from search_tiktok_ads_targeting(kind:"interest")'),
|
|
6460
7693
|
interestKeywordIds: z.array(z.string()).optional().describe('ids from search_tiktok_ads_targeting(kind:"interest_keyword") — TikTok pairs this with interestCategoryIds under Interests'),
|
|
7694
|
+
pixelId: z.string().optional().describe('REQUIRED when optimizationGoal is CONVERT or VALUE, and NOT ACCEPTED with any other goal — that is TikTok\u2019s own two-sided rule, and it is why a conversion campaign built without one dies at the ad group with "Please select a pixel." Get it from list_tiktok_ads_pixels, or create_tiktok_ads_pixel for a new one.'),
|
|
7695
|
+
optimizationEvent: z.string().optional().describe('REQUIRED whenever pixelId is given (and when optimizationGoal is IN_APP_EVENT or VALUE) — the conversion TikTok optimises toward. Web events: ' + TIKTOK_WEB_OPT_EVENTS.join(', ') + '. Pick the one that matches the goal: SHOPPING for a purchase, FORM for a lead, ON_WEB_REGISTER for a signup. The event must already EXIST on the pixel or TikTok refuses with "This pixel event type does not exist."'),
|
|
7696
|
+
customConversionId: z.string().optional().describe('optional narrower rule from list_tiktok_ads_custom_conversions — valid only alongside a pixel and only when optimizationGoal is CONVERT or IN_APP_EVENT'),
|
|
7697
|
+
promotionTargetType: z.string().optional().describe('LEAD_GENERATION campaigns only: INSTANT_PAGE (a TikTok in-app form) or EXTERNAL_WEBSITE (your own landing page). With EXTERNAL_WEBSITE + optimizationGoal CLICK, TikTok forbids pixelId and optimizationEvent.'),
|
|
7698
|
+
conversionBid: z.number().optional().describe('target cost per conversion for oCPM — required by TikTok when bidType is BID_TYPE_CUSTOM and billingEvent is OCPM'),
|
|
6461
7699
|
},
|
|
6462
|
-
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(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
7700
|
+
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() },
|
|
6463
7701
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
6464
7702
|
}, wrap(async (a) => {
|
|
6465
7703
|
const d = await apiPost('/api/tiktok-ads/adgroup', a);
|
|
6466
|
-
|
|
7704
|
+
const conv = d.pixelId ? ` toward ${d.optimizationEvent || 'a conversion'} on pixel ${d.pixelId}` : '';
|
|
7705
|
+
return ok(`Created TikTok ad group "${d.name}" (${d.id}) under campaign ${d.campaignId} — status ${d.status}, optimising for ${d.optimizationGoal}${conv}${d.verified ? '' : ' (the read-back did not confirm it — check TikTok Ads Manager)'}. ${d.note} Next: list_tiktok_ads_identities, then create_tiktok_ads_ad.`, d);
|
|
7706
|
+
}));
|
|
7707
|
+
// ── PIXELS: what makes a TikTok CONVERSION campaign deliverable at all ──────────────────────────────────────────
|
|
7708
|
+
// We offered four conversion objectives and had no pixel_id anywhere, so every one of them built a real campaign
|
|
7709
|
+
// and then died at the ad group. TikTok validates in layers and the message changes at each: no pixel → "Please
|
|
7710
|
+
// select a pixel." · a bad id → "Pixel ID doesn't exist." · a real pixel with no event yet → "This pixel event
|
|
7711
|
+
// type does not exist." All three are now refused for free, by name, BEFORE the round trip.
|
|
7712
|
+
server.registerTool('list_tiktok_ads_pixels', {
|
|
7713
|
+
title: 'List a TikTok advertiser\u2019s conversion pixels',
|
|
7714
|
+
description: 'List the conversion pixels on a TikTok ad account, each with the EVENTS it can optimise toward — which is the difference between "a pixel exists" and "this campaign can actually optimise". Call it before building any conversion campaign (WEB_CONVERSIONS, CONVERSIONS, PRODUCT_SALES, LEAD_GENERATION): those need optimizationGoal CONVERT, which TikTok refuses without a pixel. Only an event reported under optimizationEvent can be used — TikTok returns none for PAGE_VIEW because it "cannot be used for optimization". An EMPTY event list is not proof a pixel is idle: TikTok refreshes pixel event data every 2-4 hours, so a pixel that started firing recently still reports []. Read-only, free.',
|
|
7715
|
+
inputSchema: {
|
|
7716
|
+
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit only when exactly one is reachable'),
|
|
7717
|
+
pixelId: z.string().optional().describe('filter to one pixel'),
|
|
7718
|
+
name: z.string().optional().describe('fuzzy name filter'),
|
|
7719
|
+
},
|
|
7720
|
+
outputSchema: { advertiserId: z.string().optional(), pixels: z.array(z.any()).optional(), total: z.number().optional(), note: z.string().optional() },
|
|
7721
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
7722
|
+
}, wrap(async (a) => {
|
|
7723
|
+
const d = await apiGet('/api/tiktok-ads/pixels', a);
|
|
7724
|
+
const rows = d.pixels || [];
|
|
7725
|
+
return ok(rows.length
|
|
7726
|
+
? `${rows.length} TikTok pixel(s) on advertiser ${d.advertiserId}:\n` + rows.map(p => `\u2022 ${p.name} (${p.pixelId})${p.usable ? '' : ' \u2014 UNBOUND, not counted in reporting'} \u2014 optimisable events: ${(p.events || []).filter(e => e.optimizationEvent).map(e => e.optimizationEvent).join(', ') || 'NONE YET (an ad group cannot optimise toward this pixel until an event is defined on it)'}`).join('\n') + `\n${d.note || ''}`
|
|
7727
|
+
: d.note || `No pixels on advertiser ${d.advertiserId}.`, d);
|
|
7728
|
+
}));
|
|
7729
|
+
server.registerTool('create_tiktok_ads_pixel', {
|
|
7730
|
+
title: 'Create a TikTok conversion pixel',
|
|
7731
|
+
description: 'Create a new TikTok pixel on an ad account so conversion campaigns have something to optimise toward. Returns the pixel id and the pixel CODE plus the script to install. A PIXEL ALONE IS NOT ENOUGH AND THIS IS THE PART PEOPLE MISS: it measures nothing until (1) the script is installed on the website and (2) the specific conversion EVENT is defined on it — until then TikTok refuses a conversion ad group with "This pixel event type does not exist." TikTok caps the name at 40 characters, rejects emojis and rejects DUPLICATE names, and it recommends naming the pixel after the site it measures. Creating a pixel spends nothing and serves nothing — it is a measurement definition. NOTE: TikTok publishes NO endpoint that deletes a pixel, so a pixel created here is permanent on that ad account.',
|
|
7732
|
+
inputSchema: {
|
|
7733
|
+
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
7734
|
+
name: z.string().describe('\u226440 characters, no emojis, and it must not duplicate an existing pixel name on the account. TikTok recommends the website or domain it measures.'),
|
|
7735
|
+
},
|
|
7736
|
+
outputSchema: { pixelId: z.string().optional(), pixelCode: z.string().optional(), name: z.string().optional(), advertiserId: z.string().optional(), pixelScript: z.string().nullable().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
7737
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
7738
|
+
}, wrap(async (a) => {
|
|
7739
|
+
const d = await apiPost('/api/tiktok-ads/pixel', a);
|
|
7740
|
+
return ok(`Created TikTok pixel "${d.name}" \u2014 id ${d.pixelId}, code ${d.pixelCode}. ${d.note}`, d);
|
|
7741
|
+
}));
|
|
7742
|
+
server.registerTool('tiktok_ads_pixel_stats', {
|
|
7743
|
+
title: 'Read how often a TikTok pixel\u2019s events fired',
|
|
7744
|
+
description: 'Event-level statistics for one TikTok pixel over a date range — how many times each event actually fired, which is how you tell a pixel that is installed and working from one that is installed and silent. Takes the pixel CODE (not the id) as reported by list_tiktok_ads_pixels. Read-only, free.',
|
|
7745
|
+
inputSchema: {
|
|
7746
|
+
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
7747
|
+
pixelCode: z.string().describe('the pixelCode from list_tiktok_ads_pixels \u2014 NOT the pixelId'),
|
|
7748
|
+
startDate: z.string().optional().describe('YYYY-MM-DD'),
|
|
7749
|
+
endDate: z.string().optional().describe('YYYY-MM-DD'),
|
|
7750
|
+
},
|
|
7751
|
+
outputSchema: { advertiserId: z.string().optional(), pixelCode: z.string().optional(), stats: z.any().optional() },
|
|
7752
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
7753
|
+
}, wrap(async (a) => {
|
|
7754
|
+
const d = await apiGet('/api/tiktok-ads/pixel-stats', a);
|
|
7755
|
+
return ok(`Pixel ${d.pixelCode} event stats.`, d);
|
|
7756
|
+
}));
|
|
7757
|
+
server.registerTool('list_tiktok_ads_custom_conversions', {
|
|
7758
|
+
title: 'List TikTok Custom Conversions',
|
|
7759
|
+
description: 'List the Custom Conversions defined on a TikTok ad account — narrower, rule-based conversions built on top of a pixel event (for example "Purchase, but only on /checkout/premium"). Pass one to create_tiktok_ads_ad_group as customConversionId to optimise toward the narrow rule instead of the broad standard event. TikTok accepts it ONLY alongside a pixel and only when optimizationGoal is CONVERT or IN_APP_EVENT, and only when its own optimizationEvent matches the one on the ad group; usableForAds reports whether its activity status allows it. Read-only, free.',
|
|
7760
|
+
inputSchema: { advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts') },
|
|
7761
|
+
outputSchema: { advertiserId: z.string().optional(), customConversions: z.array(z.any()).optional() },
|
|
7762
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
7763
|
+
}, wrap(async (a) => {
|
|
7764
|
+
const d = await apiGet('/api/tiktok-ads/custom-conversions', a);
|
|
7765
|
+
const rows = d.customConversions || [];
|
|
7766
|
+
return ok(rows.length
|
|
7767
|
+
? `${rows.length} Custom Conversion(s):\n` + rows.map(c => `\u2022 ${c.name} (${c.id}) \u2014 event ${c.optimizationEvent || '\u2014'}, ${c.activityStatus || 'status unknown'}${c.usableForAds ? '' : ' \u2014 NOT usable for ad creation'}`).join('\n')
|
|
7768
|
+
: `No Custom Conversions on advertiser ${d.advertiserId}. A conversion ad group can still optimise toward a standard pixel event via optimizationEvent.`, d);
|
|
6467
7769
|
}));
|
|
6468
7770
|
// THE STEP THAT TURNS A RENDER INTO AN AD. Without it create_tiktok_ads_ad had no reachable source of a videoId
|
|
6469
7771
|
// and the ad lane could not be exercised at all — it was the one BLOCKED cell in the ads matrix.
|
|
@@ -6489,7 +7791,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6489
7791
|
}));
|
|
6490
7792
|
server.registerTool('create_tiktok_ads_ad', {
|
|
6491
7793
|
title: 'Create a TikTok ad',
|
|
6492
|
-
description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with "Unsupported image size", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a ⚠ warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
|
|
7794
|
+
description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with "Unsupported image size", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a ⚠ warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
|
|
6493
7795
|
inputSchema: {
|
|
6494
7796
|
advertiserId: z.string().optional(),
|
|
6495
7797
|
adgroupId: z.string().describe('the ad group whose targeting, budget and schedule this ad runs under'),
|
|
@@ -6498,16 +7800,18 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6498
7800
|
identityType: z.string().describe('REQUIRED — the matching type reported next to that id by list_tiktok_ads_identities. There is NO default and guessing is refused: the plausible-looking CUSTOMIZED_USER is wrong for a plain TikTok account, which is TT_USER.'),
|
|
6499
7801
|
adFormat: z.string().optional().describe('default SINGLE_VIDEO'),
|
|
6500
7802
|
videoId: z.string().optional().describe('the videoId returned by upload_tiktok_ads_creative (or a video already in the advertiser’s TikTok asset library)'),
|
|
7803
|
+
tiktokItemId: z.string().optional().describe('SPARK ADS — promote an EXISTING organic TikTok post instead of an uploaded video. From list_tiktok_ads_identity_posts (an account you control) or list_tiktok_ads_spark_posts (a creator post authorised by code). Mutually exclusive with videoId, and NOT supported with identityType CUSTOMIZED_USER; both are refused here for free.'),
|
|
7804
|
+
identityAuthorizedBcId: z.string().optional().describe('REQUIRED when identityType is BC_AUTH_TT — the Business Center id reported next to that identity by list_tiktok_ads_identities'),
|
|
6501
7805
|
imageIds: z.array(z.string()).optional().describe('for a video ad, the ONE cover image: pass [coverImageId] from the same upload_tiktok_ads_creative call. For the image formats, the images themselves.'),
|
|
6502
7806
|
adText: z.string().optional().describe('the ad copy people read'),
|
|
6503
7807
|
callToAction: z.string().optional().describe('TikTok’s CTA button value — default LEARN_MORE'),
|
|
6504
7808
|
landingPageUrl: z.string().optional().describe('where the ad sends people'),
|
|
6505
7809
|
},
|
|
6506
|
-
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(), imageIds: z.array(z.string()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
7810
|
+
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() },
|
|
6507
7811
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
6508
7812
|
}, wrap(async (a) => {
|
|
6509
7813
|
const d = await apiPost('/api/tiktok-ads/ad', a);
|
|
6510
|
-
return ok(`Created TikTok 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);
|
|
7814
|
+
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);
|
|
6511
7815
|
}));
|
|
6512
7816
|
server.registerTool('set_tiktok_ads_status', {
|
|
6513
7817
|
title: 'Activate, pause or delete TikTok campaigns / ad groups / ads',
|
|
@@ -6568,8 +7872,15 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6568
7872
|
// ways a caller gets it wrong and neither is guessable: money is MICRO-currency (1,000,000 = 1 unit) and a
|
|
6569
7873
|
// creative headline is capped at 34 characters.
|
|
6570
7874
|
//
|
|
6571
|
-
//
|
|
6572
|
-
// been made from this codebase, because no SNAPCHAT_CLIENT_ID
|
|
7875
|
+
// VERIFIED-LIVE 2026-08-10 — this header said the OPPOSITE until 2026-08-19 and was wrong for nine days.
|
|
7876
|
+
// It read "NOT LIVE-VERIFIED … no Snapchat call has been made from this codebase, because no SNAPCHAT_CLIENT_ID
|
|
7877
|
+
// exists in the deployment yet". The credential landed and the whole tree was swept the SAME DAY the shapes were
|
|
7878
|
+
// read: every Snapchat cell in tools/lib/ads-matrix.json is VERIFIED-LIVE against ad account
|
|
7879
|
+
// a6ef8b0b-2f33-4c41-b7e8-d756f9c987a4, and that sweep FOUND AND FIXED three defects (SNAP-1's 100%-failing
|
|
7880
|
+
// budget/status writes, SNAP-3, SNAP-6) — which is precisely the work a "docs only" header tells the next reader
|
|
7881
|
+
// has not happened. A stale not-verified claim is not harmlessly conservative: it makes someone redo a live sweep,
|
|
7882
|
+
// or distrust code that works. The one thing still genuinely unexercised is the chunked upload transport, and
|
|
7883
|
+
// that is stated where it lives (lib/snapchat-ads.mjs).
|
|
6573
7884
|
server.registerTool('list_snapchat_ads_accounts', {
|
|
6574
7885
|
title: 'List Snapchat organizations and ad accounts',
|
|
6575
7886
|
description: 'List the Snapchat AD ACCOUNTS SHARED WITH THIS BRAND — the ones it may actually build on and spend from, which is NOT everything the Snapchat login can reach — id, name, currency, timezone and status. Every other Snapchat Ads tool needs an adAccountId and this is where it comes from. One call returns both tiers, because Snap nests ad accounts inside their organization. An account flagged as a TEST account is marked as such — those cannot serve real ads. Read-only, free.',
|
|
@@ -6831,7 +8142,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
6831
8142
|
imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
|
|
6832
8143
|
idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
|
|
6833
8144
|
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.'),
|
|
6834
|
-
altText: z.string().optional(),
|
|
8145
|
+
altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Not available on a PERSONAL-profile post: LinkedIn\u2019s member posting API has no alt-text field at all.'),
|
|
6835
8146
|
title: z.string().optional().describe('video title'),
|
|
6836
8147
|
captionsSrt: z.string().optional().describe('CLOSED CAPTIONS for a videoUrl post — the SubRip (.srt) CONTENT itself, cue numbers and `00:00:00,000 --> 00:00:02,000` timing lines included, NOT a URL and NOT the plain script (a file with no timings is refused, because LinkedIn would accept it and then silently never show it). Most of LinkedIn is watched with the sound off, so an uncaptioned video is one most of the feed never hears. LinkedIn allows ONE caption file per video and ENGLISH ONLY; it can be attached only WHILE the video is uploaded, never added to a published post; and it is processed asynchronously, so the reply confirms it was UPLOADED and never that it is visible yet. Requires videoUrl — passing it on an image, carousel or link post is refused by name.'),
|
|
6837
8148
|
videoThumbnailUrl: z.string().optional().describe('the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user\u2019s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card.'),
|
|
@@ -8773,6 +10084,27 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
8773
10084
|
identities: (d) => (d.advertisers || []).map(a => ({ id: String(a.advertiserId), name: a.name || a.advertiserId, type: 'ad_account', selected: !!a.selected, detail: '' })),
|
|
8774
10085
|
write: (ids) => ['/api/tiktok-ads/scope', { advertiserIds: ids }],
|
|
8775
10086
|
},
|
|
10087
|
+
// SEARCH CONSOLE + BING WEBMASTER (2026-08-18). Both had a full picker ROUTE PAIR on the server and a row in
|
|
10088
|
+
// lib/shared-identities.mjs, and neither had a row HERE — so `list_connector_accounts`/`set_connector_accounts`,
|
|
10089
|
+
// whose enums are DERIVED from this table, rejected the provider outright. Combined with a web tile that also
|
|
10090
|
+
// had no button for them, a connected Bing account reported "4 sites, NO SITES SELECTED" and there was no way
|
|
10091
|
+
// anywhere in the product to select one: every Search Console and Bing tool then refused by design, because
|
|
10092
|
+
// scoping fails closed. One Google login very commonly holds Search Console access on a dozen clients' domains
|
|
10093
|
+
// (and a Bing API key is account-wide), so the tick list is the same privacy boundary it is on Analytics.
|
|
10094
|
+
// A property's id IS its URL here, and the two spellings are DIFFERENT properties: "https://example.com/" is
|
|
10095
|
+
// the URL-prefix property and "sc-domain:example.com" the domain property. Never normalise one into the other.
|
|
10096
|
+
google_search_console: {
|
|
10097
|
+
label: 'Google Search Console', read: '/api/search-console/identities',
|
|
10098
|
+
identities: (d) => (d.sites || []).map(s => ({ id: String(s.siteUrl), name: s.siteUrl, type: 'search_console_property', selected: !!s.selected, detail: s.permissionLevel || '' })),
|
|
10099
|
+
write: (ids) => ['/api/search-console/scope', { siteUrls: ids }],
|
|
10100
|
+
},
|
|
10101
|
+
bing_webmaster: {
|
|
10102
|
+
label: 'Bing Webmaster Tools', read: '/api/bing-webmaster/identities',
|
|
10103
|
+
// `isVerified` rides `detail` because an UNVERIFIED site is reachable, tickable and useless — Bing answers
|
|
10104
|
+
// every query about it with nothing, which reads as "no traffic" rather than "not verified yet".
|
|
10105
|
+
identities: (d) => (d.sites || []).map(s => ({ id: String(s.siteUrl), name: s.siteUrl, type: 'webmaster_site', selected: !!s.selected, detail: s.isVerified === false ? 'NOT VERIFIED in Bing Webmaster Tools yet' : '' })),
|
|
10106
|
+
write: (ids) => ['/api/bing-webmaster/scope', { siteUrls: ids }],
|
|
10107
|
+
},
|
|
8776
10108
|
};
|
|
8777
10109
|
const CONN_PICKER_IDS = Object.keys(CONN_ACCOUNT_PICKERS);
|
|
8778
10110
|
const connIdentityLines = (rows) => rows.map(r => ` ${r.selected ? '[x]' : '[ ]'} ${r.name} (id: ${r.id}, ${r.type})${r.detail ? ` — ${r.detail}` : ''}`).join('\n');
|
|
@@ -9053,6 +10385,106 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
9053
10385
|
return { content: [{ type: 'text', text }, ...blocks], structuredContent: d };
|
|
9054
10386
|
}));
|
|
9055
10387
|
|
|
10388
|
+
// ── COMPETITOR WATCH — the STANDING research order, not a one-off pull (2026-08-18). `update_settings` could
|
|
10389
|
+
// already turn the weekly digest EMAIL on and off, and nothing headless could say WHO to watch or read WHAT the
|
|
10390
|
+
// watch had found: a switch with no wiring behind it. Both tools are thin wrappers over the routes the web Ad
|
|
10391
|
+
// Spy ▸ Watching tab already calls (POST /api/watch/set, GET /api/watch/latest), so there is one set of rules.
|
|
10392
|
+
const WATCH_PLATFORM_LABEL = { meta: 'Meta', google: 'Google', linkedin: 'LinkedIn' }; // the worker stores facebook results under 'meta'
|
|
10393
|
+
const watchWhen = (ms) => (+ms > 0 ? new Date(+ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : 'never');
|
|
10394
|
+
server.registerTool('set_competitor_watch', {
|
|
10395
|
+
title: 'Set the competitor watch',
|
|
10396
|
+
description: 'Set (or STOP) this workspace\'s standing COMPETITOR WATCH — the weekly job that re-checks each named brand\'s ad libraries and reports what is NEW since last time. The same watch the web app\'s Ad Spy ▸ Watching tab manages, and the same one the weekly digest email is sent from (turn that email on/off with update_settings({watchEmail})). '
|
|
10397
|
+
+ 'This REPLACES the whole watched list, it does not add to it — pass every brand you want watched, every time. Max 5 brands (the server trims past that). Pass an EMPTY list to stop the watch entirely, which also clears the findings. '
|
|
10398
|
+
+ 'Give a `domain` wherever you know one: Google Ads Transparency is looked up BY DOMAIN and is skipped for a brand without one, and the domain is what resolves the right Meta page for a brand with an ambiguous name. '
|
|
10399
|
+
+ 'The run itself spends ScrapeCreators credits against the ad libraries (roughly 3 per brand on Meta, 1 each on Google and LinkedIn) and is hard-capped per run server-side, so an oversized watch is trimmed rather than allowed to run away. Setting the list is free; only a run spends. '
|
|
10400
|
+
+ 'runNow:true runs it once IMMEDIATELY (a background job — it spends now) and then keeps the weekly cadence; leave it off and the first check is a week out. '
|
|
10401
|
+
+ 'The country and the platform mix are NOT settable here — a re-set inherits whatever the pending run already carried (US / Meta for a watch that has never been configured otherwise). Read the findings back with list_watch_findings.',
|
|
10402
|
+
inputSchema: {
|
|
10403
|
+
competitors: z.array(z.object({
|
|
10404
|
+
name: z.string().describe('the brand name, as it advertises'),
|
|
10405
|
+
domain: z.string().optional().describe('its domain, e.g. ridge.com — required for Google Ads Transparency, and what disambiguates a common brand name on Meta'),
|
|
10406
|
+
}).passthrough()).describe('the brands to watch — the COMPLETE list, replacing whatever was set before. Empty array = stop watching.'),
|
|
10407
|
+
runNow: z.boolean().optional().describe('true to run one check immediately (spends credits now) instead of waiting a week for the first one'),
|
|
10408
|
+
},
|
|
10409
|
+
outputSchema: {
|
|
10410
|
+
watching: z.array(z.string()).optional().describe('the brand names now under watch (empty = the watch is off)'),
|
|
10411
|
+
nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
|
|
10412
|
+
},
|
|
10413
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
10414
|
+
}, wrap(async (a) => {
|
|
10415
|
+
// An OMITTED list is refused; an EMPTY one is the documented way to stop. Defaulting the omission to [] would
|
|
10416
|
+
// turn "I forgot the argument" into "delete this workspace's whole watch", silently.
|
|
10417
|
+
if (!Array.isArray(a.competitors)) return { content: [{ type: 'text', text: 'Pass `competitors` — the complete list of brands to watch, or [] to stop the watch.' }], isError: true };
|
|
10418
|
+
const comps = a.competitors.filter(c => c && String(c.name || '').trim())
|
|
10419
|
+
.map(c => ({ name: String(c.name).trim(), domain: String(c.domain || '').trim() }));
|
|
10420
|
+
const d = await apiPost('/api/watch/set', { competitors: comps, ...(a.runNow === true ? { runNow: true } : {}) });
|
|
10421
|
+
const on = (d.watching || []);
|
|
10422
|
+
if (!on.length) return ok('Competitor watch STOPPED — nothing is being watched and the findings are cleared.', d);
|
|
10423
|
+
// The server trims to 5 and drops nameless rows; report what it ACTUALLY stored, never what was asked for.
|
|
10424
|
+
const dropped = comps.length - on.length;
|
|
10425
|
+
// …and the no-domain note names only the brands that were KEPT — naming a trimmed one would tell the caller to
|
|
10426
|
+
// fix a row that is not on the watch at all.
|
|
10427
|
+
const noDomain = comps.filter(c => !c.domain && on.includes(c.name)).map(c => c.name);
|
|
10428
|
+
return ok(`Watching ${on.length} brand(s): ${on.join(', ')}.${dropped > 0 ? ` (${dropped} not kept — the watch holds at most 5.)` : ''}`
|
|
10429
|
+
+ ` Next check ${a.runNow === true ? 'is running now' : watchWhen(d.nextRunAt)}, then weekly. Read what it finds with list_watch_findings.`
|
|
10430
|
+
+ `${noDomain.length ? ` Note: no domain for ${noDomain.join(', ')} — Google Ads Transparency is looked up by domain and will be skipped for them.` : ''}`, d);
|
|
10431
|
+
}));
|
|
10432
|
+
|
|
10433
|
+
server.registerTool('list_watch_findings', {
|
|
10434
|
+
title: 'Read the competitor watch',
|
|
10435
|
+
description: 'Read what the standing COMPETITOR WATCH has found — the new ads each watched brand has launched since the last check, plus the watch\'s own state (who is watched, when it last ran, when it runs next, and whether the last run actually succeeded). The same board the web app\'s Ad Spy ▸ Watching tab renders. '
|
|
10436
|
+
+ 'Use it to answer "what are our competitors running that\'s new?", to feed a teardown, or to save something worth keeping with save_to_swipefile. '
|
|
10437
|
+
+ 'Findings marked `seed:true` are NOT new launches — the first check of a brand has nothing to diff against, so it seeds the board with what that brand is running right now; only later runs surface genuine changes. '
|
|
10438
|
+
+ 'Read-only and free — it returns the stored results of past runs and never triggers a check (set_competitor_watch({runNow:true}) is what runs one).',
|
|
10439
|
+
inputSchema: {
|
|
10440
|
+
competitor: z.string().optional().describe('only findings for this watched brand (exact name as returned in `watching`) — omit for all of them'),
|
|
10441
|
+
limit: z.number().optional().describe('max findings to return (default 25, max 75 — the server keeps at most 75, and at most 15 per brand)'),
|
|
10442
|
+
},
|
|
10443
|
+
outputSchema: {
|
|
10444
|
+
watching: z.boolean().optional().describe('whether a watch is currently set'),
|
|
10445
|
+
competitors: z.array(z.any()).optional().describe('the brands under watch ({name, domain})'),
|
|
10446
|
+
platforms: z.array(z.string()).optional().describe('the ad libraries this watch checks'),
|
|
10447
|
+
lastRun: z.number().optional().describe('epoch ms of the last check (0 = never run)'),
|
|
10448
|
+
lastRunOk: z.boolean().optional().describe('false when the last check failed every library call (usually an exhausted credit balance) — a stale board, not an empty market'),
|
|
10449
|
+
lastRunNote: z.string().optional().describe('what went wrong on the last run, when something did'),
|
|
10450
|
+
nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
|
|
10451
|
+
findings: z.array(z.any()).optional().describe('the found ads ({competitor, platform, foundAt, seed, advertiser, body, media, ad})'),
|
|
10452
|
+
total: z.number().optional().describe('how many findings the board holds before the filter/limit'),
|
|
10453
|
+
},
|
|
10454
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
10455
|
+
}, wrap(async (a) => {
|
|
10456
|
+
const d = await apiGet('/api/watch/latest');
|
|
10457
|
+
// ABSENT IS NOT EMPTY ([[failed-read-is-not-empty]]): the route answers null when this workspace has never had a
|
|
10458
|
+
// watch at all, which is a different sentence from "the watch ran and found nothing".
|
|
10459
|
+
if (!d) return ok('No competitor watch has ever been set for this workspace — set_competitor_watch names the brands to follow.', { watching: false, findings: [], total: 0 });
|
|
10460
|
+
const want = String(a.competitor || '').trim().toLowerCase();
|
|
10461
|
+
const all = Array.isArray(d.newAds) ? d.newAds : [];
|
|
10462
|
+
const rows = want ? all.filter(n => String(n.competitor || '').toLowerCase() === want) : all;
|
|
10463
|
+
const names = (d.competitors || []).map(c => c.name).filter(Boolean);
|
|
10464
|
+
if (want && !names.some(n => n.toLowerCase() === want)) {
|
|
10465
|
+
return { content: [{ type: 'text', text: `"${a.competitor}" is not on this watch. Watched: ${names.join(', ') || '(nobody — the watch is off)'}.` }], isError: true };
|
|
10466
|
+
}
|
|
10467
|
+
const lim = Math.min(75, Math.max(1, +a.limit || 25));
|
|
10468
|
+
const findings = rows.slice(0, lim).map(n => {
|
|
10469
|
+
const ad = n.ad || {}, s = ad.snapshot || {};
|
|
10470
|
+
const img = ad.imageUrl || ad.image || s.images?.[0]?.resized_image_url || s.cards?.[0]?.resized_image_url || s.videos?.[0]?.video_preview_image_url || null;
|
|
10471
|
+
return {
|
|
10472
|
+
competitor: n.competitor || '', platform: n.platform || '', foundAt: n.foundAt || 0, seed: n.seed === true,
|
|
10473
|
+
advertiser: ad.page_name || ad.advertiserName || ad.advertiser || n.competitor || '',
|
|
10474
|
+
body: trunc((typeof s.body === 'string' ? s.body : s.body?.text) || ad.headline || ad.description || ''),
|
|
10475
|
+
media: abs(s.videos?.[0]?.video_sd_url || img || ad.adUrl || ad.destinationUrl || s.link_url || ''),
|
|
10476
|
+
ad,
|
|
10477
|
+
};
|
|
10478
|
+
});
|
|
10479
|
+
const state = `Watching: ${names.join(', ') || '(nobody)'}${d.platforms?.length ? ` · libraries: ${d.platforms.join(', ')}` : ''}`
|
|
10480
|
+
+ ` · last check ${watchWhen(d.lastRun)}${d.lastRunOk === false ? ` (FAILED${d.lastRunNote ? `: ${d.lastRunNote}` : ''} — this board is stale, not a quiet market)` : ''}`
|
|
10481
|
+
+ ` · next check ${watchWhen(d.nextRunAt)}.`;
|
|
10482
|
+
const out = { watching: !!d.watching, competitors: d.competitors || [], platforms: d.platforms || [], lastRun: d.lastRun || 0, lastRunOk: d.lastRunOk !== false, lastRunNote: d.lastRunNote || '', nextRunAt: d.nextRunAt || 0, findings, total: rows.length };
|
|
10483
|
+
if (!findings.length) return ok(`${state}\nNo findings recorded yet${want ? ` for "${a.competitor}"` : ''}${d.lastRun ? '' : ' — the first check has not run'}.`, out);
|
|
10484
|
+
const line = (f) => ` • ${f.seed ? '[already running] ' : '[NEW] '}${f.competitor} · ${WATCH_PLATFORM_LABEL[f.platform] || f.platform} — ${f.advertiser}${f.body ? `: ${f.body}` : ''}${f.media ? ` ${f.media}` : ''}`;
|
|
10485
|
+
return ok(`${state}\n${rows.length} finding(s)${want ? ` for "${a.competitor}"` : ''}${findings.length < rows.length ? ` — showing ${findings.length}` : ''}:\n` + findings.map(line).join('\n'), out);
|
|
10486
|
+
}));
|
|
10487
|
+
|
|
9056
10488
|
server.registerTool('research_ads', {
|
|
9057
10489
|
title: 'Research ads',
|
|
9058
10490
|
description: 'Natural-language ad research: a Claude tool-use loop over Meta/Google/LinkedIn ad libraries + organic TikTok. Returns a summary + the found ads (with their served URLs). Spends LLM tokens + ScrapeCreators credits.',
|