hermoso 0.1.57 → 0.1.64

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.
Files changed (3) hide show
  1. package/README.md +18 -2
  2. package/mcp/tools.mjs +514 -30
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,15 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **316 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ <<<<<<< HEAD
9
+ <<<<<<< HEAD
10
+ **344 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
11
+ =======
12
+ **344 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
13
+ >>>>>>> worktree-agent-a78b27d27f3710d65
14
+ =======
15
+ **344 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
16
+ >>>>>>> worktree-agent-a74cec7ae929aa73c
9
17
  catalog with exact per-render credit costs plus the full capability map.
10
18
 
11
19
  **It is not all-or-nothing.** Research, creation, publishing/scheduling and ads management are four *independent*
@@ -53,7 +61,15 @@ Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
53
61
 
54
62
  Then ask your agent: *“Generate an image ad with Hermoso.”*
55
63
 
56
- ### What the 316 tools cover
64
+ <<<<<<< HEAD
65
+ <<<<<<< HEAD
66
+ ### What the 344 tools cover
67
+ =======
68
+ ### What the 344 tools cover
69
+ >>>>>>> worktree-agent-a78b27d27f3710d65
70
+ =======
71
+ ### What the 344 tools cover
72
+ >>>>>>> worktree-agent-a74cec7ae929aa73c
57
73
 
58
74
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
59
75
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/tools.mjs CHANGED
@@ -87,9 +87,9 @@ const okVideo = async (text, r) => {
87
87
  // bytes and answers exactly such a URL, so it is the universal bridge and the honest thing to tell an agent.
88
88
  //
89
89
  // ONE const, spliced into BOTH the capability map and MCP_INSTRUCTIONS, so this fact cannot be live on one surface
90
- // and stale on the other. The "seven ad platforms" count is asserted against the roster by
90
+ // and stale on the other. The "eight ad platforms" count is asserted against the roster by
91
91
  // tools/agent-independence-check.mjs — a hand-written number in a prompt is how a roster goes stale silently.
92
- export const INDEPENDENCE = 'INDEPENDENT AREAS, NOT A PIPELINE — research, creation, publishing/scheduling and ads management each work ON THEIR OWN, and NO tool requires that you used another one first: publish or schedule media the user already has and generate nothing here (upload_file turns any local or external file into a URL the publish, schedule and ad-build tools accept), build and read campaigns on their OWN ad accounts with their OWN creative across all seven ad platforms, research competitors with no brand drafted and no channel connected, or generate a file with nothing connected at all and simply hand back the URL. Use one area, several, or all of it together — never tell a user they have to start somewhere else first.';
92
+ export const INDEPENDENCE = 'INDEPENDENT AREAS, NOT A PIPELINE — research, creation, publishing/scheduling and ads management each work ON THEIR OWN, and NO tool requires that you used another one first: publish or schedule media the user already has and generate nothing here (upload_file turns any local or external file into a URL the publish, schedule and ad-build tools accept), build and read campaigns on their OWN ad accounts with their OWN creative across all eight ad platforms, research competitors with no brand drafted and no channel connected, or generate a file with nothing connected at all and simply hand back the URL. Use one area, several, or all of it together — never tell a user they have to start somewhere else first.';
93
93
 
94
94
  // ── CAPABILITY MAP — the FULL agent surface, four categories. Appended to hermoso_capabilities so an agent that
95
95
  // probes once learns everything Hermoso does (not just the models): ad spy, create, raw playground, account. Keep
@@ -104,7 +104,7 @@ export const CAPABILITY_MAP = [
104
104
  '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).',
105
105
  '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.',
106
106
  '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).',
107
- '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 / Pinterest / Microsoft Advertising accounts this brand may post to and spend from — one person often administers several, only the chosen ones are usable, 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). 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) · 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). 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 recent posts with views/likes/comments/shares). 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_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) · reddit_post_stats (score, comments, upvote ratio on a post you made). 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 ARE NOT AVAILABLE: the X Ads API is a separate product on a separate host with OAuth 1.0a signing and its own approval form — Hermoso cannot create or manage X ad campaigns, so say that plainly instead of offering it. PINTEREST: 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). 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 — non-retail only; the Merchant Center / Shopping-feed variant 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). 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: geo is the ONLY audience targeting this platform has) · 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.',
107
+ '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 / Pinterest / Microsoft Advertising accounts this brand may post to and spend from — one person often administers several, only the chosen ones are usable, 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). 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) · 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. 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 ARE NOT AVAILABLE: the X Ads API is a separate product on a separate host with OAuth 1.0a signing and its own approval form — Hermoso cannot create or manage X ad campaigns, so say that plainly instead of offering it. PINTEREST: 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 — non-retail only; the Merchant Center / Shopping-feed variant 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). 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: geo is the ONLY audience targeting this platform has) · 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.',
108
108
  ].join('\n');
109
109
 
110
110
  // Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
@@ -1152,18 +1152,51 @@ export function registerTools(rawServer, opts = {}) {
1152
1152
  return ok(`Reply ${a.replyId} ${d.hidden ? 'hidden' : 'unhidden'}.`, d);
1153
1153
  }));
1154
1154
 
1155
+ // DELETE, GATED ON BLAST RADIUS RATHER THAN ON INTENT ALONE (2026-08-05). This used to be a flat confirm — pass
1156
+ // confirm:true and the post was gone — which is exactly the gate 2026-08-01 proved insufficient: it reads
1157
+ // identically for a text post nobody saw and for the brand's best-performing thread. The gate is SERVER-side
1158
+ // (threadsDeletePost in server.js), so the app, this twin and the HTTP route cannot drift.
1155
1159
  server.registerTool('delete_thread', {
1156
1160
  title: 'Delete a Threads post',
1157
- description: 'Permanently delete one of the brand’s Threads posts. IRREVERSIBLE — you must confirm with the user first, then pass confirm:true.',
1161
+ description: 'PERMANENTLY delete one of the brand’s Threads posts. IRREVERSIBLE — Threads has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real text plus its views, likes, replies and reposts read back from Threads. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (the post’s exact text as it was reported) once anyone has engaged with it. confirmName 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. Threads allows only 100 deletions per account per rolling 24 hours; threads_publishing_limit says how many are left, and a quota refusal otherwise reads like a broken connection. Note Meta documents nothing about what a delete does to the replies underneath a post, so do not promise the conversation survives. 0 credits.',
1158
1162
  inputSchema: {
1159
1163
  postId: z.string().describe('post id from list_threads_posts'),
1160
- confirm: z.boolean().describe('must be true; only set it after the user has explicitly agreed to the deletion'),
1164
+ confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent; only set it after the user has explicitly agreed'),
1165
+ confirmName: z.string().optional().describe('the post’s exact text as the unconfirmed call reported it — required once it has any likes, replies or reposts'),
1161
1166
  },
1162
- outputSchema: { ok: z.boolean().optional(), deleted: z.string().optional() },
1167
+ outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), permalink: z.string().nullable().optional(), note: z.string().optional() },
1163
1168
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
1164
1169
  }, wrap(async (a) => {
1165
- const d = await apiPost('/api/threads/delete', { postId: a.postId, confirm: a.confirm });
1166
- return ok(`Deleted Threads post ${d.deleted}.`, d);
1170
+ const d = await apiPost('/api/threads/delete', { postId: a.postId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) });
1171
+ // REPORT THE READ-BACK, never the `{"success":true}` — `note` is built from re-reading the id on the server.
1172
+ return ok(d.note, d);
1173
+ }));
1174
+ // REPOST — the one Threads publish verb that was never built. Meta shipped it 2024-10-09
1175
+ // (developers.facebook.com/documentation/threads/posts/reposts, read 2026-08-05): POST /{id}/repost, no body, and
1176
+ // the result reads back as its own media object with media_type REPOST_FACADE.
1177
+ server.registerTool('repost_thread', {
1178
+ title: 'Repost a Threads post',
1179
+ description: 'Repost an existing Threads post to the brand’s own Threads profile — the Threads equivalent of a retweet. It is how a brand amplifies a customer’s post, a mention, or one of its own older threads without copying the text, and there was previously no way to do it. Works on any Threads post id: list_threads_posts, list_threads_mentions and search_threads_keyword all return them. This creates a NEW post on the profile, so show the user what is being reposted and get a yes first. Threads publishes NO un-repost endpoint — because a repost returns its own media id, deleting THAT id with delete_thread is the likely undo, but Meta does not document it, so check the profile afterwards rather than promising it worked. 0 credits. Needs Threads connected.',
1180
+ inputSchema: { postId: z.string().describe('the Threads post id to repost') },
1181
+ outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), repostedPostId: z.string().optional(), permalink: z.string().nullable().optional(), mediaType: z.string().nullable().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
1182
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1183
+ }, wrap(async (a) => {
1184
+ const d = await apiPost('/api/threads/repost', { postId: a.postId });
1185
+ return ok(d.note, d);
1186
+ }));
1187
+ // THE QUOTA READ. Threads meters posts, replies, DELETIONS and location searches on rolling 24-hour windows, and
1188
+ // every one of those refusals otherwise reads as "the connection broke". Shipped alongside the delete for exactly
1189
+ // that reason: an agent asked to clean up a month of posts will hit the 100/day deletion cap.
1190
+ server.registerTool('threads_publishing_limit', {
1191
+ title: 'Threads quota remaining',
1192
+ description: 'How much of the brand’s Threads quota is left right now — posts (250 per rolling 24 hours), replies (1,000), DELETIONS (100) and location searches (500) — each as used, total and REMAINING. Check it before any bulk operation, and read it the moment Threads starts refusing: a quota refusal is otherwise indistinguishable from a broken connection or a missing permission, and reconnecting cannot fix it. A number comes back null when Threads did not report it, never as 0 — "none left" and "we could not tell" are different answers. Read-only, 0 credits. Needs Threads connected.',
1193
+ inputSchema: {},
1194
+ outputSchema: { username: z.string().optional(), posts: z.any().optional(), replies: z.any().optional(), deletes: z.any().optional(), locationSearches: z.any().optional() },
1195
+ annotations: { readOnlyHint: true, openWorldHint: true },
1196
+ }, wrap(async () => {
1197
+ const d = await apiGet('/api/threads/publishing-limit', {});
1198
+ const line = (label, p) => `${label} ${p?.remaining == null ? 'unknown' : `${p.remaining} left`} (${p?.used ?? '?'}/${p?.quota ?? '?'})`;
1199
+ return ok(`Threads quota for @${d.username}, rolling 24h — ${[line('posts', d.posts), line('replies', d.replies), line('DELETIONS', d.deletes), line('location searches', d.locationSearches)].join('; ')}.`, d);
1167
1200
  }));
1168
1201
 
1169
1202
 
@@ -1443,6 +1476,54 @@ export function registerTools(rawServer, opts = {}) {
1443
1476
  const d = await apiDelete(`/api/schedule/${encodeURIComponent(a.id)}`);
1444
1477
  return ok(`Cancelled ${d.cancelled}.`, d);
1445
1478
  }));
1479
+ // ── RETRY + DUPLICATE (2026-08-05) ────────────────────────────────────────────────────────────────────────────
1480
+ // The calendar could create, list, edit and cancel — and then had nothing to offer the moment a post FAILED, which
1481
+ // is the single most likely thing a user wants to act on. Duplicate existed only as a browser-side prefill, i.e. a
1482
+ // WEB-ONLY capability, the one direction that counts as a defect. Both are thin wrappers over the same helpers the
1483
+ // HTTP route and the in-app agent call.
1484
+ server.registerTool('retry_scheduled', {
1485
+ title: 'Retry a failed scheduled post',
1486
+ 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 a few seconds out, 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: reconnecting the account, picking a board, shortening the caption. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
1487
+ inputSchema: {
1488
+ id: z.string().describe('the scheduled post id from list_scheduled'),
1489
+ channels: z.array(z.string()).optional().describe('retry only these channels (default: every channel that did not publish)'),
1490
+ at: z.string().optional().describe('when to retry — ISO timestamp or epoch milliseconds (default: a few seconds from now)'),
1491
+ 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.'),
1492
+ },
1493
+ outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), retryOf: z.string().optional(), retrying: z.array(z.string()).optional(), alreadyPublished: z.array(z.string()).optional(), note: z.string().optional() },
1494
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1495
+ }, wrap(async (a) => {
1496
+ const { id, ...rest } = a;
1497
+ const d = await apiPost(`/api/schedule/${encodeURIComponent(id)}/retry`, rest);
1498
+ return ok(d.note || `Re-queued ${id} as ${d.id}.`, d);
1499
+ }));
1500
+ server.registerTool('duplicate_scheduled', {
1501
+ title: 'Duplicate a scheduled post',
1502
+ description: 'Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.',
1503
+ inputSchema: {
1504
+ id: z.string().describe('the post to copy, from list_scheduled'),
1505
+ at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
1506
+ useQueue: z.boolean().optional().describe('instead of naming a time, take the brand’s next free posting slot'),
1507
+ timezone: z.string().optional().describe('IANA zone for the queue, e.g. "America/New_York"'),
1508
+ channels: z.array(z.string()).optional().describe('post the copy to these channels instead of the original’s'),
1509
+ message: z.string().optional().describe('a different caption for the copy'),
1510
+ captions: z.record(z.string()).optional().describe('per-channel caption overrides for the copy'),
1511
+ imageUrl: z.string().optional(), videoUrl: z.string().optional(),
1512
+ imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image URLs published as ONE swipeable post'),
1513
+ title: z.string().optional(), link: z.string().optional(),
1514
+ boardId: z.string().optional().describe('PINTEREST — the board for the copy (list_pinterest_boards)'),
1515
+ linkedinOrganizationId: z.string().optional().describe('LINKEDIN — publish the copy as this company Page (list_linkedin_pages)'),
1516
+ pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Page (list_meta_pages)'),
1517
+ locationId: z.string().optional().describe('GOOGLE BUSINESS — which listing (list_business_locations)'),
1518
+ visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional(),
1519
+ },
1520
+ outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), duplicateOf: z.string().optional(), note: z.string().optional() },
1521
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1522
+ }, wrap(async (a) => {
1523
+ const { id, ...rest } = a;
1524
+ const d = await apiPost(`/api/schedule/${encodeURIComponent(id)}/duplicate`, rest);
1525
+ return ok(d.note || `Duplicated ${id} as ${d.id}.`, d);
1526
+ }));
1446
1527
  // ── THE POSTING REFILL (2026-08-03) ───────────────────────────────────────────────────────────────────────────
1447
1528
  // Keeping a calendar full is the part of "post three times a day" that nobody sustains by hand, and it was
1448
1529
  // browser-only for about an hour. These three wrap the SAME routes the app uses; there is no second queue —
@@ -1678,6 +1759,87 @@ export function registerTools(rawServer, opts = {}) {
1678
1759
  const d = await apiGet('/api/reddit/post-stats', { postId: a.postId });
1679
1760
  return ok(`“${d.title}” in ${d.subreddit || 'that subreddit'}: ${d.score ?? '?'} score, ${d.comments ?? '?'} comments${d.upvoteRatio != null ? `, ${Math.round(d.upvoteRatio * 100)}% upvoted` : ''}${d.removed ? ' — REMOVED by the subreddit' : ''}.`, d);
1680
1761
  }));
1762
+ // ── OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing
1763
+ // else — no list, no edit, no delete, and not one comment. On the platform whose entire value IS the thread that
1764
+ // is the worst version of the gap. Every endpoint behind these uses a scope this connector ALREADY requests
1765
+ // (`edit`, `submit`, `read`, `history`), so nobody reconnects.
1766
+ server.registerTool('list_reddit_posts', {
1767
+ title: 'The connected Reddit account’s own posts',
1768
+ description: 'The connected Reddit account’s OWN submissions — id, title, subreddit, score, comment count, whether the subreddit removed it, and whether its body can be edited at all. THIS IS WHERE THE postId EVERY OTHER REDDIT TOOL NEEDS COMES FROM: post_to_reddit returns an id only at the instant it publishes, so an agent that did not itself just post had no way to name a post and had to ask the user for a link. Read-only, 0 credits. Needs Reddit connected.',
1769
+ inputSchema: {
1770
+ limit: z.number().optional().describe('1–100, default 25'),
1771
+ sort: z.enum(['new', 'hot', 'top', 'controversial']).optional().describe('default new'),
1772
+ cursor: z.string().optional().describe('the cursor a previous call returned'),
1773
+ },
1774
+ outputSchema: { username: z.string().optional(), count: z.number().optional(), posts: z.array(z.any()).optional(), cursor: z.string().nullable().optional() },
1775
+ annotations: { readOnlyHint: true, openWorldHint: true },
1776
+ }, wrap(async (a) => {
1777
+ const d = await apiGet('/api/reddit/posts', { ...(a.limit ? { limit: a.limit } : {}), ...(a.sort ? { sort: a.sort } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) });
1778
+ if (!d.posts?.length) return ok(`u/${d.username} has no submissions Reddit will return.`, d);
1779
+ return ok(`${d.count} post(s) by u/${d.username}:\n${d.posts.map(p => `• “${p.title}” (id ${p.id}) in ${p.subreddit} — ${p.score ?? '?'} score, ${p.comments ?? '?'} comments${p.editable ? '' : ' — LINK post, body not editable'}${p.removed ? ' — REMOVED' : ''}`).join('\n')}`, d);
1780
+ }));
1781
+ // EDIT — the body, on a self post, and nothing else. Reddit's own endpoint is documented as editing "the body
1782
+ // text of a comment or self-post"; a LINK post is refused outright, and `title` appears on exactly one endpoint in
1783
+ // Reddit's entire API (creation), so a published title is frozen for everyone. Both refusals are stated up front
1784
+ // here rather than discovered as a 403, because an agent told an edit is possible will promise it to a user.
1785
+ server.registerTool('edit_reddit_post', {
1786
+ title: 'Edit a Reddit text post’s body',
1787
+ description: 'Rewrite the BODY of one of the connected account’s Reddit TEXT posts — the fix for a dead link, a wrong price or a correction the comments are asking for. THREE THINGS REDDIT DOES NOT ALLOW, and you must not offer them: (1) a post’s TITLE can never be changed by any API — `title` exists only on Reddit’s submit endpoint, so a published title is frozen for every client, not just this one; (2) a LINK post cannot be edited at all — Reddit documents this endpoint as editing "the body text of a comment or self-post" and refuses a link post; (3) a post that has already been deleted cannot be edited. In each case the only remedy is to delete and submit again, which loses the score, the age and the whole comment thread — say that plainly instead of implying an edit is possible. The result is READ BACK from Reddit, so an accepted edit that did not apply is reported as NOT confirmed rather than narrated as done. 0 credits. Needs Reddit connected.',
1788
+ inputSchema: {
1789
+ postId: z.string().describe('the post id, its t3_… fullname, or the permalink (list_reddit_posts returns them)'),
1790
+ text: z.string().describe('the new body markdown — this REPLACES the existing body'),
1791
+ },
1792
+ outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), subreddit: z.string().nullable().optional(), title: z.string().optional(), url: z.string().nullable().optional(), applied: z.boolean().nullable().optional(), note: z.string().optional() },
1793
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1794
+ }, wrap(async (a) => {
1795
+ const d = await apiPost('/api/reddit/edit', { postId: a.postId, text: a.text });
1796
+ return ok(d.note, d);
1797
+ }));
1798
+ // DELETE. THE REASON THE READ-BACK IS NOT OPTIONAL HERE: Reddit's /api/del answers `{}` with HTTP 200 no matter
1799
+ // what — a wrong id, someone ELSE'S post and a real delete are byte-identical responses, and there is no error to
1800
+ // catch. So ownership is resolved before the gate (server-side) and the verdict comes from re-reading the post.
1801
+ server.registerTool('delete_reddit_post', {
1802
+ title: 'Delete a Reddit post',
1803
+ description: 'PERMANENTLY delete one of the connected account’s Reddit posts. Reddit has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real title, subreddit, score and comment count read back from Reddit. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (its exact title) once it has comments or a real score, because confirming that you meant to delete SOMETHING does not prove you aimed at the right post. TELL THE USER THIS BEFORE THEY AGREE: deleting a Reddit post does NOT delete the comments under it — Reddit keeps the thread and shows the post as [deleted], so the conversation stays public with only their side removed. Reddit’s delete endpoint returns an empty success for every call, including one aimed at a post the account did not write, so the verdict here comes from re-reading the post afterwards and never from that response. 0 credits. Needs Reddit connected.',
1804
+ inputSchema: {
1805
+ postId: z.string().describe('the post id, its t3_… fullname, or the permalink'),
1806
+ confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent'),
1807
+ confirmName: z.string().optional().describe('the post’s EXACT title as the unconfirmed call reported it — required once it has comments or a real score'),
1808
+ },
1809
+ outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), title: z.string().optional(), subreddit: z.string().nullable().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), url: z.string().nullable().optional(), note: z.string().optional() },
1810
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
1811
+ }, wrap(async (a) => {
1812
+ const d = await apiPost('/api/reddit/delete', { postId: a.postId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) });
1813
+ return ok(d.note, d);
1814
+ }));
1815
+ server.registerTool('list_reddit_comments', {
1816
+ title: 'Comments on a Reddit post',
1817
+ description: 'Read the comments under one of the connected account’s Reddit posts — author, text, score, whether it is the poster’s own reply, and when. On Reddit the thread IS the value of a post, and this is where the questions, objections and exact customer wording live: the same raw material for ad copy that list_meta_comments and list_youtube_comments give you on the other channels, from the audience that argues back hardest. Each row carries the fullname to pass to reply_to_reddit_comment. Read-only, 0 credits. Needs Reddit connected.',
1818
+ inputSchema: {
1819
+ postId: z.string().describe('the post id, its t3_… fullname, or the permalink'),
1820
+ limit: z.number().optional().describe('1–100, default 25'),
1821
+ sort: z.enum(['top', 'new', 'confidence', 'controversial', 'old', 'qa']).optional().describe('default top'),
1822
+ },
1823
+ outputSchema: { postId: z.string().optional(), title: z.string().optional(), subreddit: z.string().nullable().optional(), total: z.number().nullable().optional(), count: z.number().optional(), comments: z.array(z.any()).optional() },
1824
+ annotations: { readOnlyHint: true, openWorldHint: true },
1825
+ }, wrap(async (a) => {
1826
+ const d = await apiGet('/api/reddit/comments', { postId: a.postId, ...(a.limit ? { limit: a.limit } : {}), ...(a.sort ? { sort: a.sort } : {}) });
1827
+ if (!d.comments?.length) return ok(`No comments on “${d.title || d.postId}” yet.`, d);
1828
+ return ok(`${d.count} of ${d.total ?? d.count} comment(s) on “${d.title}”:\n${d.comments.map(c => `• u/${c.author}${c.isOp ? ' (the poster)' : ''} — ${c.score ?? '?'} — ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 220)} [reply with parentId ${c.fullname}]`).join('\n')}`, d);
1829
+ }));
1830
+ server.registerTool('reply_to_reddit_comment', {
1831
+ title: 'Reply on Reddit',
1832
+ description: 'Reply on Reddit as the connected account — either a top-level comment on a post, or a reply to somebody’s comment. This publishes PUBLICLY under their username immediately, so show the user the exact wording and get an explicit yes BEFORE calling. Reddit judges brands harder on how they behave in comments than on what they post: answer the actual question, in plain language, and do not paste marketing copy — an account that does gets buried and can get the whole domain banned from the subreddit. parentId is a FULLNAME, not a bare id: t3_… replies to a POST (a new top-level comment), t1_… replies to a COMMENT. list_reddit_comments returns the right one on every row. 0 credits. Needs Reddit connected.',
1833
+ inputSchema: {
1834
+ parentId: z.string().describe('t3_… fullname of a post (top-level comment) or t1_… fullname of a comment (a reply to it)'),
1835
+ text: z.string().describe('the reply markdown'),
1836
+ },
1837
+ outputSchema: { ok: z.boolean().optional(), id: z.string().nullable().optional(), fullname: z.string().nullable().optional(), parentId: z.string().optional(), url: z.string().nullable().optional(), text: z.string().optional() },
1838
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1839
+ }, wrap(async (a) => {
1840
+ const d = await apiPost('/api/reddit/reply', { parentId: a.parentId, text: a.text });
1841
+ return ok(`Replied on Reddit${d.url ? ` — ${d.url}` : ''}.`, d);
1842
+ }));
1681
1843
  // ── PINTEREST (2026-07-30). Two tools on purpose: the board is a REQUIRED, user-owned choice, and a Pin on the
1682
1844
  // wrong board is a public mistake that cannot be quietly undone.
1683
1845
  server.registerTool('list_pinterest_boards', {
@@ -1753,6 +1915,108 @@ export function registerTools(rawServer, opts = {}) {
1753
1915
  if (d?.idempotentReplay) return ok(`${d.note} (Nothing was pinned a second time.)`, d);
1754
1916
  return ok(`Pinned to Pinterest${d.carousel ? ` as a ${d.slides}-slide carousel` : ''}${d.url ? ` — ${d.url}` : '.'}`, d);
1755
1917
  }));
1918
+ // ── OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either
1919
+ // again. Everything here is a WIRING gap, not a scope gap — boards:read/write and pins:read/write are all already
1920
+ // in the Pinterest grant — and every operation is read off Pinterest's OWN v5 OpenAPI description
1921
+ // (github.com/pinterest/api-description, info.version 5.28.0, read 2026-08-05), which is the only machine-readable
1922
+ // truth: developers.pinterest.com is a client-rendered SPA that returns nothing to a fetch.
1923
+ server.registerTool('list_pinterest_pins', {
1924
+ title: 'List Pins on a Pinterest board',
1925
+ description: 'The Pins on one of the account’s boards — or, with no boardId, the account’s own Pins across all of them. Each row carries the Pin id, title, description, destination link, alt text, board, creation date, and whether it HAS BEEN PROMOTED in an ad. THIS IS WHERE THE pinId EVERY OTHER PIN TOOL NEEDS COMES FROM: post_to_pinterest returns an id only at the instant it pins, so an agent that did not itself just pin had no way to name a Pin. Prefer passing a boardId — Pinterest’s own spec warns the account-wide listing has known timeouts. Read-only, 0 credits. Needs Pinterest connected.',
1926
+ inputSchema: {
1927
+ boardId: z.string().optional().describe('numeric board id from list_pinterest_boards — omit for the account’s own Pins across all boards'),
1928
+ limit: z.number().optional().describe('1–100, default 25'),
1929
+ cursor: z.string().optional().describe('the cursor a previous call returned'),
1930
+ },
1931
+ outputSchema: { boardId: z.string().nullable().optional(), count: z.number().optional(), pins: z.array(z.any()).optional(), cursor: z.string().nullable().optional() },
1932
+ annotations: { readOnlyHint: true, openWorldHint: true },
1933
+ }, wrap(async (a) => {
1934
+ const d = await apiGet('/api/pinterest/pins', { ...(a.boardId ? { boardId: a.boardId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) });
1935
+ if (!d.pins?.length) return ok(d.boardId ? 'That Pinterest board has no Pins on it.' : 'This Pinterest account has no Pins yet.', d);
1936
+ return ok(`${d.count} Pin(s)${d.boardId ? ` on board ${d.boardId}` : ''}:\n${d.pins.map(p => `• ${p.title || '(untitled)'} (id ${p.id})${p.promoted ? ' — HAS BEEN PROMOTED in an ad' : ''}${p.link ? ` → ${p.link}` : ''}`).join('\n')}${d.cursor ? '\n(more available — pass cursor)' : ''}`, d);
1937
+ }));
1938
+ // ⚠️ ON PINTEREST THE DESTRUCTIVE OPERATION IS THE RELIABLE ONE AND THE SAFE ONE IS GATED — the reverse of every
1939
+ // other connector here. `pins/update` carries, verbatim in Pinterest's own spec, "This endpoint is currently in
1940
+ // beta and not available to all apps", while `pins/delete` carries no such note. The description says so up
1941
+ // front and names the two GA remedies, because an agent that discovers this as a 403 will read it as a broken
1942
+ // connection and tell the user to reconnect — which cannot possibly fix an endpoint their app is not in the beta
1943
+ // for ([[unsourced-capability-comments]]: this limit is quoted from the vendor, not inferred).
1944
+ server.registerTool('update_pinterest_pin', {
1945
+ title: 'Edit a published Pin',
1946
+ description: 'Edit a published Pin — its title, description, destination link, alt text, or which board it sits on. Only send the fields that should change. TWO LIMITS TO STATE BEFORE OFFERING THIS. (1) Pinterest marks its Update Pin endpoint "currently in beta and not available to all apps" in its own API description, so it may be refused outright whatever the account’s scopes or access tier — reconnecting cannot change that. If it is refused, save_pinterest_pin gets the Pin onto another board (generally available) and changing the wording means deleting and re-pinning. (2) A published Pin’s IMAGE or VIDEO can never be changed by anyone: Pinterest’s update model has no media field at all, so swapping the creative means delete and re-pin, which loses the Pin’s accumulated saves. The values reported back are what Pinterest STORED, not what was sent. 0 credits. Needs Pinterest connected.',
1947
+ inputSchema: {
1948
+ pinId: z.string().describe('numeric Pin id from list_pinterest_pins'),
1949
+ title: z.string().optional().describe('max 100 characters'),
1950
+ description: z.string().optional().describe('max 800 characters — the text Pinterest search reads'),
1951
+ link: z.string().optional().describe('destination URL, max 2048'),
1952
+ altText: z.string().optional().describe('accessibility alt text, max 500'),
1953
+ boardId: z.string().optional().describe('move the Pin to this board'),
1954
+ boardSectionId: z.string().optional().describe('section within the board'),
1955
+ },
1956
+ outputSchema: { ok: z.boolean().optional(), pinId: z.string().optional(), title: z.string().optional(), description: z.string().optional(), link: z.string().nullable().optional(), altText: z.string().nullable().optional(), boardId: z.string().nullable().optional(), url: z.string().optional(), changed: z.array(z.string()).optional(), note: z.string().optional() },
1957
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1958
+ }, wrap(async (a) => {
1959
+ const d = await apiPost('/api/pinterest/pin/update', a);
1960
+ return ok(d.note, d);
1961
+ }));
1962
+ server.registerTool('save_pinterest_pin', {
1963
+ title: 'Save a Pin to another board',
1964
+ description: 'Save an existing Pin onto another of the account’s boards. This is the GENERALLY AVAILABLE way to get a Pin onto the right board — unlike update_pinterest_pin, which Pinterest keeps in a limited beta — so reach for it first when a Pin is on the wrong board. It COPIES rather than moves: Pinterest’s save endpoint creates a new Pin and the original stays where it is, so delete that one with delete_pinterest_pin if it should not be in two places. Let the USER pick the destination board (list_pinterest_boards) — a Pin on the wrong board is a public mistake. 0 credits. Needs Pinterest connected.',
1965
+ inputSchema: {
1966
+ pinId: z.string().describe('numeric Pin id'),
1967
+ boardId: z.string().describe('the board to save it to, from list_pinterest_boards — the user picks, never guess'),
1968
+ boardSectionId: z.string().optional(),
1969
+ },
1970
+ outputSchema: { ok: z.boolean().optional(), pinId: z.string().optional(), boardId: z.string().optional(), id: z.string().nullable().optional(), url: z.string().nullable().optional(), note: z.string().optional() },
1971
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1972
+ }, wrap(async (a) => {
1973
+ const d = await apiPost('/api/pinterest/pin/save', a);
1974
+ return ok(d.note, d);
1975
+ }));
1976
+ server.registerTool('delete_pinterest_pin', {
1977
+ title: 'Delete a Pin',
1978
+ description: 'PERMANENTLY delete a Pin. Pinterest has no undelete and no archive for one. Call it WITHOUT confirm first: nothing is deleted, and it answers with the Pin’s real title, its lifetime saves and impressions, and whether it HAS BEEN PROMOTED in an ad — all read back from Pinterest. Show the user that, get an unambiguous yes, then call again with confirm:true plus confirmName (its exact title) once it has saves or has been promoted, because confirming that you meant to delete SOMETHING does not prove you aimed at the right Pin. DELETING A PIN THAT AN AD PROMOTES pulls the creative out from under that ad, so check the promoted flag before agreeing. The verdict is read back from Pinterest, never taken from its 2xx. 0 credits. Needs Pinterest connected.',
1979
+ inputSchema: {
1980
+ pinId: z.string().describe('numeric Pin id from list_pinterest_pins'),
1981
+ confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent'),
1982
+ confirmName: z.string().optional().describe('the Pin’s EXACT title as the unconfirmed call reported it — required once it has saves or has been promoted'),
1983
+ },
1984
+ outputSchema: { ok: z.boolean().optional(), pinId: z.string().optional(), title: z.string().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), note: z.string().optional() },
1985
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
1986
+ }, wrap(async (a) => {
1987
+ const d = await apiPost('/api/pinterest/pin/delete', { pinId: a.pinId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) });
1988
+ return ok(d.note, d);
1989
+ }));
1990
+ server.registerTool('update_pinterest_board', {
1991
+ title: 'Rename or re-privacy a Pinterest board',
1992
+ description: 'Rename a board, rewrite its description, or change its privacy. ⚠️ SETTING A BOARD TO SECRET HIDES EVERY PIN ON IT from everyone but this account — nothing errors and nothing is deleted, the Pins simply stop being public, which is the Pinterest flavour of a post that looks published and is not. Say so and get a yes before doing it; it IS reversible (set PUBLIC again), and the read-back reports how many Pins were hidden. Pinterest accepts only PUBLIC or SECRET on an update: PROTECTED can be chosen when a board is created and can never be set afterwards, so that is refused by name rather than sent and rejected. 0 credits. Needs Pinterest connected.',
1993
+ inputSchema: {
1994
+ boardId: z.string().describe('numeric board id from list_pinterest_boards'),
1995
+ name: z.string().optional(),
1996
+ description: z.string().optional().describe('max 500 characters'),
1997
+ privacy: z.enum(['PUBLIC', 'SECRET']).optional().describe('SECRET hides every Pin on the board from everyone but this account'),
1998
+ },
1999
+ outputSchema: { ok: z.boolean().optional(), boardId: z.string().optional(), name: z.string().optional(), description: z.string().optional(), privacy: z.string().optional(), changed: z.array(z.string()).optional(), note: z.string().optional() },
2000
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2001
+ }, wrap(async (a) => {
2002
+ const d = await apiPost('/api/pinterest/board/update', a);
2003
+ return ok(d.note, d);
2004
+ }));
2005
+ server.registerTool('delete_pinterest_board', {
2006
+ title: 'Delete a Pinterest board',
2007
+ 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, update_pinterest_board(privacy:"SECRET") hides the board and every Pin on it and is REVERSIBLE — offer that first. 0 credits. Needs Pinterest connected.',
2008
+ inputSchema: {
2009
+ boardId: z.string().describe('numeric board id from list_pinterest_boards'),
2010
+ confirm: z.boolean().optional().describe('REQUIRED true — the board and its Pins are gone for good'),
2011
+ confirmName: z.string().optional().describe('the board’s EXACT name as the unconfirmed call reported it'),
2012
+ confirmChildren: z.number().optional().describe('the number of Pins the unconfirmed call reported on the board'),
2013
+ },
2014
+ outputSchema: { ok: z.boolean().optional(), boardId: z.string().optional(), name: z.string().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), note: z.string().optional() },
2015
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2016
+ }, wrap(async (a) => {
2017
+ const d = await apiPost('/api/pinterest/board/delete', { boardId: a.boardId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}), ...(a.confirmChildren != null ? { confirmChildren: a.confirmChildren } : {}) });
2018
+ return ok(d.note, d);
2019
+ }));
1756
2020
  // ── PINTEREST DEEP ANALYTICS (2026-08-05) ──────────────────────────────────────────────────────────────────────
1757
2021
  // The async report reaches 914 days where the synchronous one stops at 90; targeting analytics and audience
1758
2022
  // insights had never been built at all. Everything read from Pinterest's own OpenAPI (info.version 5.28.0).
@@ -2232,14 +2496,29 @@ export function registerTools(rawServer, opts = {}) {
2232
2496
  }));
2233
2497
  server.registerTool('list_tiktok_videos', {
2234
2498
  title: 'List the connected account’s TikTok posts',
2235
- description: 'List the connected account’s own recent PUBLIC TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. Use it for “how did our last TikToks do”, “which of our videos performed best”, or to pick a reference before making a new ad. Only ever the connected user’s OWN videos. Read-only. Needs TikTok connected.',
2236
- inputSchema: { limit: z.number().optional().describe('1-20, default 10') },
2237
- outputSchema: { videos: z.array(z.object({ id: z.string().nullable().optional(), title: z.string().optional(), durationSeconds: z.number().nullable().optional(), cover: z.string().nullable().optional(), url: z.string().nullable().optional(), postedAt: z.string().nullable().optional(), views: z.number().nullable().optional(), likes: z.number().nullable().optional(), comments: z.number().nullable().optional(), shares: z.number().nullable().optional() })).optional(), cursor: z.number().nullable().optional(), hasMore: z.boolean().optional() },
2499
+ // TWO READS, ONE TOOL (videoIds TikTok's video/query, otherwise video/list). Splitting them would leave a
2500
+ // caller paging through the account for a post whose id it is already holding — which is exactly what
2501
+ // collect_post_metrics used to do, reporting anything older than the last 20 posts as "still processing".
2502
+ //
2503
+ // THE NO-DELETE FACT BELONGS HERE because there is nowhere else to put it: TikTok publishes no delete and no
2504
+ // update endpoint for a published post ANYWHERE in its API (the whole Content Posting surface is 8 pages and
2505
+ // the Display API 4; there is no delete scope in developers.tiktok.com/doc/tiktok-api-scopes at all, read
2506
+ // 2026-08-05). An agent asked to take a TikTok down must be able to say so without a failed round trip.
2507
+ description: 'The connected account’s own TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. TWO WAYS TO ASK: with no arguments it lists the most recent (newest first, up to 20 a page); with videoIds it reads THOSE posts directly however old they are, which is how you answer "how did that specific video do" without paging back through the account. Any id TikTok does not return comes back under `unresolved` — meaning it is not on this account or no longer exists, which TikTok does not distinguish — never as a zero. Only ever the connected user’s OWN videos. ⚠️ TIKTOK OFFERS NO WAY TO DELETE OR EDIT A PUBLISHED POST through its API — not the caption, not the privacy level, not the comment/duet/stitch settings, not the cover. Every one of those is fixed at the moment of publishing. If the user wants a TikTok changed or taken down, tell them plainly that it has to be done in the TikTok app; do not look for a tool for it. Read-only, 0 credits. Needs TikTok connected.',
2508
+ inputSchema: {
2509
+ limit: z.number().optional().describe('1-20, default 10 (ignored when videoIds is given)'),
2510
+ videoIds: z.array(z.string()).optional().describe('read these specific TikTok video ids instead of listing recent ones — up to 20 per call'),
2511
+ },
2512
+ outputSchema: { videos: z.array(z.object({ id: z.string().nullable().optional(), title: z.string().optional(), durationSeconds: z.number().nullable().optional(), cover: z.string().nullable().optional(), url: z.string().nullable().optional(), postedAt: z.string().nullable().optional(), views: z.number().nullable().optional(), likes: z.number().nullable().optional(), comments: z.number().nullable().optional(), shares: z.number().nullable().optional() })).optional(), cursor: z.number().nullable().optional(), hasMore: z.boolean().optional(), unresolved: z.array(z.string()).optional() },
2238
2513
  annotations: { readOnlyHint: true, openWorldHint: true },
2239
2514
  }, wrap(async (a) => {
2240
- const d = await apiGet('/api/tiktok/videos', a);
2515
+ const ids = (a.videoIds || []).filter(Boolean);
2516
+ const d = await apiGet('/api/tiktok/videos', ids.length ? { videoIds: ids.join(',') } : (a.limit ? { limit: a.limit } : {}));
2241
2517
  const rows = (d.videos || []).map((v, i) => `${i + 1}. ${(v.title || '(no caption)').slice(0, 70)} — ${v.views ?? '?'} views, ${v.likes ?? '?'} likes${v.url ? ` — ${v.url}` : ''}`);
2242
- return ok(rows.length ? `${rows.length} recent TikTok post(s)${d.hasMore ? ' (more available)' : ''}:\n${rows.join('\n')}` : 'No public videos on that TikTok account yet.', d);
2518
+ // An id TikTok did not return is STATED, never silently dropped otherwise a short list reads as the whole answer.
2519
+ const missing = (d.unresolved || []).length ? `\nNOT RETURNED by TikTok (not on this account, or gone): ${d.unresolved.join(', ')}` : '';
2520
+ if (!rows.length) return ok(ids.length ? `TikTok returned nothing for ${ids.length === 1 ? 'that video id' : 'those video ids'} — not on this account, or no longer there. TikTok does not say which.` : 'No public videos on that TikTok account yet.', d);
2521
+ return ok(`${rows.length} TikTok post(s)${d.hasMore ? ' (more available)' : ''}:\n${rows.join('\n')}${missing}`, d);
2243
2522
  }));
2244
2523
  server.group('ads');
2245
2524
  server.registerTool('upload_meta_asset', {
@@ -2746,8 +3025,8 @@ export function registerTools(rawServer, opts = {}) {
2746
3025
  return ok(d.note, d);
2747
3026
  }));
2748
3027
  server.registerTool('set_google_ads_targeting', {
2749
- title: 'Set Google Ads location & language targeting',
2750
- description: 'Set WHERE and in what LANGUAGE an existing Google Ads campaign runs. Pass locations by NAME ("United States", "California", "Toronto") — they are resolved to Google\'s geo target ids for you; excludedLocations blocks places; languages takes ISO codes ("en","fr"). A campaign with NO location targeting runs WORLDWIDE, which is the most expensive default in Google Ads. Changing a LIVE campaign\'s targeting moves real spend immediately, so that needs confirm:true.',
3028
+ title: 'Add Google Ads location & language targeting',
3029
+ description: 'ADD locations and languages to an existing Google Ads campaign. THIS ADDS; IT DOES NOT REPLACE — Google campaign criteria are a list, this call only ever creates entries, and there is no remove operation here. So a campaign already targeting the United States that you "change to Canada" ends up targeting BOTH and still spending in the US; the read-back names every pre-existing location and language it kept, and you MUST relay that rather than reporting the new total as the answer. Removing targeting is done in Google Ads (Campaign ▸ Settings ▸ Locations). Pass locations by NAME ("United States", "California", "Toronto") — they are resolved to Google\'s geo target ids for you; excludedLocations adds a NEGATIVE criterion (the reliable way to stop serving somewhere from here); languages takes ISO codes ("en","fr"). A campaign with NO location targeting runs WORLDWIDE, which is the most expensive default in Google Ads. Changing a LIVE campaign\'s targeting moves real spend immediately, so that needs confirm:true.',
2751
3030
  inputSchema: {
2752
3031
  customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
2753
3032
  campaignId: z.string().describe('the campaign to target'),
@@ -3376,16 +3655,113 @@ export function registerTools(rawServer, opts = {}) {
3376
3655
  annotations: { openWorldHint: true },
3377
3656
  }, wrap(async (a) => { const d = await apiPost('/api/x-ads/campaign', a); return ok(d.note, d); }));
3378
3657
  server.registerTool('set_x_ads_status', {
3379
- title: 'Pause or activate an X ads campaign',
3380
- description: 'Pause or ACTIVATE an X ads campaign. ACTIVATING STARTS REAL SPEND on the next auction, so it requires confirm:true — this is the only switch on X that arms money. Tell the user the budget and what will start spending BEFORE you pass confirm. The result is read back from X rather than assumed.',
3658
+ title: 'Pause or activate an X campaign or line item',
3659
+ description: 'Pause or ACTIVATE an X ads CAMPAIGN (campaignId) or ONE LINE ITEM inside it (lineItemId) — pass exactly one. ACTIVATING STARTS REAL SPEND on the next auction, so it requires confirm:true — this is the only switch on X that arms money. Tell the user the budget and what will start spending BEFORE you pass confirm. DELIVERY ON X IS THE AND OF BOTH LEVELS: an ACTIVE line item under a PAUSED campaign serves nothing, so the result reads the PARENT back too and states whether anything can actually spend rather than letting you infer it. Pausing a line item is the REVERSIBLE way to take one ad group out of delivery — deleting it is not.',
3381
3660
  inputSchema: {
3382
- accountId: z.string(), campaignId: z.string(),
3661
+ accountId: z.string(),
3662
+ campaignId: z.string().optional().describe('the whole campaign — pass this OR lineItemId'),
3663
+ lineItemId: z.string().optional().describe('one ad group — pass this OR campaignId'),
3383
3664
  status: z.enum(['ACTIVE', 'PAUSED']),
3384
3665
  confirm: z.boolean().optional().describe('required to set ACTIVE — real money'),
3385
3666
  },
3386
- outputSchema: { campaignId: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
3667
+ outputSchema: { campaignId: z.string().nullable().optional(), lineItemId: z.string().nullable().optional(), status: z.string().optional(), parentStatus: z.string().nullable().optional(), note: z.string().optional() },
3387
3668
  annotations: { openWorldHint: true },
3388
3669
  }, wrap(async (a) => { const d = await apiPost('/api/x-ads/status', a); return ok(d.note, d); }));
3670
+ // ── X: managing what you built — update, remove, and the reads both depend on (2026-08-05) ────────────────────
3671
+ // Hermoso could build an X campaign and activate it and then change NOTHING about it, and could remove nothing at
3672
+ // all: X was the eighth ad platform in the product and the only one with no removal path. Every field offered
3673
+ // below was PROVEN to move on a real object, because X silently ignores a parameter it will not apply — so a
3674
+ // freely-forwarded update answers 200 having changed nothing.
3675
+ server.registerTool('update_x_ads_campaign', {
3676
+ title: 'Change an X campaign budget or settings',
3677
+ description: "Change a LIVE X campaign — budget, name, delivery pacing. THIS IS HOW YOU THROTTLE OR RAISE SPEND on a running campaign without rebuilding it, and lowering dailyBudget is the fastest way to slow money down short of pausing. Budgets are in the ad account's own currency. Only fields X actually applies are offered: startTime/endTime are DEPRECATED on an X campaign (its schedule lives on the LINE ITEM — use update_x_ads_line_item) and frequency capping needs an X account feature we cannot enable, so both are refused BY NAME with the reason instead of being sent and silently ignored. THE READ-BACK IS A DIFF against the before-state: a field X did not move is reported as REFUSED, never counted as applied. Print the returned note verbatim.",
3678
+ inputSchema: {
3679
+ accountId: z.string(), campaignId: z.string().describe('from list_x_ads_campaigns'),
3680
+ name: z.string().optional(),
3681
+ dailyBudget: z.number().optional().describe("in the ad account's currency"),
3682
+ totalBudget: z.number().optional(),
3683
+ standardDelivery: z.boolean().optional().describe('false = accelerated: spend the budget as fast as the auction allows'),
3684
+ purchaseOrderNumber: z.string().optional(),
3685
+ },
3686
+ outputSchema: { accountId: z.string().optional(), campaignId: z.string().optional(), updated: z.array(z.string()).optional(), changed: z.number().optional(), status: z.string().nullable().optional(), note: z.string().optional() },
3687
+ annotations: { openWorldHint: true },
3688
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/campaign-update', a); return ok(d.note, d); }));
3689
+ server.registerTool('update_x_ads_line_item', {
3690
+ title: 'Change an X line item (ad group)',
3691
+ description: "Change an X line item — BID, bid strategy, SCHEDULE, goal or name. An X campaign's start and end times are deprecated, so the schedule genuinely lives HERE. Lowering `bidAmount` is also the fix when X refuses a campaign budget with “Bid is too close to Budget”. NOT changeable after creation: `objective` and `productType` — X answers 200 and silently keeps the old value (measured), so both are refused BY NAME here and a different objective means a NEW line item. Changing `goal` also requires `bidAmount` (X refuses the goal alone) and that is stated up front rather than relayed. A line-item dailyBudget/totalBudget is only legal when the parent campaign is NOT budget-optimised — that is the campaign's setting, so those two are forwarded and X's own refusal names the remedy rather than Hermoso blocking a legal edit. THE READ-BACK IS A DIFF against an independent re-read: a field X accepted but did not move is reported as REFUSED. Print the returned note verbatim.",
3692
+ inputSchema: {
3693
+ accountId: z.string(), lineItemId: z.string().describe('from list_x_ads_line_items'),
3694
+ name: z.string().optional(),
3695
+ bidAmount: z.number().optional().describe("in the ad account's currency"),
3696
+ bidStrategy: z.enum(['AUTO', 'GUARANTEED', 'MAX', 'TARGET']).optional(),
3697
+ goal: z.enum(['APP_CLICKS', 'APP_INSTALLS', 'APP_PURCHASES', 'ENGAGEMENT', 'FOLLOWERS', 'LINK_CLICKS', 'MAX_REACH', 'PREROLL', 'PREROLL_STARTS', 'REACH_WITH_ENGAGEMENT', 'SITE_VISITS', 'SOCIAL_ENGAGEMENT', 'VIDEO_VIEW', 'VIEW_15S', 'VIEW_3S_100PCT', 'VIEW_6S', 'WEBSITE_CONVERSIONS', 'WEBSITE_CONVERSIONS_V2']).optional().describe('requires bidAmount too'),
3698
+ startTime: z.string().optional().describe('ISO 8601'), endTime: z.string().optional(),
3699
+ dailyBudget: z.number().optional().describe('only if the campaign is not budget-optimised'),
3700
+ totalBudget: z.number().optional().describe('only if the campaign is not budget-optimised'),
3701
+ },
3702
+ outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), updated: z.array(z.string()).optional(), changed: z.number().optional(), status: z.string().nullable().optional(), note: z.string().optional() },
3703
+ annotations: { openWorldHint: true },
3704
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/line-item-update', a); return ok(d.note, d); }));
3705
+ server.registerTool('list_x_ads_promoted_tweets', {
3706
+ title: 'List X promoted posts',
3707
+ description: "List the promoted posts (the CREATIVES) attached to an X line item, or across the whole ad account. Two things only this can tell you: each post's APPROVAL STATUS, so an ad X rejected — which can never serve however the statuses are set — is visible rather than mysterious; and the promoted-tweet ID, which is the only way to remove one. NOTE a promoted post cannot be PAUSED on X (its PUT accepts only an approval appeal), so the reversible way to stop it is to pause its LINE ITEM. Read-only, free.",
3708
+ inputSchema: { accountId: z.string(), lineItemId: z.string().optional().describe('scope to one line item'), limit: z.number().optional() },
3709
+ outputSchema: { accountId: z.string().optional(), count: z.number().optional(), promotedTweets: z.array(z.any()).optional(), note: z.string().optional() },
3710
+ annotations: { readOnlyHint: true, openWorldHint: true },
3711
+ }, wrap(async (a) => {
3712
+ const d = await apiGet('/api/x-ads/promoted-tweets', a);
3713
+ return ok(`${d.note}\n${(d.promotedTweets || []).map(p => `• promoted-tweet ${p.id} — post ${p.tweetId}, ${p.approvalStatus} (line item ${p.lineItemId})`).join('\n')}`, d);
3714
+ }));
3715
+ server.registerTool('list_x_ads_targeting', {
3716
+ title: 'List an X line item’s targeting',
3717
+ description: "List every targeting criterion an X line item carries, WITH THE ID of each — the id needed to remove one with delete_x_ads_object. READ THE EMPTY CASE CORRECTLY: no targeting criteria on X means the line item is UNRESTRICTED and will reach the broadest possible audience once ACTIVE — it does NOT mean it cannot serve. Read-only, free.",
3718
+ inputSchema: { accountId: z.string(), lineItemId: z.string() },
3719
+ outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), count: z.number().optional(), targeting: z.array(z.any()).optional(), note: z.string().optional() },
3720
+ annotations: { readOnlyHint: true, openWorldHint: true },
3721
+ }, wrap(async (a) => {
3722
+ const d = await apiGet('/api/x-ads/targeting', a);
3723
+ return ok(`${d.note}\n${(d.targeting || []).map(t => `• ${t.targetingType} ${t.name || t.targetingValue} — id ${t.id}`).join('\n')}`, d);
3724
+ }));
3725
+ server.registerTool('list_x_ads_funding_instruments', {
3726
+ title: 'List X ad account payment methods',
3727
+ description: "List the funding instruments (payment methods) on an X ad account — type, currency, credit limit, credit remaining, and whether each can currently fund a campaign. THIS IS THE ANSWER TO “why is my X campaign not delivering?” whenever the cause is a cancelled card or an exhausted credit line, which is invisible from the campaign itself, and it shows what a campaign will spend against BEFORE anyone activates it. Hermoso cannot add a payment method — that is done at ads.x.com. Read-only, free.",
3728
+ inputSchema: { accountId: z.string() },
3729
+ outputSchema: { accountId: z.string().optional(), count: z.number().optional(), usableCount: z.number().optional(), fundingInstruments: z.array(z.any()).optional(), note: z.string().optional() },
3730
+ annotations: { readOnlyHint: true, openWorldHint: true },
3731
+ }, wrap(async (a) => { const d = await apiGet('/api/x-ads/funding-instruments', a); return ok(d.note, d); }));
3732
+ server.registerTool('x_ads_targeting_search', {
3733
+ title: 'Find X targeting ids (interests, devices, languages…)',
3734
+ description: "Resolve X targeting ids by name for every vocabulary BEYOND location — INTEREST, CONVERSATION, PLATFORM, DEVICE, LANGUAGE, APP_STORE_CATEGORY, NETWORK_OPERATOR, TV_MARKET, TV_SHOW, EVENT. X's targeting ids are opaque (an interest is a 19-digit number, a device is “96”) and X has NO name-based targeting parameter, so this is the only way to obtain one — without it add_x_ads_targeting accepts vocabularies nobody can supply a value for. Pass a result as criteria:[{targetingType, targetingValue}]. Locations have their own tool: x_ads_geo_search. TV_SHOW requires a `locale`, which comes from kind:\"TV_MARKET\". Read-only, free.",
3735
+ inputSchema: {
3736
+ kind: z.enum(['INTEREST', 'CONVERSATION', 'PLATFORM', 'DEVICE', 'LANGUAGE', 'APP_STORE_CATEGORY', 'NETWORK_OPERATOR', 'TV_MARKET', 'TV_SHOW', 'EVENT']),
3737
+ query: z.string().optional().describe('filter by name'),
3738
+ osType: z.enum(['ANDROID', 'IOS']).optional().describe('APP_STORE_CATEGORY'),
3739
+ countryCode: z.string().optional().describe('NETWORK_OPERATOR, e.g. US'),
3740
+ locale: z.string().optional().describe('TV_SHOW — required; get one from kind:"TV_MARKET"'),
3741
+ eventTypes: z.enum(['CONFERENCE', 'HOLIDAY', 'MOVIE_RELEASE', 'MUSIC_AND_ENTERTAINMENT', 'OLYMPICS', 'OTHER', 'POLITICS', 'RECURRING', 'SPORTS']).optional().describe('EVENT'),
3742
+ limit: z.number().optional(),
3743
+ },
3744
+ outputSchema: { kind: z.string().optional(), count: z.number().optional(), returnedByX: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
3745
+ annotations: { readOnlyHint: true, openWorldHint: true },
3746
+ }, wrap(async (a) => {
3747
+ const d = await apiGet('/api/x-ads/targeting-search', a);
3748
+ return ok(`${d.note}\n${(d.results || []).map(t => `• ${t.name} — id ${t.id}`).join('\n')}`, d);
3749
+ }));
3750
+ server.registerTool('delete_x_ads_object', {
3751
+ title: 'Delete an X ads object (permanent, cascades)',
3752
+ description: "PERMANENTLY delete an X campaign, line item, promoted post or targeting criterion. X CASCADES AND PUBLISHES NO UNDO: deleting a campaign destroys its line items and their promoted posts too — verified live, the children answer 404 the moment the parent is deleted. RUN IT WITHOUT confirm FIRST: that deletes nothing and reports the REAL blast radius read back off X (what is underneath it, whether it is live, whether it has spent). Show the user exactly that, get an unambiguous yes, then call again with confirm:true — and, for anything with children / live delivery / real spend, also confirmName set to its exact name and confirmChildren set to the real count, because confirm:true alone proves you meant to delete SOMETHING and cannot prove you aimed at the right object. TO STOP DELIVERY WITHOUT DESTROYING ANYTHING use set_x_ads_status PAUSED, which is reversible — except for a promoted post, which cannot be paused on X at all, so pause its LINE ITEM instead. Removing a targeting criterion WIDENS the audience rather than narrowing it. The result is confirmed by re-reading the object, never by X's 200.",
3753
+ inputSchema: {
3754
+ accountId: z.string(),
3755
+ type: z.enum(['campaign', 'line_item', 'promoted_tweet', 'targeting_criterion']),
3756
+ id: z.string().describe('campaign/line-item id, or the id from list_x_ads_promoted_tweets / list_x_ads_targeting'),
3757
+ lineItemId: z.string().optional().describe('REQUIRED for type "targeting_criterion" — X cannot look one up without its line item'),
3758
+ confirm: z.boolean().optional().describe('omit on the first call to see the blast radius'),
3759
+ confirmName: z.string().optional().describe("the target's exact name — required once it has children, is live, or has spent"),
3760
+ confirmChildren: z.number().optional().describe('the real number of children, from the unconfirmed call'),
3761
+ },
3762
+ outputSchema: { ok: z.boolean().optional(), platform: z.string().optional(), type: z.string().optional(), id: z.string().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), blastRadius: z.any().optional(), note: z.string().optional() },
3763
+ annotations: { destructiveHint: true, openWorldHint: true },
3764
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/delete', a); return ok(d.note, d); }));
3389
3765
  // ── X: the rest of the tree (2026-08-05) ──────────────────────────────────────────────────────────────────────
3390
3766
  // campaign → LINE ITEM → PROMOTED POST. A campaign on its own can never serve, so until these landed
3391
3767
  // create_x_ads_campaign was offerable-and-undeliverable. Every enum below was read off the LIVE API by sending an
@@ -3957,16 +4333,14 @@ export function registerTools(rawServer, opts = {}) {
3957
4333
  return ok(`${d.note} Pass postId:"${d.id}" to create_reddit_ads_ad.`, d);
3958
4334
  }));
3959
4335
  server.registerTool('update_reddit_ads_post', {
3960
- title: 'Edit a Reddit ad post',
3961
- description: 'Edit an existing Reddit ad post’s headline, body or comment setting. The post is already public, so an edit is publicly visible show the user the exact new text first. A REDDIT AD POST CANNOT BE REMOVED THROUGH THE API AT ALL: Reddit publishes no delete endpoint for one and its update schema has no status, archived or deleted field (re-checked against Reddit’s own reference on 2026-08-05), so creating one is a one-way door and the only way to take it down is Reddit’s Ads Manager. Say that plainly rather than offering to delete it.',
4336
+ title: 'Turn comments on or off on a Reddit ad post',
4337
+ description: 'Turn comments ON or OFF on an existing Reddit ad post. THAT IS THE ONLY EDIT REDDIT ALLOWS: its post-update schema permits exactly one field, `allow_comments`, and REQUIRES it — headline and body both answer “Additional fields not permitted” once a post is published (measured live 2026-08-05). So a copy change is not an edit at all: create a new post with create_reddit_ads_post and point the ad at it with update_reddit_ads_ad, or fix the wording in Reddit’s Ads Manager. Never promise to reword a live post. A REDDIT AD POST CANNOT BE REMOVED THROUGH THE API AT ALL: Reddit publishes no delete endpoint for one and its update schema has no status, archived or deleted field (re-checked against Reddit’s own reference on 2026-08-05), so creating one is a one-way door and the only way to take it down is Reddit’s Ads Manager. Say that plainly rather than offering to delete it. Turning comments off is publicly visible on a post people may already be replying to, so confirm it with the user first.',
3962
4338
  inputSchema: {
3963
4339
  adAccountId: z.string().optional(),
3964
4340
  postId: z.string().describe('the post id (t3_…)'),
3965
- headline: z.string().optional(),
3966
- body: z.string().optional(),
3967
- allowComments: z.boolean().optional(),
4341
+ allowComments: z.boolean().describe('REQUIRED — Reddit demands allow_comments on every post update, and it is the only field it permits'),
3968
4342
  },
3969
- outputSchema: { id: z.string().optional(), headline: z.string().optional(), note: z.string().optional() },
4343
+ outputSchema: { id: z.string().optional(), headline: z.string().optional(), allowComments: z.boolean().optional(), note: z.string().optional() },
3970
4344
  annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
3971
4345
  }, wrap(async (a) => {
3972
4346
  const d = await apiPost('/api/reddit-ads/posts/update', a);
@@ -4674,7 +5048,7 @@ export function registerTools(rawServer, opts = {}) {
4674
5048
  }, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-delete', a); return ok(d.note, d); }));
4675
5049
  server.registerTool('update_meta_object', {
4676
5050
  title: 'Edit a Meta campaign / ad set / ad',
4677
- description: 'Update an EXISTING campaign, ad set, or ad — rename, change its daily budget, retarget (ad sets), or change status (PAUSED / ACTIVE / ARCHIVED). Pass objectId (from list_meta_ads) + adAccountId. Setting something ACTIVE can start REAL AD SPEND — show the user what will run + its budget, get a yes, then pass confirm:true. Pausing / renaming / archiving is always safe.',
5051
+ description: 'Update an EXISTING campaign, ad set, or ad — rename, change its daily budget, retarget (ad sets), or change status (PAUSED / ACTIVE / ARCHIVED). Pass objectId (from list_meta_ads) + adAccountId. Setting something ACTIVE can start REAL AD SPEND — show the user what will run + its budget, get a yes, then pass confirm:true. Pausing and renaming are always safe and always reversible. ARCHIVING IS NOT: Meta treats an archived object as DELETED and refuses to bring it back — every later edit answers "This campaign has been deleted, so you can only edit the name" (measured live 2026-08-05), archiving a campaign takes its ad sets and ads down with it, and the only way back is to duplicate it as a new object. Use PAUSED unless the user has said they are finished with it for good.',
4678
5052
  inputSchema: {
4679
5053
  objectId: z.string().describe('the campaign / ad set / ad id (from list_meta_ads)'),
4680
5054
  adAccountId: z.string().describe('ad account id (for auth + scope)'),
@@ -4907,6 +5281,107 @@ export function registerTools(rawServer, opts = {}) {
4907
5281
  return ok(`Read “${d.title || 'the doc'}” (${(d.text || '').length} chars):\n${(d.text || '').slice(0, 8000)}`, d);
4908
5282
  }));
4909
5283
 
5284
+ // ---------- The Sheets / Docs WRITE surface (2026-08-05) ----------
5285
+ // Append-only was the tell: a user could add a row forever and never CORRECT one. Everything here runs on the SAME
5286
+ // `drive.file` grant already held — verified live against a token holding drive.file and nothing else — so no new
5287
+ // scope, no second consent screen, and no re-triggered Google verification. Every destructive op is gated
5288
+ // SERVER-side on its real blast radius, so these descriptions are telling the caller what will happen, not
5289
+ // enforcing it.
5290
+ server.group('files');
5291
+ server.registerTool('list_sheet_tabs', {
5292
+ title: 'List the tabs in a Google Sheet',
5293
+ description: 'The tabs in a Google Spreadsheet, each with its name, numeric sheetId, row/column count and position. Call this BEFORE naming a tab in update_sheet / clear_sheet_range / manage_sheet_tabs / format_sheet, and before proposing to delete one — it is how you learn what the file actually contains instead of guessing at a name. Read-only, free.',
5294
+ inputSchema: {
5295
+ spreadsheetId: z.string().optional().describe('the spreadsheet id (from create_sheet, or list_drive_files for one the user picked)'),
5296
+ sheetUrl: z.string().optional().describe('a Google Sheets URL — the id is extracted from it'),
5297
+ },
5298
+ outputSchema: { ok: z.boolean().optional(), spreadsheetId: z.string().optional(), title: z.string().optional(), url: z.string().optional(), count: z.number().optional(),
5299
+ tabs: z.array(z.object({ sheetId: z.number().optional(), title: z.string().optional(), index: z.number().optional(), rows: z.number().optional(), columns: z.number().optional() })).optional() },
5300
+ annotations: { readOnlyHint: true, openWorldHint: true },
5301
+ }, wrap(async (a) => {
5302
+ const d = await apiGet('/api/sheets/tabs', { spreadsheetId: a.spreadsheetId, sheetUrl: a.sheetUrl });
5303
+ return ok(`“${d.title}” has ${d.count} tab${d.count === 1 ? '' : 's'}: ${(d.tabs || []).map(t => `“${t.title}” (sheetId ${t.sheetId}, ${t.rows}×${t.columns})`).join(', ')}.`, d);
5304
+ }));
5305
+ server.registerTool('update_sheet', {
5306
+ title: 'Write to a range in a Google Sheet',
5307
+ description: 'CORRECT cells in a Google Sheet — write values to an exact range, overwriting whatever is there. This is the fix append_to_sheet cannot make: appending only ever adds rows at the bottom, so without this a wrong number stays wrong forever and the only "correction" is a second row contradicting the first. Pass `range` (e.g. "B2:C5", or "Q3 Report!B2" to name a tab — list_sheet_tabs gives the names) and `values` as an array of row arrays; an anchor cell like "B2" is fine and the block is written down and right from it. Writing into EMPTY cells goes straight through. Writing OVER cells that already hold values is REFUSED first, naming exactly how many filled cells would be overwritten — show the user that, get a yes, then call again with confirm:true. The result is READ BACK from the sheet, so what you report is what the sheet now holds rather than what Google accepted.',
5308
+ inputSchema: {
5309
+ spreadsheetId: z.string().optional(), sheetUrl: z.string().optional(),
5310
+ range: z.string().optional().describe('A1 range or anchor cell, e.g. "B2:C5", "B2", or "Q3 Report!B2" (default A1)'),
5311
+ values: z.array(z.array(z.union([z.string(), z.number(), z.boolean()]))).optional().describe('array of row arrays to write'),
5312
+ updates: z.array(z.object({ range: z.string().optional(), values: z.array(z.array(z.union([z.string(), z.number(), z.boolean()]))).optional() })).optional().describe('write SEVERAL disjoint ranges in one call, instead of range+values'),
5313
+ valueInputOption: z.enum(['USER_ENTERED', 'RAW']).optional().describe('USER_ENTERED (default) parses formulas, dates and numbers the way typing them would; RAW stores every value as literal text'),
5314
+ confirm: z.boolean().optional().describe('required only when the target range already holds values'),
5315
+ },
5316
+ outputSchema: { ok: z.boolean().optional(), spreadsheetId: z.string().optional(), url: z.string().optional(), updated: z.array(z.string()).optional(), overwrote: z.number().optional(), verified: z.boolean().optional(), values: z.any().optional(), note: z.string().optional() },
5317
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5318
+ }, wrap(async (a) => {
5319
+ const d = await apiPost('/api/sheets/update', a);
5320
+ return ok(d.note, d);
5321
+ }));
5322
+ server.registerTool('clear_sheet_range', {
5323
+ title: 'Clear a range in a Google Sheet',
5324
+ description: 'Empty a range of cells in a Google Sheet, leaving the rows themselves in place. DESTRUCTIVE: call it WITHOUT confirm first and nothing is cleared — you get back the real number of filled cells in that exact range. Show the user that number, get an unambiguous yes, then call again with confirm:true AND confirmCells set to it. The echo is not ceremony: it is what catches naming A1:Z1000 when you meant A1:Z10, which is the mistake that actually happens. There is deliberately NO default range. The clear is read back and reported as confirmed only if the range really is empty afterwards. To remove a whole tab instead, use manage_sheet_tabs.',
5325
+ inputSchema: {
5326
+ spreadsheetId: z.string().optional(), sheetUrl: z.string().optional(),
5327
+ range: z.string().describe('the range to clear, e.g. "A2:D50" or "Sheet1!A2:D50"'),
5328
+ confirm: z.boolean().optional(), confirmCells: z.number().optional().describe('echo back the filled-cell count the unconfirmed call reported'),
5329
+ },
5330
+ outputSchema: { ok: z.boolean().optional(), spreadsheetId: z.string().optional(), url: z.string().optional(), range: z.string().optional(), cleared: z.number().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
5331
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5332
+ }, wrap(async (a) => {
5333
+ const d = await apiPost('/api/sheets/clear', a);
5334
+ return ok(d.note, d);
5335
+ }));
5336
+ server.registerTool('manage_sheet_tabs', {
5337
+ title: 'Add, rename or delete a sheet tab',
5338
+ description: 'Add, rename or delete a tab in a Google Spreadsheet. action:"add" + title · action:"rename" + tab + newTitle · action:"delete" + tab. Name the tab by its TITLE or its numeric sheetId (list_sheet_tabs gives both); an unknown tab is refused with the real list rather than a Google error nobody can map back. DELETING a tab destroys everything on it: call it without confirm first to get the filled-cell count, then confirm:true + confirmCells. Google does not allow removing the LAST remaining tab in a file, and that is refused by name with the way out (clear it, or delete the whole file with delete_drive_file). Every action is read back from the spreadsheet before it is reported as done.',
5339
+ inputSchema: {
5340
+ spreadsheetId: z.string().optional(), sheetUrl: z.string().optional(),
5341
+ action: z.enum(['add', 'rename', 'delete']),
5342
+ tab: z.string().optional().describe('which tab — its title or numeric sheetId (rename / delete)'),
5343
+ title: z.string().optional().describe('the name for the new tab (action:"add")'),
5344
+ newTitle: z.string().optional().describe('what to rename the tab to (action:"rename")'),
5345
+ confirm: z.boolean().optional(), confirmCells: z.number().optional(),
5346
+ },
5347
+ outputSchema: { ok: z.boolean().optional(), spreadsheetId: z.string().optional(), action: z.string().optional(), sheetId: z.number().optional(), title: z.string().optional(), was: z.string().optional(), destroyed: z.number().optional(), url: z.string().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
5348
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
5349
+ }, wrap(async (a) => {
5350
+ const d = await apiPost('/api/sheets/tab', a);
5351
+ return ok(d.note, d);
5352
+ }));
5353
+ server.registerTool('format_sheet', {
5354
+ title: 'Format a Google Sheet',
5355
+ description: 'Make an exported sheet readable: bold the header row, FREEZE it so it stays visible while scrolling, and auto-size the columns so nothing is cut off. Worth calling right after create_sheet — a raw export with unsized columns and a header that scrolls away is the difference between a spreadsheet someone reads and one they close. Changes no cell VALUE, so it is never gated. The defaults do all three on the first tab; pass `tab` to pick another, freezeRows:0 to skip freezing, boldHeader:false or autoResize:false to skip those.',
5356
+ inputSchema: {
5357
+ spreadsheetId: z.string().optional(), sheetUrl: z.string().optional(),
5358
+ tab: z.string().optional().describe('tab title or numeric sheetId (default: the first tab)'),
5359
+ boldHeader: z.boolean().optional(), freezeRows: z.number().optional().describe('how many top rows to freeze (default 1, 0 = none)'), autoResize: z.boolean().optional(),
5360
+ },
5361
+ outputSchema: { ok: z.boolean().optional(), spreadsheetId: z.string().optional(), tab: z.string().optional(), sheetId: z.number().optional(), url: z.string().optional(), applied: z.array(z.string()).optional(), note: z.string().optional() },
5362
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
5363
+ }, wrap(async (a) => {
5364
+ const d = await apiPost('/api/sheets/format', a);
5365
+ return ok(d.note, d);
5366
+ }));
5367
+ server.registerTool('update_doc', {
5368
+ title: 'Edit a Google Doc in place',
5369
+ description: 'EDIT a Google Doc — the correction append_to_doc cannot make, which until now meant a doc could only ever grow and a wrong line stayed in it forever. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:"…"` replaces the ENTIRE body (rewrite:"" empties it). Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.',
5370
+ inputSchema: {
5371
+ documentId: z.string().optional().describe('the document id (from create_doc, or list_drive_files for one the user picked)'),
5372
+ docUrl: z.string().optional().describe('a Google Docs URL — the id is extracted from it'),
5373
+ replacements: z.array(z.object({ find: z.string(), replace: z.string().optional(), matchCase: z.boolean().optional() })).optional().describe('find/replace pairs, applied in order'),
5374
+ rewrite: z.string().optional().describe('replace the WHOLE body with this text ("" empties the doc)'),
5375
+ confirm: z.boolean().optional(), confirmCells: z.number().optional().describe('echo back the character count the unconfirmed call reported (rewrite only)'),
5376
+ },
5377
+ outputSchema: { ok: z.boolean().optional(), documentId: z.string().optional(), title: z.string().optional(), url: z.string().optional(), occurrences: z.number().optional(), replacedChars: z.number().optional(), verified: z.boolean().nullable().optional(), text: z.string().nullable().optional(),
5378
+ replacements: z.array(z.object({ find: z.string().optional(), replace: z.string().optional(), occurrences: z.number().optional() })).optional(), note: z.string().optional() },
5379
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5380
+ }, wrap(async (a) => {
5381
+ const d = await apiPost('/api/docs/update', a);
5382
+ return ok(d.note, d);
5383
+ }));
5384
+
4910
5385
  // ---------- Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite) ----------
4911
5386
  server.group('files');
4912
5387
  server.registerTool('save_to_onedrive', {
@@ -7125,17 +7600,22 @@ export function registerTools(rawServer, opts = {}) {
7125
7600
 
7126
7601
  server.registerTool('set_product_image', {
7127
7602
  title: 'Set product photo',
7128
- description: "Lock an image as the ad's real PRODUCT photo so every render grounds on the true packaging. Pass `imageUrl` = a product shot's URL — an image from a prior research result (an organic Instagram/TikTok post, a scraped page image), a workspace / list_product_photos url, or any public product photo. The server downloads it and runs a product+safety check: a lifestyle/scene shot with no clear product, or an off-category / unsafe image, is REJECTED and NOTHING is locked (the summary says why). On PASS it persists the photo to a DURABLE url and returns it pass that url as a reference to generate_image / render_ad. Bills one vision check. Reads YOUR saved brand for the category match (pass brandId to target a specific brand — switches this key's active brand like use_brand).",
7603
+ description: "Lock an image as the ad's real PRODUCT photo and SAVE it as this brand's default product, so every later plan_ad / render_ad / generate_image grounds on the true packaging without being told again. Pass `imageUrl` = a product shot's URL — an image from a prior research result (an organic Instagram/TikTok post, a scraped page image), a workspace / list_product_photos url, or any public product photo. The server downloads it and runs a product+safety check: a lifestyle/scene shot with no clear product, or an off-category / unsafe image, is REJECTED and NOTHING is locked or saved (the summary says why). On PASS it persists the photo to a DURABLE url, writes it to the brand's product library as the DEFAULT, and READS THE BRAND BACK to confirm `savedToBrand` and the summary report what the brand ACTUALLY holds now, never what was asked for, so if it did not become the default you are told instead of finding out from a paid render. Bills one vision check. Reads YOUR saved brand for the category match (pass brandId to target a specific brand — switches this key's active brand like use_brand).",
7129
7604
  inputSchema: {
7130
7605
  imageUrl: z.string().describe('the image URL to lock as the product (from a research result, a workspace / list_product_photos url, or any public product photo)'),
7131
7606
  source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
7132
7607
  brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
7133
7608
  },
7134
7609
  outputSchema: {
7135
- attached: z.boolean().optional().describe('true when the image passed the product check and was locked'),
7136
- summary: z.string().optional().describe('the check verdict on rejection, why nothing was locked'),
7610
+ attached: z.boolean().optional().describe('true when the image passed the product check and a durable url was minted'),
7611
+ savedToBrand: z.boolean().optional().describe('THE READ-BACK: true only when the brand row came back naming this photo as its default product. False means a render will NOT use it — see defaultProduct'),
7612
+ defaultProduct: z.string().nullable().optional().describe("the brand's default product photo as READ BACK from the store — the photo a render will actually ground on"),
7613
+ productImages: z.array(z.string()).nullable().optional().describe("the brand's product library as read back, newest first"),
7614
+ summary: z.string().optional().describe('the check verdict plus the read-back — on rejection, why nothing was locked; on a failed save, what a render would use instead'),
7137
7615
  url: z.string().nullable().optional().describe('the durable served URL of the locked product photo'),
7138
7616
  source_note: z.string().nullable().optional().describe('where the photo came from'),
7617
+ unconfirmed: z.string().nullable().optional().describe('set when the save landed but the confirming read failed — neither saved nor failed; verify with list_product_photos'),
7618
+ saveError: z.string().nullable().optional().describe('set when the photo passed the check but could NOT be saved to the brand'),
7139
7619
  },
7140
7620
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
7141
7621
  }, wrap(async ({ imageUrl, source_note, brandId }) => {
@@ -7144,7 +7624,11 @@ export function registerTools(rawServer, opts = {}) {
7144
7624
  if (!d.attached) return ok(d.summary || 'That image was not locked as the product.', d); // gate honesty: rejected → nothing attached
7145
7625
  const url = abs(d.url);
7146
7626
  const img = await imageBlock(url); // show the locked product inline
7147
- return { content: [{ type: 'text', text: `${d.summary}\nProduct photo: ${url}` }, ...(img ? [img] : [])], structuredContent: { ...d, url } };
7627
+ // THE ANSWER IS THE READ-BACK. `d.summary` already carries it (productLockReadbackNote, server-side, one copy for
7628
+ // every surface). A photo that passed the check but did NOT become the brand's default must not be shown as a
7629
+ // finished job, so the headline is the read-back verdict rather than the ask — same law the ads tree follows.
7630
+ const head = d.savedToBrand ? 'Locked as this brand\u2019s product photo' : d.unconfirmed ? 'Checked and saved \u2014 but NOT confirmed' : 'Checked, but NOT saved as the brand\u2019s product photo';
7631
+ return { content: [{ type: 'text', text: `${head}.\n${d.summary}\nProduct photo: ${url}` }, ...(img ? [img] : [])], structuredContent: { ...d, url } };
7148
7632
  }));
7149
7633
 
7150
7634
  // APP SCREENS — the one asset class a headless caller could never acquire after onboarding. `draft_brand` pulls them
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.57",
3
+ "version": "0.1.64",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic \u2014 then publish to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest and build & manage the ad campaigns behind them on Meta, Google Ads, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads. MCP server (316 tools), CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
5
+ "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic \u2014 then publish to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest and build & manage the ad campaigns behind them on Meta, Google Ads, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads. MCP server (344 tools), CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"