hermoso 0.1.64 → 0.1.68

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 +22 -20
  2. package/mcp/tools.mjs +509 -26
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -5,15 +5,7 @@ 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
- <<<<<<< 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
8
+ **366 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
17
9
  catalog with exact per-render credit costs plus the full capability map.
18
10
 
19
11
  **It is not all-or-nothing.** Research, creation, publishing/scheduling and ads management are four *independent*
@@ -61,15 +53,7 @@ Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
61
53
 
62
54
  Then ask your agent: *“Generate an image ad with Hermoso.”*
63
55
 
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
56
+ ### What the 366 tools cover
73
57
 
74
58
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
75
59
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -84,8 +68,13 @@ The workspace's **saved cast** is reusable: `list_creators` returns every saved
84
68
  Also `make_template_ad` (native HTML ad formats), `make_explainer`, `product_sizzle`, `make_thumbnail`,
85
69
  `remix_static`, `recast_motion`, `reframe_video`, `upscale_video`, `dub_video`, `change_voice`, `finish_video`,
86
70
  `fix_beat`, `stitch_video`, `clip_video`, `post_edit`, plus `plan_variations` + `score_ad` to fan out and rank.
87
- **Length is yours to set:** pass `durationSeconds` to `plan_ad` and the storyboard is *authored* to it — ≤15s
88
- renders as one continuous clip, longer is stitched from acts (40s = 15+15+10), never time-compressed.
71
+ **Length is yours to set:** pass `durationSeconds` to `plan_ad` and the storyboard is *authored* to it — a length
72
+ that fits one clip of the render model renders as a single continuous take, longer is stitched from acts (on a
73
+ 15s-clip model, 40s = 15+15+10), never time-compressed. What fits one clip is the model's own maximum, not a fixed
74
+ number: most video models cap a clip at 15 seconds and the longest-clip one takes **30 seconds in one unbroken
75
+ take** with native synchronized audio. `hermoso_capabilities` is the live list — durations, resolutions and the
76
+ exact credit cost of every tier — and naming that model in `model` is how you get it, since an unnamed render is
77
+ routed by a narrower auto-pool.
89
78
 
90
79
  **Raw model playground** — the full catalog (30+ image / video / voice / writing models, each with its exact
91
80
  per-render credit cost) with no ad framing: `generate_image` / `generate_video` with `useBrand:false`,
@@ -106,6 +95,19 @@ change confirm-gated, on **Meta**, **Google Ads**, **LinkedIn Ads**, **Reddit Ad
106
95
  (e.g. `list_google_ads_campaigns`, `google_ads_report`, `create_google_ads_campaign`, `set_google_ads_budget`,
107
96
  `set_google_ads_status`). *X Ads are not supported — X posting only.*
108
97
 
98
+ **Measure what the ads achieved** — Google Analytics 4 closes the loop. Every other connector here reports what an
99
+ ad *cost*; this is the one that reports what it *did*. `analytics_report` breaks sessions, users, conversions and
100
+ revenue down by channel, source/medium, campaign, landing page, country, device or date, so the campaign Hermoso
101
+ built and the revenue it drove sit in one conversation. `analytics_realtime` shows who is on the site right now.
102
+ Start at `list_analytics_properties` — the tools take a numeric property id, not the `G-XXXXXXXXX` Measurement ID
103
+ from your tracking snippet, and this is what resolves one from the other. It writes as well as reads:
104
+ `create_analytics_key_event` marks an event GA4 already collects as a key event — which is what makes it importable
105
+ into Google Ads as a conversion — and `create_analytics_custom_dimension` registers an event parameter so reports
106
+ can break down by it, with `list_analytics_definitions` showing what the property already measures. It signs in
107
+ with the same Google account as Google Ads, YouTube and Drive, but it is its own connection.
108
+ *GA4 only — the API has no Universal Analytics surface. A custom dimension can be archived but never deleted, and a
109
+ property holds 50 event-scoped ones.*
110
+
109
111
  **Files** — Google Drive CRUD (`save_to_drive`, `list_drive_files`, `update_drive_file`, `delete_drive_file`,
110
112
  `create_drive_folder`), Google Sheets (`create_sheet`, `append_to_sheet`, `read_sheet`), Google Docs
111
113
  (`create_doc`, `append_to_doc`), and OneDrive (`save_to_onedrive` + full CRUD).
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 "eight ad platforms" count is asserted against the roster by
90
+ // and stale on the other. The "nine 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 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.';
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 nine 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). 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.',
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. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this brand can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. 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). GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids: 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
@@ -155,6 +155,70 @@ async function imageBlock(url) {
155
155
  // • reportToolError fires ONLY for an error with no `_viaApi` marker, i.e. one that never reached the server at
156
156
  // all: a local throw, a schema rejection, a socket reset. Those have no other way of ever being seen.
157
157
  // Both are best-effort and neither can change what the caller is told.
158
+
159
+ // ── "NOT CONNECTED" HAS ONE SHAPE: 401 + connector:'<provider>' (2026-08-10) ─────────────────────────────────────
160
+ // This gate used to DETECT the condition with a prose regex over the error message
161
+ // (/isn.?t connected|connect your .* (account|channel)|add it under .*connectors/i). Prose is not a classifier, and
162
+ // it over-matched in exactly the way metaErr's permission regex did three times ([[meta-error-class-check]]):
163
+ // • A SHARING refusal — "… isn’t shared with this brand. Add it under Settings ▸ Connectors ▸ … ▸ Manage
164
+ // accounts, then retry." (server.js, 400/403, connector ALREADY LINKED) — hit `add it under .*connectors` and
165
+ // got "Ask your human to connect it at app.hermoso.ai (Settings ▸ Connectors)" bolted on. That instruction is
166
+ // false (it IS connected), it cannot fix anything (re-doing OAuth does not tick a Page/property), and it
167
+ // directly contradicts the correct Manage-accounts guidance sitting in the same message. Observed live
168
+ // 2026-08-10 on a Google Analytics property-sharing 403.
169
+ // • A DISCONNECT refusal — "You can keep using it, or connect your own account alongside it." — hit
170
+ // `connect your .* account`, i.e. a 403 about ownership read as "not connected".
171
+ // • A MISSING SERVER-SIDE PROVIDER KEY — "Dubbing isn’t connected yet." / "Payment isn’t connected." (501) — sent
172
+ // the user to Connectors for a credential no user can supply.
173
+ // The structured signal was already there and this gate was ignoring it. server.js gives "not connected" ONE shape:
174
+ // 401 carrying `connector:'<provider>'`, minted only by notConnected()/sendNotConnected(), copied onto the body by
175
+ // route(), and stamped on any other connector-context 401 by connectorMarkerBelt(). client.mjs's unwrap() puts BOTH
176
+ // on the thrown error (`e.status`, `e.connector`). So detection is that PAIR and nothing else:
177
+ // • 401 + marker → not connected. The marker NAMES the provider, so the one-click link is exact and a connector
178
+ // added tomorrow is covered with no edit here.
179
+ // • 401, no marker → a SESSION 401. Never claimed — api()'s "your session expired" is the right answer, and
180
+ // claiming it would send someone to reconnect a connector over an expired login.
181
+ // • marker, not 401 → NOT not-connected. A 403 sharing refusal, a 403 disconnect refusal (which carries a marker),
182
+ // a 429 throttle and a 404 all fall through with their own accurate message untouched.
183
+ // ONE PATH GENUINELY CANNOT BE SEEN THIS WAY, so it is named rather than papered over: server.js's
184
+ // saveUrlToOneDrive throws a BARE `new Error('OneDrive isn’t connected — add it under Connectors first.')`, which
185
+ // /api/onedrive/save answers as a 502 with no status of ours and no marker — so `save_to_onedrive` on an unlinked
186
+ // OneDrive would silently lose its connect link. NOT_CONNECTED_LEGACY_RE covers THAT class and nothing else: it
187
+ // fires only when there is no marker, the status is not 401 (a session) and not 501 (a server-side provider key),
188
+ // and the message says a connection is ABSENT. It deliberately does NOT test for "Connectors" or for
189
+ // "connect your … account" — those are precisely the two alternatives that swallowed the refusals above. The branch
190
+ // retires the day that throw becomes notConnected('microsoft_onedrive'); nothing else has to move.
191
+ const NOT_CONNECTED_LEGACY_RE = /isn['’`]?t connected\b|\bConnect [A-Z][\w .+&-]{1,28} first\b/;
192
+ // LEGACY-ONLY provider resolution, reached solely by the branch above. It is order-sensitive in a way that bites
193
+ // (/tiktok/ matches "TikTok Ads", so the ads entries MUST precede the organic ones) and it has always been missing
194
+ // live providers — which is exactly the fragility the marker removes, and exactly why nothing on the marker path
195
+ // consults it.
196
+ const CONNECT_LEGACY_PROVIDERS = [
197
+ [/onedrive/i, 'microsoft_onedrive'], [/google ads/i, 'google_ads'],
198
+ [/google sheet|google doc|google drive|\bdrive\b/i, 'google_drive'] /* Drive, Sheets and Docs are ONE connector since 2026-08-01 — one grant, one consent screen, one connect link */,
199
+ [/google business/i, 'google_business'], [/youtube/i, 'youtube'], [/threads/i, 'threads'],
200
+ [/tiktok ads/i, 'tiktok_ads'], [/tiktok/i, 'tiktok'],
201
+ [/reddit ads/i, 'reddit_ads'], [/reddit/i, 'reddit'],
202
+ [/microsoft ads|bing/i, 'microsoft_ads'], [/chatgpt ads|openai/i, 'openai_ads'],
203
+ [/pinterest/i, 'pinterest'], [/\bx\b|twitter/i, 'x'],
204
+ [/meta|facebook|instagram/i, 'meta'], [/linkedin/i, 'linkedin'],
205
+ ];
206
+ const CONNECT_LINK_HINT = (provider) => `\nHand your human this one-click connect link: https://app.hermoso.ai/?connect=${provider} — it opens Hermoso, signs them in if needed, and starts the connection. Then retry.`;
207
+ const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.ai (Settings ▸ Connectors), then retry.`;
208
+ // PURE, and module-scope on purpose: tools/mcp-not-connected-gate-check.mjs lifts and RUNS this exact function over
209
+ // a table of real error shapes, so the classification is tested rather than read. Returns the sentence to APPEND, or
210
+ // '' when the failure is not a not-connected one (which must leave the caller's own message alone).
211
+ function notConnectedHint(e, msg) {
212
+ const status = Number(e?.status) || 0;
213
+ const marker = e?.connector ? String(e.connector) : '';
214
+ if (status === 401) return marker ? CONNECT_LINK_HINT(marker) : ''; // the ONE shape; a bare 401 is a session
215
+ if (marker) return ''; // connected already — a sharing / ownership / provider refusal that merely names its connector
216
+ if (status === 501) return ''; // a provider key WE have not set; no user connection can supply it
217
+ if (!NOT_CONNECTED_LEGACY_RE.test(String(msg || ''))) return '';
218
+ const hit = CONNECT_LEGACY_PROVIDERS.find(([re]) => re.test(msg));
219
+ return hit ? CONNECT_LINK_HINT(hit[1]) : CONNECT_ASK_HINT;
220
+ }
221
+
158
222
  const wrap = (fn) => {
159
223
  // A NAMED closure, so registerTools' proxy can stamp the tool name onto the very function it registers and this
160
224
  // body can read it back. Reading a name off `fn` instead would be wrong: `fn` is the inner handler and the thing
@@ -170,11 +234,9 @@ const wrap = (fn) => {
170
234
  // matching only the latter meant research, X posting and the competitor watch hit a 402 and told the agent
171
235
  // nothing about how to fix it, so the top-up path this whole flow depends on was unreachable from those tools.
172
236
  if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
173
- // connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen in-agent) — Dave 2026-07-23
174
- else if (/isn.?t connected|connect your .* (account|channel)|add it under .*connectors/i.test(msg)) {
175
- const prov = [[/onedrive/i, 'microsoft_onedrive'], [/google ads/i, 'google_ads'], [/google sheet|google doc|google drive|\bdrive\b/i, 'google_drive'] /* Drive, Sheets and Docs are ONE connector since 2026-08-01 — one grant, one consent screen, one connect link */, [/youtube/i, 'youtube'], [/threads/i, 'threads'], [/meta|facebook|instagram/i, 'meta'], [/linkedin/i, 'linkedin']].find(([re]) => re.test(msg));
176
- msg += prov ? `\nHand your human this one-click connect link: https://app.hermoso.ai/?connect=${prov[1]} — it opens Hermoso, signs them in if needed, and starts the connection. Then retry.` : `\nAsk your human to connect it at https://app.hermoso.ai (Settings ▸ Connectors), then retry.`;
177
- }
237
+ // connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
238
+ // in-agent) Dave 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
239
+ else msg += notConnectedHint(e, msg);
178
240
  return { content: [{ type: 'text', text: msg }], isError: true };
179
241
  }
180
242
  };
@@ -248,8 +310,13 @@ const qaLine = (r) => { const n = renderPayload(r)?.qaNote; return n ? `\n${n}`
248
310
  // Every field is optional so validation can never fail on a sparse or still-rendering result.
249
311
  // ── LENGTH ASKS. Two numbers govern every duration a caller can ask for, and both are stated in the tool schemas
250
312
  // rather than discovered at render time (2026-07-31: a 40-second brief came back as a 15s spot with no warning).
251
- // VIDEO_SINGLE_CLIP_CEILING — the longest SINGLE generation any current model does (Seedance 2.0 / Kling 3 = 15s).
252
- // Used only as the trigger to go CHECK the live catalog before refusing, never as the refusal's own authority.
313
+ // VIDEO_SINGLE_CLIP_CEILING — a CONSERVATIVE trigger, not a fact about the catalog. It is the length past which
314
+ // generate_video stops and goes to READ /api/generate/status before it refuses anything; the refusal itself is
315
+ // always measured against the live catalog, never against this number. It deliberately stays at the length the
316
+ // ORDINARY models render (15s) even though the catalog now carries a 30s single-take model: raising it to 30
317
+ // would mean a 20s ask skips the probe entirely, and on an account where that model is not configured (no
318
+ // FAL_KEY) the ask would sail through to a model that cannot render it. Low = one extra free round trip on a
319
+ // long ask. High = a silently-truncated render. So it stays low, and the live catalog stays the authority.
253
320
  // AD_LENGTH_MAX — the longest STITCHED spot the planner can build: 12 acts (KEYFRAME_CAP) × 15s.
254
321
  const VIDEO_SINGLE_CLIP_CEILING = 15;
255
322
  const AD_LENGTH_MAX = 180, AD_LENGTH_MIN = 4;
@@ -596,7 +663,22 @@ export function registerTools(rawServer, opts = {}) {
596
663
  + `\nAny channel absent from BOTH lists is not available on this build — do not tell the user to connect it.`
597
664
  + `\nOnly the accounts a user TICKED under a connector's "Manage accounts" are usable; list_connector_accounts shows them and set_connector_accounts changes them.`;
598
665
  } catch { /* capabilities must still answer when connectors are unreadable */ }
599
- const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\nIMPORTANT: durations above are SINGLE-PASS — e.g. seedance-2 renders a full multi-beat 15s ad in ONE generation (do NOT assume a generic 8–10s cap, and do NOT stitch for ≤15s spots; stitching is only for longer). durationSeconds must be one of the model's listed values.\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar} canPublish:${d.canPublish}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`;
666
+ // THE THREE VIDEO RULES AN AGENT GETS WRONG, and the length one is DERIVED so it can never go stale. This line
667
+ // used to read "e.g. seedance-2 renders a full multi-beat 15s ad in ONE generation" — a hardcoded model and a
668
+ // hardcoded number, both of which stopped being the whole truth the day a 30s single-take model landed in the
669
+ // catalog. Read the ceiling off the catalog that was just fetched instead; a new model moves it with no edit here.
670
+ const _vm = d.options?.video?.models || [];
671
+ const _longest = _vm.reduce((b, m) => { const mx = Math.max(0, ...(m.durations || [0])); return mx > b.max ? { max: mx, id: m.id, label: m.label } : b; }, { max: 0, id: '', label: '' });
672
+ const _lenLine = _longest.max
673
+ ? `IMPORTANT: the durations above are SINGLE-PASS — ONE continuous generation with a full multi-beat arc, not a stitch. The longest single clip on this account is ${_longest.max}s (${_longest.id}${_longest.label ? ` · ${_longest.label}` : ''}), so do NOT assume a generic 8–10s cap and do NOT stitch anything that fits inside one clip. durationSeconds must be one of the chosen model's own listed values.`
674
+ : 'IMPORTANT: the durations above are SINGLE-PASS — durationSeconds must be one of the chosen model\'s own listed values.';
675
+ // NAMING THE MODEL IS HOW YOU GET IT. The catalog is what this account COULD render; the auto-router is narrower
676
+ // than the catalog by design, and neither `best` nor the longest-clip row decides where an unnamed ask lands.
677
+ // Stated as a rule rather than as today's allowlist, so it stays true when the allowlist changes.
678
+ const _pickLine = 'NAMING A MODEL IS HOW YOU GET ONE: a render that passes no `model` is routed by the server\'s own auto-pool, which is deliberately NARROWER than this catalog — the `best` flag and the longest-clip row do NOT decide it. If you need a particular model\'s length, resolution, audio or reference-count capability, pass its id in `model`; that is a deliberate pick and the server will not swap it without telling you.';
679
+ // RESOLUTION IS PER MODEL, and asking outside the list is not an error — it is a quiet downgrade.
680
+ const _resLine = 'RESOLUTION: each model\'s `resolutions` list is its REAL enum (and `creditsByRes` prices every tier). Ask for a tier a model does not list and the render is delivered at that model\'s best available tier instead — the reply does not say so — so read `resolutions` here before promising anyone 1080p or 4k.';
681
+ const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\n${_pickLine}\n${_resLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar} canPublish:${d.canPublish}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`;
600
682
  return ok(text + connLine, d);
601
683
  }));
602
684
 
@@ -2844,6 +2926,49 @@ export function registerTools(rawServer, opts = {}) {
2844
2926
  }));
2845
2927
  // ---------- Google Ads: read + manage (flagship, Meta-parity). Every spend change is confirm-gated. ----------
2846
2928
  server.group('ads');
2929
+ // GOOGLE MERCHANT CENTER (2026-08-07) — rides the google_ads connection (same grant, extra `content` scope).
2930
+ // ALL FOUR SHIPPED CALLING `text(...)`, WHICH IS DECLARED NOWHERE IN THIS FILE (found 2026-08-10). Every call
2931
+ // therefore threw `ReferenceError: text is not defined`, `wrap()` caught it, and the tool answered "Error: text
2932
+ // is not defined" — a 100% failure rate at everything all four advertised, on the lane a Merchant Center customer
2933
+ // lives on. Same class as `boardK is not defined` and `update_reddit_ads_post`: an unsupplied identifier inside a
2934
+ // try/catch is indistinguishable from a working line, and no roster gate reaches it (mcp-parity only ever calls
2935
+ // listTools). They also carried NO outputSchema, which is what made it visible — mcp-output-schema-check asserts
2936
+ // every tool keeps its typed contract, and a schema cannot be added without returning structuredContent. Both
2937
+ // halves are the same fix: `ok(sentence, d)` prints the sentence AND carries the route's real object.
2938
+ server.registerTool('register_merchant_developer', {
2939
+ description: "ONE-TIME SETUP, and the FIRST thing to run when Merchant Center calls are being refused. Google blocks every Merchant API call until Hermoso's Google Cloud project is registered against the merchant's account, and says so verbatim (\"GCP project ... is not registered with the merchant account\"). This performs that link. It is NOT a broken connection and reconnecting cannot fix it. Google asks for up to 5 minutes afterwards before the API starts answering.",
2940
+ inputSchema: { merchantCenterId: z.string().describe('the numeric id shown top-right in Merchant Center'), developerEmail: z.string().describe('a contact address Google records against the registration; it is not used to sign in') },
2941
+ outputSchema: { merchantCenterId: z.string().optional(), developerEmail: z.string().optional(), gcpIds: z.array(z.string()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
2942
+ }, wrap(async (a) => {
2943
+ const d = await apiPost('/api/merchant/register', { merchantCenterId: a.merchantCenterId, developerEmail: a.developerEmail });
2944
+ return ok(d.note, d); // print the READ-BACK sentence verbatim — it names the GCP project Google actually stored
2945
+ }));
2946
+ server.registerTool('list_merchant_accounts', {
2947
+ description: "List the Google Merchant Center accounts this brand's connected Google account can reach. EVERY other Merchant tool needs the merchantCenterId this returns, and an agency often has several, so never guess one — ask the user which store. Read-only and free. If it is refused, the Merchant Center permission has not been granted yet; that is not a broken connection and reconnecting will not change it.",
2948
+ inputSchema: {},
2949
+ outputSchema: { accounts: z.array(z.any()).optional() },
2950
+ }, wrap(async () => {
2951
+ const d = await apiGet('/api/merchant/accounts');
2952
+ const rows = d?.accounts || [];
2953
+ return ok(rows.length ? rows.map(a => `${a.name || '(unnamed)'} — ${a.accountId}`).join('\n')
2954
+ : 'This Google account can reach no Merchant Center accounts.', d);
2955
+ }));
2956
+ server.registerTool('list_merchant_products', {
2957
+ description: "Read the products in a Merchant Center feed — what the merchant actually offers, which is what Shopping and retail Performance Max campaigns serve. Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
2958
+ inputSchema: { merchantCenterId: z.string().describe('from list_merchant_accounts'), limit: z.number().optional().describe('max products (default 50, max 250)') },
2959
+ outputSchema: { merchantCenterId: z.string().optional(), count: z.number().optional(), products: z.array(z.any()).optional() },
2960
+ }, wrap(async (a) => {
2961
+ const d = await apiGet('/api/merchant/products', { merchantCenterId: a.merchantCenterId, limit: a.limit });
2962
+ return ok(`Merchant Center ${d.merchantCenterId}: ${d.count} product(s).\n${JSON.stringify(d.products || []).slice(0, 4000)}`, d);
2963
+ }));
2964
+ server.registerTool('list_merchant_issues', {
2965
+ description: "Read the account-level issues Google reports on a Merchant Center — the answer to \"why is this product not showing?\", which Google Ads reporting CANNOT give you, because a disapproved product has no impressions to report on. Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
2966
+ inputSchema: { merchantCenterId: z.string().describe('from list_merchant_accounts') },
2967
+ outputSchema: { merchantCenterId: z.string().optional(), count: z.number().optional(), issues: z.array(z.any()).optional(), note: z.string().optional() },
2968
+ }, wrap(async (a) => {
2969
+ const d = await apiGet('/api/merchant/issues', { merchantCenterId: a.merchantCenterId });
2970
+ return ok(`${d.note}\n${JSON.stringify(d.issues || []).slice(0, 4000)}`, d);
2971
+ }));
2847
2972
  server.registerTool('list_google_ads_campaigns', {
2848
2973
  title: 'List Google Ads accounts / campaigns',
2849
2974
  description: 'Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (Settings ▸ Connectors ▸ Google Ads).',
@@ -3241,7 +3366,7 @@ export function registerTools(rawServer, opts = {}) {
3241
3366
  }));
3242
3367
  server.registerTool('create_google_ads_performance_max_campaign', {
3243
3368
  title: 'Create a Google Ads Performance Max campaign',
3244
- description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps) and the one Google pushes hardest at small advertisers. ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL / Shopping Performance Max (a Merchant Center product feed with listing groups) is NOT supported here and is refused by name.',
3369
+ description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps) and the one Google pushes hardest at small advertisers. ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL / Shopping Performance Max (a Merchant Center product feed with listing groups) is NOT supported here and is refused by name. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
3245
3370
  inputSchema: {
3246
3371
  customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
3247
3372
  name: z.string().describe('campaign name'),
@@ -3259,6 +3384,8 @@ export function registerTools(rawServer, opts = {}) {
3259
3384
  startDate: z.string().optional().describe('YYYY-MM-DD'),
3260
3385
  endDate: z.string().optional().describe('YYYY-MM-DD'),
3261
3386
  containsEuPoliticalAds: z.boolean().optional().describe('true ONLY for genuine EU political advertising'),
3387
+ merchantCenterId: z.string().optional().describe('makes this a RETAIL (Shopping-feed) Performance Max — the Merchant Center account whose products it advertises, from list_merchant_accounts. It advertises the WHOLE feed; partitioning by brand/category is not built and is refused by name'),
3388
+ feedLabel: z.string().optional().describe('optional, retail only: narrow to ONE Merchant Center feed by its feed label. Omit to use products from all feeds'),
3262
3389
  brandGuidelinesEnabled: z.boolean().optional().describe('default TRUE, matching Google’s own default since v21: businessName + logos are linked to the CAMPAIGN. Pass false only for the pre-v21 layout, where they sit on the asset group instead'),
3263
3390
  assetGroup: z.object({
3264
3391
  name: z.string().describe('asset group name'),
@@ -3305,6 +3432,101 @@ export function registerTools(rawServer, opts = {}) {
3305
3432
  const d = await apiPost('/api/google-ads/keyword-ideas', a);
3306
3433
  return ok(d.note || `${d.count || 0} keyword idea(s).`, d);
3307
3434
  }));
3435
+ // ── GOOGLE ANALYTICS (GA4) (2026-08-10) ────────────────────────────────────────────────────────────────────────
3436
+ // A SEPARATE connection from Google Ads even though it shares the GCP OAuth client — a brand that spends on Google
3437
+ // Ads every day may have no Analytics access at all, so never read one as the other.
3438
+ // THE PROPERTY ID IS THE WHOLE ONBOARDING PROBLEM. GA4's APIs address `properties/<numeric id>`; users
3439
+ // overwhelmingly know their "G-XXXXXXX" MEASUREMENT id instead, which no endpoint accepts anywhere. So every one
3440
+ // of these says so, and list_analytics_properties exists to RESOLVE it rather than making the user go find it.
3441
+ server.registerTool('list_analytics_properties', {
3442
+ title: 'List the GA4 properties this connection can read',
3443
+ description: 'The GA4 properties the connected Google account can see, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the "G-XXXXXXX" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. Read-only, 0 credits. Needs Google Analytics connected (Settings ▸ Connectors ▸ Google Analytics).',
3444
+ inputSchema: {},
3445
+ outputSchema: { properties: z.array(z.object({ property: z.string().optional(), displayName: z.string().optional(), account: z.string().optional(), propertyType: z.string().optional() })).optional(), count: z.number().optional() },
3446
+ annotations: { readOnlyHint: true, openWorldHint: true },
3447
+ }, wrap(async () => {
3448
+ const d = await apiGet('/api/analytics/properties', {});
3449
+ const rows = (d.properties || []).map(p => `• ${p.displayName || '(unnamed)'} — property ${p.property}${p.account ? ` · ${p.account}` : ''}`);
3450
+ return ok(rows.length ? `${rows.length} GA4 propert(ies):\n${rows.join('\n')}` : 'This Google account can see no GA4 properties. It needs at least Viewer on one (GA4 ▸ Admin ▸ Property access management).', d);
3451
+ }));
3452
+ server.registerTool('analytics_report', {
3453
+ title: 'Run a GA4 report',
3454
+ description: 'WHAT HAPPENED ON THE SITE — the GA4 Data API report, and the tool that answers "where is our traffic coming from", "which campaign converted", "which landing page is working". Pass metrics by name (activeUsers, sessions, screenPageViews, conversions, totalRevenue, engagementRate, bounceRate, averageSessionDuration …) and dimensions to break them down by (sessionDefaultChannelGroup, sessionSource / sessionMedium / sessionCampaignName, landingPage, pagePath, country, deviceCategory, date, eventName …). GA4 publishes hundreds of both and each property ships its OWN custom dimensions, so names are forwarded as given rather than validated against a copied list — use list_analytics_definitions to see a property\'s custom ones. THE PROPERTY IS A NUMERIC ID (e.g. 123456789), NOT the G-XXXXXXX Measurement ID — run list_analytics_properties to resolve it. Dates default to the last 28 days and accept either YYYY-MM-DD or GA4\'s relative forms ("28daysAgo", "yesterday", "today"). Rows come back as flat named objects, and a sampled result says so. Read-only, 0 credits.',
3455
+ inputSchema: {
3456
+ property: z.string().describe('the NUMERIC GA4 property id from list_analytics_properties (e.g. "123456789") — never the G-XXXXXXX Measurement ID'),
3457
+ metrics: z.array(z.string()).optional().describe('GA4 metric names — default ["activeUsers","sessions","screenPageViews"]'),
3458
+ dimensions: z.array(z.string()).optional().describe('GA4 dimension names to break the metrics down by — omit for a single total row'),
3459
+ startDate: z.string().optional().describe('YYYY-MM-DD or a GA4 relative date like "28daysAgo" (default 28daysAgo)'),
3460
+ endDate: z.string().optional().describe('YYYY-MM-DD or "today" (default today)'),
3461
+ limit: z.number().optional().describe('rows, 1–1000 (default 50)'),
3462
+ orderByMetric: z.string().optional().describe('sort by this metric — must be one of the metrics requested'),
3463
+ orderDesc: z.boolean().optional().describe('default true (largest first) when orderByMetric is set'),
3464
+ },
3465
+ outputSchema: { property: z.string().optional(), rows: z.array(z.any()).optional(), rowCount: z.number().optional(), dimensions: z.array(z.string()).optional(), metrics: z.array(z.string()).optional(), sampled: z.boolean().optional() },
3466
+ annotations: { readOnlyHint: true, openWorldHint: true },
3467
+ }, wrap(async (a) => {
3468
+ const d = await apiPost('/api/analytics/report', a);
3469
+ return ok(`${d.rowCount || 0} row(s) for property ${d.property}${d.sampled ? ' (SAMPLED — GA4 dropped rows to answer this; say so when reporting)' : ''}.\n${rowLines(d.rows)}`, d);
3470
+ }));
3471
+ server.registerTool('analytics_realtime', {
3472
+ title: 'Who is on the site right now (GA4 realtime)',
3473
+ description: 'WHO IS ON THE SITE RIGHT NOW — GA4\'s realtime report, covering roughly the last 30 minutes. Use it to see a launch, a post or a campaign landing in real time. IT IS A DIFFERENT REPORT WITH A DIFFERENT METRIC SET, NOT a shortcut for analytics_report: realtime accepts activeUsers, screenPageViews, conversions and eventCount, and REJECTS `sessions` outright — asking for sessions here returns an error, not a zero. Realtime dimensions are also narrower (unifiedScreenName, country, deviceCategory, platform, eventName …) and there is no date range at all. The property is the NUMERIC id from list_analytics_properties, never the G-XXXXXXX Measurement ID. Read-only, 0 credits.',
3474
+ inputSchema: {
3475
+ property: z.string().describe('the NUMERIC GA4 property id from list_analytics_properties'),
3476
+ metrics: z.array(z.string()).optional().describe('realtime metric names — default ["activeUsers"]. NOT the same set as analytics_report; `sessions` is not a realtime metric'),
3477
+ dimensions: z.array(z.string()).optional().describe('realtime dimension names, e.g. ["country"] or ["unifiedScreenName"]'),
3478
+ limit: z.number().optional().describe('rows, 1–1000 (default 50)'),
3479
+ },
3480
+ outputSchema: { property: z.string().optional(), rows: z.array(z.any()).optional(), activeUsers: z.number().optional() },
3481
+ annotations: { readOnlyHint: true, openWorldHint: true },
3482
+ }, wrap(async (a) => {
3483
+ const d = await apiPost('/api/analytics/realtime', a);
3484
+ return ok(`${d.activeUsers ?? 0} active user(s) on property ${d.property} right now.\n${rowLines(d.rows)}`, d);
3485
+ }));
3486
+ server.registerTool('list_analytics_definitions', {
3487
+ title: 'List a GA4 property’s key events and custom dimensions',
3488
+ description: 'What a GA4 property already MEASURES — its key events (what GA4 counts as a conversion) and its custom dimensions, with each dimension\'s parameter name and scope. Two reasons to call it: to learn a property\'s own custom dimension names before using them in analytics_report, and to CHECK BEFORE CREATING — a custom dimension can never be deleted, only archived, and a property is capped at 50 event-scoped ones, so creating a duplicate permanently burns a slot. The property is the NUMERIC id from list_analytics_properties. Read-only, 0 credits.',
3489
+ inputSchema: { property: z.string().describe('the NUMERIC GA4 property id from list_analytics_properties') },
3490
+ outputSchema: { property: z.string().optional(), keyEvents: z.array(z.any()).optional(), customDimensions: z.array(z.any()).optional() },
3491
+ annotations: { readOnlyHint: true, openWorldHint: true },
3492
+ }, wrap(async (a) => {
3493
+ const d = await apiGet('/api/analytics/definitions', { property: a.property });
3494
+ const ke = (d.keyEvents || []).map(k => `• key event: ${k.eventName}${k.countingMethod ? ` (${k.countingMethod})` : ''}`);
3495
+ const cd = (d.customDimensions || []).map(c => `• custom dimension: ${c.displayName} — parameter "${c.parameterName}", ${c.scope} scope`);
3496
+ return ok(`Property ${d.property}: ${ke.length} key event(s), ${cd.length} custom dimension(s).\n${[...ke, ...cd].join('\n')}`, d);
3497
+ }));
3498
+ server.registerTool('create_analytics_key_event', {
3499
+ title: 'Mark a GA4 event as a key event (conversion)',
3500
+ description: 'Mark an event GA4 ALREADY COLLECTS as a KEY EVENT — what GA4 calls a conversion since the 2024 rename (the resource is keyEvents; the old conversionEvents spelling is deprecated). This is what makes an event countable as a result in reports and importable into Google Ads as a conversion. THE EVENT NAME MUST MATCH AN EVENT THE SITE ALREADY SENDS ("purchase", "generate_lead", "sign_up", or a custom one) — marking an event that is never fired creates a key event that can never fire, so check list_analytics_definitions or an analytics_report broken down by eventName first. countingMethod ONCE_PER_EVENT counts every occurrence; ONCE_PER_USER counts one per user per day. The result is READ BACK from Google, never echoed from the request. The property is the NUMERIC id from list_analytics_properties. Needs the Google Analytics connection to have edit access on the property.',
3501
+ inputSchema: {
3502
+ property: z.string().describe('the NUMERIC GA4 property id from list_analytics_properties'),
3503
+ eventName: z.string().describe('an event GA4 already collects, e.g. "purchase" / "generate_lead" / "sign_up"'),
3504
+ countingMethod: z.enum(['ONCE_PER_EVENT', 'ONCE_PER_USER']).optional().describe('default ONCE_PER_EVENT'),
3505
+ },
3506
+ outputSchema: { property: z.string().optional(), keyEvent: z.any().optional() },
3507
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
3508
+ }, wrap(async (a) => {
3509
+ const d = await apiPost('/api/analytics/key-event', a);
3510
+ const k = d.keyEvent || {};
3511
+ return ok(`Property ${d.property} now counts "${k.eventName}" as a key event (${k.countingMethod}).`, d);
3512
+ }));
3513
+ server.registerTool('create_analytics_custom_dimension', {
3514
+ title: 'Create a GA4 custom dimension',
3515
+ description: 'Register an event parameter the site ALREADY SENDS as a custom dimension, so reports can break down by it (plan tier, content category, logged-in state…). TWO THINGS TO TELL THE USER BEFORE CALLING: a GA4 custom dimension CANNOT BE DELETED — only archived — and a property is capped at 50 EVENT-scoped dimensions, so a duplicate or a typo permanently burns one of them. Call list_analytics_definitions first to see what already exists. GET THE TWO NAMES THE RIGHT WAY ROUND: parameterName is the event parameter GA4 is already collecting (e.g. "customer_tier") and displayName is only the label shown in reports — swapping them silently produces a dimension that always reads "(not set)". Registering a dimension is NOT retroactive: it only collects from the moment it is created. The property is the NUMERIC id from list_analytics_properties. Needs the Google Analytics connection to have edit access on the property.',
3516
+ inputSchema: {
3517
+ property: z.string().describe('the NUMERIC GA4 property id from list_analytics_properties'),
3518
+ parameterName: z.string().describe('the event parameter GA4 already collects, e.g. "customer_tier" — NOT the report label'),
3519
+ displayName: z.string().optional().describe('the label shown in GA4 reports — defaults to parameterName'),
3520
+ scope: z.enum(['EVENT', 'USER', 'ITEM']).optional().describe('default EVENT. The 50-dimension cap applies to EVENT scope'),
3521
+ description: z.string().optional().describe('optional description, ≤150 characters'),
3522
+ },
3523
+ outputSchema: { property: z.string().optional(), customDimension: z.any().optional(), note: z.string().optional() },
3524
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
3525
+ }, wrap(async (a) => {
3526
+ const d = await apiPost('/api/analytics/custom-dimension', a);
3527
+ const c = d.customDimension || {};
3528
+ return ok(`Property ${d.property} now has custom dimension "${c.displayName}" on parameter "${c.parameterName}" (${c.scope} scope). ${d.note || ''}`.trim(), d);
3529
+ }));
3308
3530
  // ---------- Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created
3309
3531
  // Paused, only an explicit confirm:true arms real money, and every narration comes from a READ-BACK.
3310
3532
  // Microsoft's statuses are Active / Paused (never ENABLED) and it answers HTTP 200 with a PartialErrors
@@ -3888,7 +4110,7 @@ export function registerTools(rawServer, opts = {}) {
3888
4110
  }));
3889
4111
  server.registerTool('create_openai_ads_audience', {
3890
4112
  title: 'Create a ChatGPT Ads custom audience',
3891
- description: 'NOT YET AVAILABLE — ChatGPT Ads requires a customer list to be uploaded as a FILE and OpenAI does not document where that file id comes from, so this refuses cleanly rather than fail with an unreadable vendor error (verified live 2026-08-05). Build the audience in ChatGPT Ads Manager and list_openai_ads_audiences will see it. Pass plain emails and/or phone numbers: Hermoso NORMALISES AND SHA-256 HASHES THEM LOCALLY and sends only the digests, so no plaintext personal data leaves Hermoso say that to the user rather than implying their list was uploaded raw. Values that are neither an email nor a phone number are skipped and counted, never silently dropped. An audience is a definition and cannot spend.',
4113
+ description: 'Create a ChatGPT Ads custom audience from a customer list. Pass plain emails and/or phone numbers: Hermoso NORMALISES AND SHA-256 HASHES THEM LOCALLY and uploads only the digests, so no plaintext personal data leaves Hermoso. MEASURED 2026-08-09: OpenAI’s ads file endpoint only accepts IMAGE mimetypes (gif/jpeg/png/webp) and rejects a customer-list CSV under every upload purpose, so audiences are UI-only for now this tool reports OpenAI’s verbatim refusal and points the user at ChatGPT Ads Manager, and will start working unchanged the day a data-file path opens. Values that are neither an email nor a phone number are skipped and counted, never silently dropped. An audience is a definition and cannot spend.',
3892
4114
  inputSchema: {
3893
4115
  name: z.string(),
3894
4116
  members: z.array(z.string()).describe('emails and/or phone numbers (already-SHA256-hashed emails are passed through as-is)'),
@@ -4745,6 +4967,262 @@ export function registerTools(rawServer, opts = {}) {
4745
4967
  return ok(`${d.note}\n${JSON.stringify((d.changes || []).slice(0, 30))}`, d);
4746
4968
  }));
4747
4969
 
4970
+ // ══ TIKTOK ADS (Marketing API v1.3) — the ninth ad platform (2026-08-07) ═══════════════════════════════════
4971
+ // A SEPARATE CONNECTION from the TikTok POSTING connector. post_to_tiktok / list_tiktok_videos / tiktok_account
4972
+ // ride the Content Posting API on a creator's own account; everything below rides the Business (Marketing) API on
4973
+ // an ADVERTISER. Connecting one does not connect the other, and a brand that has posted for months may still have
4974
+ // no ad account here — so never read "TikTok is connected" as "TikTok Ads is connected".
4975
+ //
4976
+ // FOUR FACTS THAT DECIDE HOW THESE TOOLS ARE USED. Every one was MEASURED against TikTok's live sandbox on
4977
+ // 2026-08-06/07 — lib/tiktok-ads.mjs carries the probe transcript for each, and it is the single authority:
4978
+ // 1. TIKTOK CREATES ENABLED. Every other ad platform in this repo is born paused by the VENDOR's own default —
4979
+ // Meta hard-codes PAUSED, Google is confirm-gated — so TikTok is the ONE where omitting a field means a live
4980
+ // campaign on a real account. The server forces operation_status DISABLE at campaign, ad group AND ad level
4981
+ // and OVERRIDES a caller who asks for ENABLE. That is a law, not a default.
4982
+ // 2. QPS IS 1 on the Basic tier: TikTok answered "reaches the QPS limit 1, current QPS is 2" to TWO concurrent
4983
+ // requests. Every TikTok call is serialized server-side, so building a tree or reading in bulk is SLOW BY
4984
+ // DESIGN. A throttle is not a broken connection and must never be reported as one.
4985
+ // 3. THE RESPONSE ENVELOPE LIES: TikTok answers HTTP 200 with a NON-ZERO `code` on failure. The server
4986
+ // classifies that as an error before it reaches these tools — but never call a TikTok write successful
4987
+ // because the request returned.
4988
+ // 4. AN AD HAS NO IDENTITY OF ITS OWN and there is no default one. list_tiktok_ads_identities first, let the
4989
+ // USER pick, and never guess — an ad posts publicly as whichever TikTok account that id names.
4990
+ //
4991
+ // NO OBJECTIVE / BUDGET-MODE ENUM IS DECLARED IN THESE SCHEMAS, deliberately. lib/tiktok-ads.mjs owns the
4992
+ // offerable set and the route refuses an unavailable objective BY NAME with TikTok's own sentence; these twins
4993
+ // cannot import that module (the published npm package ships no lib/), so an enum here would be a second copy
4994
+ // that rots silently and starts refusing values TikTok accepts. The prose below is guidance; the server decides.
4995
+ //
4996
+ // EVERY PARAMETER BELOW WAS READ OFF THE ROUTE HANDLER IT POSTS TO, not off a spec: a field the route does not
4997
+ // read is a field silently dropped, which is how a targeting knob comes to be offerable and undeliverable.
4998
+ // The status read-back as ONE sentence, shared by set_tiktok_ads_status and delete_tiktok_ads_object so the two
4999
+ // cannot describe the same vendor operation two different ways. It prints what TikTok STORED per id — never the
5000
+ // status that was asked for — and says so out loud when the read-back could not confirm every id.
5001
+ const ttStatusLine = (d) => {
5002
+ const read = d.read || [];
5003
+ const body = read.length ? read.map(r => `${r.id} → ${r.status}`).join(', ') : '(TikTok returned no rows on the read-back)';
5004
+ return `TikTok ${d.level} — requested ${d.requested}. Read back from TikTok: ${body}.${d.verified ? '' : ` ⚠ ${d.note || 'The read-back did not return every id, so this is what TikTok ACCEPTED, not what it is confirmed to have stored.'}`}`;
5005
+ };
5006
+ server.registerTool('list_tiktok_ads_accounts', {
5007
+ title: 'List TikTok advertiser accounts',
5008
+ description: 'List the TikTok ADVERTISER accounts this brand can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Settings ▸ Connectors ▸ TikTok Ads) — that is a DIFFERENT connection from the TikTok posting connector behind post_to_tiktok, so a brand that publishes to TikTok every day may still have nothing here.',
5009
+ inputSchema: {},
5010
+ outputSchema: { advertisers: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
5011
+ annotations: { readOnlyHint: true, openWorldHint: true },
5012
+ }, wrap(async () => {
5013
+ const d = await apiGet('/api/tiktok-ads/accounts', {});
5014
+ const list = d.advertisers || [];
5015
+ if (!list.length) return ok('TikTok Ads is connected but NO advertiser account is reachable from it. The user needs an advertiser account on this TikTok Business login. Do not guess an advertiserId.', d);
5016
+ return ok(`${list.length} TikTok advertiser account(s):\n${list.map(x => `• ${x.name} (${x.advertiserId})${x.currency ? ` — ${x.currency}` : ''}${x.timezone ? `, ${x.timezone}` : ''}${x.status ? ` · ${x.status}` : ''}`).join('\n')}\nPass an advertiserId to read or build on one.`, d);
5017
+ }));
5018
+ server.registerTool('list_tiktok_ads_campaigns', {
5019
+ title: 'List TikTok campaigns, ad groups and ads',
5020
+ description: 'Read a TikTok advertiser account’s whole tree in one call — campaigns, ad groups and ads, each with the operation status that says whether it is enabled at all. Omit advertiserId when the brand reaches exactly one account; with several, the call names the choices rather than picking for you. THE THREE TIERS ARE READ SEPARATELY AND A TIER THAT FAILED IS REPORTED AS FAILED, never as empty — if the result carries a `partial` note, say which tier could not be read instead of telling the user they have no ads. Read-only, free. TikTok’s QPS is 1, so a big account reads back slowly: that is the throttle working, not a fault.',
5021
+ inputSchema: { advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit only when exactly one is reachable') },
5022
+ outputSchema: { advertiserId: z.string().optional(), name: z.string().optional(), campaigns: z.array(z.any()).optional(), adGroups: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), partial: z.record(z.any()).optional() },
5023
+ annotations: { readOnlyHint: true, openWorldHint: true },
5024
+ }, wrap(async (a) => {
5025
+ const d = await apiGet('/api/tiktok-ads/campaigns', a);
5026
+ const c = (d.campaigns || []).map(x => `• campaign ${x.campaign_name} (${x.campaign_id}) — ${x.operation_status}${x.objective_type ? `, ${x.objective_type}` : ''}`);
5027
+ const g = (d.adGroups || []).map(x => `• ad group ${x.adgroup_name} (${x.adgroup_id}) — ${x.operation_status}`);
5028
+ const s = (d.ads || []).map(x => `• ad ${x.ad_name} (${x.ad_id}) — ${x.operation_status}`);
5029
+ // A TIER THAT THREW IS NOT A TIER THAT IS EMPTY ([[failed-read-is-not-empty]]) — the route reports the two
5030
+ // apart and the sentence has to keep them apart, or "0 campaigns" reads as a fact about the account.
5031
+ const bad = Object.entries(d.partial || {}).filter(([, v]) => v);
5032
+ return ok(`TikTok advertiser ${d.advertiserId}${d.name ? ` (${d.name})` : ''}: ${(d.campaigns || []).length} campaign(s), ${(d.adGroups || []).length} ad group(s), ${(d.ads || []).length} ad(s).\n${[...c, ...g, ...s].join('\n') || '(empty)'}${bad.length ? `\n⚠ COULD NOT READ ${bad.map(([k, v]) => `${k} (${v})`).join('; ')} — those counts are unknown, NOT zero. Say so rather than reporting an empty account.` : ''}`, d);
5033
+ }));
5034
+ server.registerTool('list_tiktok_ads_identities', {
5035
+ title: 'List the TikTok identities an ad can post as',
5036
+ description: 'List the IDENTITIES on a TikTok advertiser account — the TikTok accounts an ad is allowed to appear as. THIS IS A HARD PREREQUISITE, not a convenience: a TikTok ad carries no identity of its own and there is NO DEFAULT, so create_tiktok_ads_ad refuses without an id from here. Call it, show the user the list, and let them CHOOSE — an ad runs publicly under whichever account is named, so picking one for them is a public mistake on somebody else’s profile. If it comes back empty, no TikTok account has been authorised on this advertiser yet and nothing can be advertised from it; say that rather than guessing an id. Read-only, free.',
5037
+ inputSchema: { advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts') },
5038
+ outputSchema: { advertiserId: z.string().optional(), identities: z.array(z.any()).optional() },
5039
+ annotations: { readOnlyHint: true, openWorldHint: true },
5040
+ }, wrap(async (a) => {
5041
+ const d = await apiGet('/api/tiktok-ads/identities', a);
5042
+ const list = d.identities || [];
5043
+ if (!list.length) return ok('This TikTok advertiser account has NO identity attached, so no ad can be created on it yet — a TikTok ad must post as a specific TikTok account and TikTok provides no default. The user has to authorise one in TikTok Ads Manager first. Do not guess an identityId.', d);
5044
+ return ok(`${list.length} TikTok identit${list.length === 1 ? 'y' : 'ies'} — let the USER pick which one the ad posts as, then pass BOTH fields to create_tiktok_ads_ad:\n${list.map(x => `• ${x.name || '(unnamed)'} — identityId ${x.identityId}, identityType ${x.identityType}`).join('\n')}`, d);
5045
+ }));
5046
+ server.registerTool('search_tiktok_ads_targeting', {
5047
+ title: 'Resolve TikTok locations / interests / hashtags / languages',
5048
+ description: 'Look up the exact ids TikTok ad-group targeting expects, so none of them has to be invented. kind:"location" resolves TikTok’s targetable regions — an ad group CANNOT be created without location ids, TikTok refuses it in its own words ("‘location_ids’ or ‘zipcode_ids’ must be specified"). kind:"interest" resolves the interest categories, kind:"hashtag" the recommended interest keywords, kind:"language" the language codes. A made-up id either fails the create or, worse, targets somebody else and spends money silently, so always resolve here first and never guess. Read-only, free.',
5049
+ inputSchema: {
5050
+ advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — TikTok scopes these lookups to an advertiser'),
5051
+ kind: z.enum(['location', 'interest', 'hashtag', 'language']).optional().describe('default location'),
5052
+ keyword: z.string().optional().describe('narrows the lookup, e.g. "Canada", "Beauty", "skincare"'),
5053
+ },
5054
+ outputSchema: { advertiserId: z.string().optional(), kind: z.string().optional(), results: z.array(z.any()).optional() },
5055
+ annotations: { readOnlyHint: true, openWorldHint: true },
5056
+ }, wrap(async (a) => {
5057
+ const d = await apiGet('/api/tiktok-ads/targeting', a);
5058
+ return ok(`${(d.results || []).length} TikTok ${d.kind || a.kind || 'location'} result(s) on advertiser ${d.advertiserId}:\n${JSON.stringify((d.results || []).slice(0, 30))}`, d);
5059
+ }));
5060
+ server.registerTool('tiktok_ads_report', {
5061
+ title: 'TikTok ads performance report',
5062
+ description: 'Performance for a TikTok advertiser account at campaign, ad group or ad level — spend, impressions, clicks, CTR, CPC, CPM and conversions by default, or whichever of TikTok’s metrics and grouping dimensions you name. A report with ZERO rows genuinely means nothing delivered in that window — say exactly that, and never present zeros as measured performance. Read-only, free, spends nothing. NOTE: everything Hermoso creates on TikTok is created PAUSED, so a brand-new build reports nothing at all until somebody activates it with set_tiktok_ads_status(confirm:true) — check the statuses with list_tiktok_ads_campaigns before reading an empty report as bad performance.',
5063
+ inputSchema: {
5064
+ advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
5065
+ level: z.enum(['campaign', 'adgroup', 'ad']).optional().describe('the reporting level — default campaign'),
5066
+ startDate: z.string().optional().describe('YYYY-MM-DD'),
5067
+ endDate: z.string().optional().describe('YYYY-MM-DD'),
5068
+ metrics: z.array(z.string()).optional().describe('TikTok metric names — default ["spend","impressions","clicks","ctr","cpc","cpm","conversion"]'),
5069
+ dimensions: z.array(z.string()).optional().describe('TikTok dimensions to group by — default the level’s own id, e.g. ["campaign_id"]. Add "stat_time_day" for a daily breakdown.'),
5070
+ },
5071
+ outputSchema: { advertiserId: z.string().optional(), level: z.string().optional(), rows: z.array(z.any()).optional(), metrics: z.array(z.string()).optional(), dimensions: z.array(z.string()).optional() },
5072
+ annotations: { readOnlyHint: true, openWorldHint: true },
5073
+ }, wrap(async (a) => {
5074
+ // The route takes metrics/dimensions as CSV on the query string; arrays are what an agent naturally holds, so
5075
+ // join here rather than making every caller remember the wire shape.
5076
+ const d = await apiGet('/api/tiktok-ads/report', { ...a, metrics: a.metrics ? a.metrics.join(',') : undefined, dimensions: a.dimensions ? a.dimensions.join(',') : undefined });
5077
+ const rows = d.rows || [];
5078
+ if (!rows.length) return ok(`NO ROWS for advertiser ${d.advertiserId} at ${d.level} level in that window. That means nothing DELIVERED — it is not a measurement of zero performance, and on a freshly built account it usually means nothing has been activated yet.`, d);
5079
+ return ok(`${rows.length} row(s) from TikTok (${d.level} level, metrics ${(d.metrics || []).join(', ')}):\n${JSON.stringify(rows.slice(0, 40))}`, d);
5080
+ }));
5081
+ server.registerTool('create_tiktok_ads_campaign', {
5082
+ title: 'Create a TikTok campaign (forced paused)',
5083
+ description: 'Create the top tier of a TikTok ad — the campaign, which fixes the OBJECTIVE everything under it optimises toward. CREATED PAUSED AND THERE IS NO OVERRIDE, and TikTok is the one platform where that is NOT the vendor’s own behaviour: TikTok creates ENABLED by default, so Hermoso forces operation_status DISABLE and overrides a caller who asks for ENABLE. It spends nothing until set_tiktok_ads_status(confirm:true). OBJECTIVES that create cleanly, each proven with a real create: TRAFFIC (the default), REACH, VIDEO_VIEWS, ENGAGEMENT, WEB_CONVERSIONS, LEAD_GENERATION, APP_INSTALL, PRODUCT_SALES, CONVERSIONS. RF_REACH (Reach & Frequency) also works but ONLY with budgetMode BUDGET_MODE_INFINITE — TikTok’s own words are "The budget type for Reach & Frequency must be unlimited". Five further values sit in TikTok’s schema enum and are REFUSED BY NAME with the reason (CATALOG_SALES is retired, SHOP_PURCHASES needs TikTok Shop on the advertiser, and APP_PROMOTION / RF_ENGAGEMENT / RF_APP_INSTALL are withheld) — the server owns that list, so a refusal names its cause and is TikTok’s restriction rather than a Hermoso fault. budgetMode is BUDGET_MODE_DAY (the default), BUDGET_MODE_TOTAL, BUDGET_MODE_DYNAMIC_DAILY_BUDGET or BUDGET_MODE_INFINITE. A campaign alone can never serve: build an ad group under it, then an ad. The result is READ BACK from TikTok and says so when the read-back could not be run.',
5084
+ inputSchema: {
5085
+ advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit only when exactly one is reachable'),
5086
+ name: z.string().describe('the campaign name in TikTok Ads Manager'),
5087
+ objective: z.string().optional().describe('default TRAFFIC. e.g. TRAFFIC, REACH, VIDEO_VIEWS, ENGAGEMENT, WEB_CONVERSIONS, LEAD_GENERATION, APP_INSTALL, PRODUCT_SALES, CONVERSIONS — or RF_REACH with budgetMode BUDGET_MODE_INFINITE. The server validates it and refuses an unavailable one BY NAME with TikTok’s reason.'),
5088
+ budgetMode: z.string().optional().describe('default BUDGET_MODE_DAY — or BUDGET_MODE_TOTAL / BUDGET_MODE_DYNAMIC_DAILY_BUDGET / BUDGET_MODE_INFINITE. RF_REACH requires BUDGET_MODE_INFINITE.'),
5089
+ budget: z.number().optional().describe('campaign budget in the advertiser’s own currency — not needed with BUDGET_MODE_INFINITE'),
5090
+ },
5091
+ outputSchema: { id: z.string().optional(), advertiserId: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
5092
+ annotations: { readOnlyHint: false, openWorldHint: true },
5093
+ }, wrap(async (a) => {
5094
+ const d = await apiPost('/api/tiktok-ads/campaign', a);
5095
+ return ok(`Created TikTok campaign "${d.name}" (${d.id}) — objective ${d.objective}, status ${d.status}. ${d.note} Next: create_tiktok_ads_ad_group under it, then list_tiktok_ads_identities and create_tiktok_ads_ad.`, d);
5096
+ }));
5097
+ server.registerTool('create_tiktok_ads_ad_group', {
5098
+ title: 'Create a TikTok ad group (targeting, budget, bidding, schedule)',
5099
+ description: 'Create an ad group under an existing TikTok campaign — the tier that holds the budget, the bid, the placements, the schedule and ALL the targeting. CREATED PAUSED with no override (TikTok would otherwise create it ENABLED, and a live ad group under a paused campaign is exactly how spend escapes a one-level pause); it spends nothing until set_tiktok_ads_status(confirm:true). TIKTOK REQUIRES THIRTEEN FIELDS TOGETHER and its error names only the FIRST missing one per round trip, so the server checks the whole set locally and refuses with EVERY absentee at once — which matters here because QPS is 1 and each round trip costs a second. Sensible defaults are supplied for placement, pacing, promotion type, schedule type, optimization goal, billing event, bid type and budget mode, so the ONE thing you must resolve yourself is LOCATION: locationIds is effectively mandatory (TikTok: "‘location_ids’ or ‘zipcode_ids’ must be specified") and comes from search_tiktok_ads_targeting(kind:"location"). Resolve interest and language ids the same way — a guessed id either fails the create or targets the wrong people, silently. The result is READ BACK from TikTok.',
5100
+ inputSchema: {
5101
+ advertiserId: z.string().optional(),
5102
+ campaignId: z.string().describe('the campaign this ad group belongs to'),
5103
+ name: z.string(),
5104
+ locationIds: z.array(z.string()).describe('REQUIRED in practice — ids from search_tiktok_ads_targeting(kind:"location"); TikTok refuses an ad group without them'),
5105
+ placements: z.array(z.string()).optional().describe('default ["PLACEMENT_TIKTOK"]'),
5106
+ placementType: z.string().optional().describe('default PLACEMENT_TYPE_NORMAL'),
5107
+ optimizationGoal: z.string().optional().describe('what TikTok optimises delivery toward — default CLICK; it has to fit the campaign objective and TikTok’s refusal names the valid values'),
5108
+ billingEvent: z.string().optional().describe('what TikTok charges for — default CPC; must fit the optimization goal'),
5109
+ bidType: z.string().optional().describe('default BID_TYPE_NO_BID (let TikTok bid). A manual bid type needs bid.'),
5110
+ bid: z.number().optional().describe('bid price in the advertiser’s own currency — required by the manual bid types'),
5111
+ pacing: z.string().optional().describe('default PACING_MODE_SMOOTH, which spends the budget evenly across the day. PACING_MODE_FAST spends as fast as delivery allows — a real money decision, so it is never chosen for you.'),
5112
+ budgetMode: z.string().optional().describe('default BUDGET_MODE_DAY'),
5113
+ budget: z.number().optional().describe('ad-group budget in the advertiser’s own currency'),
5114
+ scheduleType: z.string().optional().describe('default SCHEDULE_FROM_NOW'),
5115
+ scheduleStartTime: z.string().optional().describe('"YYYY-MM-DD HH:MM:SS" in the advertiser’s timezone'),
5116
+ scheduleEndTime: z.string().optional().describe('only needed when the schedule type is a fixed window'),
5117
+ promotionType: z.string().optional().describe('what is being promoted — default WEBSITE'),
5118
+ ageGroups: z.array(z.string()).optional().describe('TikTok age band values — omit to reach every age'),
5119
+ genders: z.array(z.string()).optional().describe('omit to reach everyone'),
5120
+ languages: z.array(z.string()).optional().describe('language codes from search_tiktok_ads_targeting(kind:"language")'),
5121
+ interestCategoryIds: z.array(z.string()).optional().describe('ids from search_tiktok_ads_targeting(kind:"interest")'),
5122
+ },
5123
+ outputSchema: { id: z.string().optional(), advertiserId: z.string().optional(), campaignId: z.string().optional(), name: z.string().optional(), status: z.string().optional(), optimizationGoal: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
5124
+ annotations: { readOnlyHint: false, openWorldHint: true },
5125
+ }, wrap(async (a) => {
5126
+ const d = await apiPost('/api/tiktok-ads/adgroup', a);
5127
+ return ok(`Created TikTok ad group "${d.name}" (${d.id}) under campaign ${d.campaignId} — status ${d.status}, optimising for ${d.optimizationGoal}${d.verified ? '' : ' (the read-back did not confirm it — check TikTok Ads Manager)'}. ${d.note} Next: list_tiktok_ads_identities, then create_tiktok_ads_ad.`, d);
5128
+ }));
5129
+ // THE STEP THAT TURNS A RENDER INTO AN AD. Without it create_tiktok_ads_ad had no reachable source of a videoId
5130
+ // and the ad lane could not be exercised at all — it was the one BLOCKED cell in the ads matrix.
5131
+ server.registerTool('upload_tiktok_ads_creative', {
5132
+ title: 'Upload a video or image into a TikTok advertiser’s asset library',
5133
+ description: 'Put a finished creative onto the TikTok AD ACCOUNT so an ad can point at it — this is the bridge between making an ad and running one, and create_tiktok_ads_ad has no other source for the ids it needs. Pass the public https url of a Hermoso render (every render already is one) or any other public https file; TikTok fetches it from its own servers, so a local path, a data: url or anything on http:// is refused here for free. A VIDEO UPLOAD ALSO UPLOADS ITS OWN COVER and hands back coverImageId — pass that straight to create_tiktok_ads_ad as imageIds, because TikTok requires a cover on a video ad and rejects any whose dimensions differ from the video. CHECK usableInAds BEFORE BUILDING THE AD: TikTok happily accepts a creative it will then refuse to build an ad from, and says so only in this flag — a 720x1280 render is fine and a 496x864 one is not, so a false here means re-render larger, and the note says so. Uploading the same file twice is fine; TikTok refuses a repeated file NAME, so leave fileName out unless you want a specific label in TikTok Ads Manager. Free — TikTok charges nothing for storage and Hermoso bills no credits for this.',
5134
+ inputSchema: {
5135
+ advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit only when exactly one is reachable'),
5136
+ kind: z.enum(['video', 'image']).optional().describe('default video'),
5137
+ url: z.string().describe('public https url of the file. A Hermoso render url works as-is; for a local or external file run upload_file first to get one.'),
5138
+ fileName: z.string().optional().describe('the label in TikTok Ads Manager. Omit it and a unique one is derived — TikTok rejects a DUPLICATE name on the same advertiser, so an explicit name that has been used before is refused.'),
5139
+ },
5140
+ outputSchema: { advertiserId: z.string().optional(), kind: z.string().optional(), videoId: z.string().nullable().optional(), imageId: z.string().nullable().optional(), coverImageId: z.string().nullable().optional(), materialId: z.string().nullable().optional(), width: z.number().nullable().optional(), height: z.number().nullable().optional(), durationSeconds: z.number().nullable().optional(), displayable: z.boolean().nullable().optional(), usableInAds: z.boolean().optional(), allowedPlacements: z.array(z.string()).optional(), note: z.string().optional(), coverNote: z.string().optional() },
5141
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
5142
+ }, wrap(async (a) => {
5143
+ const d = await apiPost('/api/tiktok-ads/upload', a);
5144
+ const size = d.width && d.height ? ` ${d.width}x${d.height}` : '';
5145
+ const id = d.kind === 'image' ? `imageId ${d.imageId}` : `videoId ${d.videoId}`;
5146
+ const cover = d.kind === 'video' ? (d.coverImageId ? ` Cover image uploaded too — pass imageIds:["${d.coverImageId}"] alongside it.` : ` ${d.coverNote || 'No cover image was produced, and a video ad needs one.'}`) : '';
5147
+ // usableInAds:false is the whole reason this tool reports rather than just returns an id — it is the only
5148
+ // moment the problem is still free to fix, so the reason is printed rather than left in the payload.
5149
+ return ok(`Uploaded to TikTok advertiser ${d.advertiserId} —${size} ${id}.${d.usableInAds ? ' TikTok reports it is usable in ads.' : ` ⚠ ${d.note || 'TikTok has not confirmed this creative is usable in ads.'}`}${cover}`, d);
5150
+ }));
5151
+ server.registerTool('create_tiktok_ads_ad', {
5152
+ title: 'Create a TikTok ad',
5153
+ description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with "Unsupported image size", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a ⚠ warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
5154
+ inputSchema: {
5155
+ advertiserId: z.string().optional(),
5156
+ adgroupId: z.string().describe('the ad group whose targeting, budget and schedule this ad runs under'),
5157
+ name: z.string().describe('the ad name in TikTok Ads Manager — not the copy people see'),
5158
+ identityId: z.string().describe('REQUIRED — from list_tiktok_ads_identities; the TikTok account the ad posts as'),
5159
+ identityType: z.string().describe('REQUIRED — the matching type reported next to that id by list_tiktok_ads_identities. There is NO default and guessing is refused: the plausible-looking CUSTOMIZED_USER is wrong for a plain TikTok account, which is TT_USER.'),
5160
+ adFormat: z.string().optional().describe('default SINGLE_VIDEO'),
5161
+ videoId: z.string().optional().describe('the videoId returned by upload_tiktok_ads_creative (or a video already in the advertiser’s TikTok asset library)'),
5162
+ imageIds: z.array(z.string()).optional().describe('for a video ad, the ONE cover image: pass [coverImageId] from the same upload_tiktok_ads_creative call. For the image formats, the images themselves.'),
5163
+ adText: z.string().optional().describe('the ad copy people read'),
5164
+ callToAction: z.string().optional().describe('TikTok’s CTA button value — default LEARN_MORE'),
5165
+ landingPageUrl: z.string().optional().describe('where the ad sends people'),
5166
+ },
5167
+ outputSchema: { id: z.string().optional(), advertiserId: z.string().optional(), adgroupId: z.string().optional(), name: z.string().optional(), status: z.string().nullable().optional(), identityId: z.string().optional(), videoId: z.string().nullable().optional(), imageIds: z.array(z.string()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
5168
+ annotations: { readOnlyHint: false, openWorldHint: true },
5169
+ }, wrap(async (a) => {
5170
+ const d = await apiPost('/api/tiktok-ads/ad', a);
5171
+ return ok(`Created TikTok ad "${d.name}" (${d.id}) in ad group ${d.adgroupId}, posting as identity ${d.identityId} — TikTok stored it as ${d.status || 'an unreported status'}. ${d.note} TikTok still has to review it before it can show.`, d);
5172
+ }));
5173
+ server.registerTool('set_tiktok_ads_status', {
5174
+ title: 'Activate, pause or delete TikTok campaigns / ad groups / ads',
5175
+ description: 'THE ONE SWITCH THAT ARMS REAL MONEY ON TIKTOK. Pass level ("campaign", "adgroup" or "ad"), the ids, and a status: ENABLE starts real spend on the next auction, DISABLE stops it, DELETE removes the objects (TikTok models removal as a STATUS — it publishes no delete verb — which is why delete_tiktok_ads_object is a thin wrapper over this same route). EVERY status change here needs confirm:true, and CALLING IT WITHOUT confirm CHANGES NOTHING and hands back the sentence describing exactly what would happen — show the user that, get an unambiguous yes, then confirm. EVERY TIER HAS TO BE ENABLED FOR AN IMPRESSION TO SERVE: Hermoso creates all three paused, so enabling the campaign alone does nothing while its ad group and ad are still disabled, and each level is a separate call. Ids can be passed in bulk, but TikTok’s QPS is 1 and calls are serialized, so a long list is simply slow. THE ANSWER IS THE READ-BACK: the result carries what TikTok STORED per id, plus a note when the read-back did not return every id — repeat that rather than the status you asked for.',
5176
+ inputSchema: {
5177
+ advertiserId: z.string().optional(),
5178
+ level: z.enum(['campaign', 'adgroup', 'ad']).describe('which tier the ids belong to'),
5179
+ ids: z.array(z.string()).describe('the campaign / ad group / ad ids to change'),
5180
+ status: z.enum(['ENABLE', 'DISABLE', 'DELETE']).describe('ENABLE = start real spend; DISABLE = stop; DELETE = remove (TikTok has no delete verb, removal is a status)'),
5181
+ confirm: z.boolean().optional().describe('REQUIRED true — call without it first to see exactly what would change'),
5182
+ },
5183
+ outputSchema: { advertiserId: z.string().optional(), level: z.string().optional(), requested: z.string().optional(), read: z.array(z.any()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
5184
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5185
+ }, wrap(async (a) => {
5186
+ const d = await apiPost('/api/tiktok-ads/status', a);
5187
+ return ok(ttStatusLine(d), d);
5188
+ }));
5189
+ server.registerTool('set_tiktok_ads_budget', {
5190
+ title: 'Change a TikTok campaign or ad group budget',
5191
+ description: 'Change the budget on a TikTok campaign (level:"campaign") or ad group (level:"adgroup"), in the advertiser’s own currency. It needs confirm:true, and CALLING IT WITHOUT confirm CHANGES NOTHING and returns the sentence naming the object and the new amount — show the user that first. Everything Hermoso creates on TikTok is forced paused, so a budget change on one of those spends nothing; on an object somebody has ENABLED it takes effect on the next auction, and LOWERING the budget is the fastest way to slow real money down short of set_tiktok_ads_status(status:"DISABLE"). The new budget is READ BACK from TikTok — report what it returns, not what you sent, and say so plainly when the read-back could not confirm it.',
5192
+ inputSchema: {
5193
+ advertiserId: z.string().optional(),
5194
+ level: z.enum(['campaign', 'adgroup']).optional().describe('which tier holds the budget — default campaign'),
5195
+ id: z.string().describe('the campaign or ad group id'),
5196
+ budget: z.number().describe('the new budget in the advertiser’s own currency — must be above zero'),
5197
+ budgetMode: z.string().optional().describe('default BUDGET_MODE_DAY'),
5198
+ confirm: z.boolean().optional().describe('REQUIRED true — call without it first to see exactly what would change'),
5199
+ },
5200
+ outputSchema: { advertiserId: z.string().optional(), level: z.string().optional(), id: z.string().optional(), requested: z.number().optional(), read: z.number().nullable().optional(), verified: z.boolean().optional() },
5201
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
5202
+ }, wrap(async (a) => {
5203
+ const d = await apiPost('/api/tiktok-ads/budget', a);
5204
+ return ok(d.verified
5205
+ ? `TikTok ${d.level} ${d.id} budget is now ${d.read} (read back from TikTok).`
5206
+ : `TikTok accepted a budget of ${d.requested} for ${d.level} ${d.id}, but the read-back could not confirm what it STORED — check TikTok Ads Manager before relying on it.`, d);
5207
+ }));
5208
+ server.registerTool('delete_tiktok_ads_object', {
5209
+ title: 'Delete TikTok campaigns / ad groups / ads',
5210
+ description: 'Remove TikTok campaigns, ad groups or ads. TIKTOK HAS NO DELETE VERB — removal is modelled as a STATUS, exactly like Reddit — so this posts to the same route as set_tiktok_ads_status with status:"DELETE" rather than being a second implementation with its own rules. It is permanent, TikTok publishes no undelete, and it needs confirm:true; calling it WITHOUT confirm changes nothing and returns the sentence naming how many objects go, which is what you show the user first. TikTok does NOT document whether removing a campaign takes its ad groups and ads with it — each carries its own status — so remove the children yourself if they should go too, and VERIFY with list_tiktok_ads_campaigns afterwards rather than assuming a cascade either way. TO STOP DELIVERY WITHOUT DESTROYING ANYTHING, use set_tiktok_ads_status(status:"DISABLE"): that is reversible and this is not.',
5211
+ inputSchema: {
5212
+ advertiserId: z.string().optional(),
5213
+ level: z.enum(['campaign', 'adgroup', 'ad']).describe('which tier the ids belong to'),
5214
+ ids: z.array(z.string()).describe('the campaign / ad group / ad ids to remove'),
5215
+ confirm: z.boolean().optional().describe('REQUIRED true — the removal is permanent'),
5216
+ },
5217
+ outputSchema: { advertiserId: z.string().optional(), level: z.string().optional(), requested: z.string().optional(), read: z.array(z.any()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
5218
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5219
+ }, wrap(async (a) => {
5220
+ // ONE code path with set_tiktok_ads_status, deliberately: TikTok's removal IS a status, so a second
5221
+ // implementation would be a second set of rules for the same vendor operation.
5222
+ const d = await apiPost('/api/tiktok-ads/delete', a); // the platform's own removal route — it delegates to the gated status setter, so the confirm gate and read-back are the same ones
5223
+ return ok(`${ttStatusLine(d)} Removal on TikTok is permanent and TikTok does not document whether it cascades — re-read with list_tiktok_ads_campaigns before telling the user the children are gone too.`, d);
5224
+ }));
5225
+
4748
5226
  // ══ LINKEDIN COMPANY PAGES + ADS (2026-07-30) ══════════════════════════════════════════════════════════════
4749
5227
  server.registerTool('list_linkedin_pages', {
4750
5228
  title: 'List the LinkedIn company Pages this account administers',
@@ -5493,7 +5971,7 @@ export function registerTools(rawServer, opts = {}) {
5493
5971
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
5494
5972
  product: z.string().describe('what to advertise + any angle/offer the user specified'),
5495
5973
  format: z.enum(['auto', 'image', 'video']).optional().describe("'image', 'video', or 'auto' when unspecified"),
5496
- durationSeconds: z.number().optional().describe('VIDEO ONLY — the total spot length the user explicitly asked for, in seconds, copied verbatim (30 for "a 30 second ad"). The planner authors the storyboard TO it: the scenes’ seconds sum to it and the script is word-budgeted for it. Supported range 4–180; anything outside is CLAMPED to it (the reply says so). One model clip caps at 15s, so ≤15 renders as a single continuous pass and anything longer is STITCHED from acts filled to 15s with the remainder last (40 → 15+15+10, 17 → 13+4) — never time-compressed. Omit when the user named no length; do NOT pass a guess, an omitted value keeps the recipe-aware default.'),
5974
+ durationSeconds: z.number().optional().describe('VIDEO ONLY — the total spot length the user explicitly asked for, in seconds, copied verbatim (30 for "a 30 second ad"). The planner authors the storyboard TO it: the scenes’ seconds sum to it and the script is word-budgeted for it. Supported range 4–180; anything outside is CLAMPED to it (the reply says so). A length that fits ONE clip of the render model renders as a single continuous pass; anything longer is STITCHED from acts filled to that model’s clip maximum with the remainder last (on a 15s-clip model, 40 → 15+15+10 and 17 → 13+4) — never time-compressed. The maximum is the model’s own: 15s on most, 30s on the longest-clip model, so a 30s ad can be one unbroken take rather than two acts. Omit when the user named no length; do NOT pass a guess, an omitted value keeps the recipe-aware default.'),
5497
5975
  hook: z.string().optional().describe('force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "macro_asmr"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.'),
5498
5976
  setting: z.string().optional().describe('force the WHERE — a setting id from list_hooks (e.g. "kitchen", "gym", or a surreal one like "volcano_rim" / "airplane_wing", which are played 100% straight and never acknowledged). Omit for a neutral setting.'),
5499
5977
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
@@ -5548,12 +6026,17 @@ export function registerTools(rawServer, opts = {}) {
5548
6026
  let _lenLine = '';
5549
6027
  if (c.format === 'video') {
5550
6028
  const _planned = Math.round(+c.render_plan?.duration_seconds || (c.video_storyboard?.scenes || []).reduce((s, x) => s + (+x.seconds || 0), 0) || 0);
5551
- const _struct = String(c.render_plan?.structure || (_planned > 15 ? 'stitched_acts' : 'single_clip'));
5552
- _lenLine = `\nLength: ${_planned || ''}s (${_struct === 'stitched_acts' ? 'stitched acts, each ≤15s' : _struct === 'carousel' ? 'carousel slides' : 'one continuous clip'})`
6029
+ // A GUESSED STRUCTURE IS LABELLED AS ONE. The plan normally DECLARES structure; when it does not we fall back on
6030
+ // the ordinary 15s clip ceiling which is now only true of most models, not all (the longest-clip model does 30s
6031
+ // in one take), so an inferred label is marked rather than printed as fact. Same law as the delivered-length
6032
+ // read-back: never state as measured something we computed for ourselves.
6033
+ const _declared = String(c.render_plan?.structure || '');
6034
+ const _struct = _declared || (_planned > 15 ? 'stitched_acts' : 'single_clip');
6035
+ _lenLine = `\nLength: ${_planned || '—'}s (${_struct === 'stitched_acts' ? 'stitched acts, each at most ONE clip of the render model' : _struct === 'carousel' ? 'carousel slides' : 'one continuous clip'}${_declared ? '' : ' — inferred, the plan declared no structure'})`
5553
6036
  + (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
5554
6037
  + (_len && _planned && Math.abs(_planned - _len) > 1 ? ` — ⚠ this does NOT match the ${_len}s you asked for; tell the user before rendering, or re-plan` : '');
5555
6038
  }
5556
- const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a ≤15s storyboard renders as ONE single-pass clip, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
6039
+ const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
5557
6040
  return ok(text, c);
5558
6041
  }));
5559
6042
 
@@ -5693,14 +6176,14 @@ export function registerTools(rawServer, opts = {}) {
5693
6176
  server.group('create');
5694
6177
  server.registerTool('render_ad', {
5695
6178
  title: 'Render ad video',
5696
- description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a ≤15s storyboard renders as ONE single-pass clip; a longer plan automatically renders as STITCHED ACTS (fewest balanced ≤15s clips) — never time-compressed into one clip. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
6179
+ description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
5697
6180
  inputSchema: {
5698
6181
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
5699
6182
  creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if the plan has nobody on camera, or if they are a REAL person with no likeness consent on file.'),
5700
6183
  model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
5701
- durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. ≤15s renders as one clip; longer is stitched from acts filled to 15s with the remainder last — use dryRun:true to see the exact act split for free before spending.'),
6184
+ durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
5702
6185
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
5703
- resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
6186
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
5704
6187
  captions: z.boolean().optional().describe('composited caption pills on/off (default: the recipe decides)'),
5705
6188
  endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
5706
6189
  music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
@@ -5932,15 +6415,15 @@ export function registerTools(rawServer, opts = {}) {
5932
6415
 
5933
6416
  server.registerTool('generate_video', {
5934
6417
  title: 'Generate video',
5935
- description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
6418
+ description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them and not by omitting the field. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
5936
6419
  inputSchema: {
5937
6420
  prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
5938
6421
  refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
5939
6422
  refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the omni engine accepts a raw clip and transforms it per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit to generate a fresh clip."),
5940
- durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the chosen model’s listed durations from hermoso_capabilities (seedance-2/kling-3: 5/10/15). This is a single continuous generation, so it CANNOT exceed the model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). For a spot longer than one clip, use plan_ad with durationSeconds then render_ad, which stitches 15s acts (40s = 15+15+10).'),
6423
+ durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the CHOSEN model’s listed durations from hermoso_capabilities (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 40s = 15+15+10).'),
5941
6424
  aspectRatio: z.string().optional().describe("default '9:16'"),
5942
6425
  model: z.string().optional().describe('video model id from hermoso_capabilities. Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback); omit it to let the router pick'),
5943
- resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
6426
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
5944
6427
  ttsScript: z.string().optional().describe('voiceover script to speak'),
5945
6428
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
5946
6429
  musicMood: z.string().optional().describe('WHICH mood the music bed is composed in (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic). It does NOT decide WHETHER there is one: a clip that comes back with no audio track — every model hermoso_capabilities lists as "silent", plus any audio model that returned mute — gets a bed composed and CHARGED automatically, at the flat per-track fee hermoso_capabilities reports as explainerMusicCredits, and omitting this field only means the mood defaults to "warm". Pass audio:false for a genuinely silent clip with no bed and no bed charge.'),
@@ -5980,7 +6463,7 @@ export function registerTools(rawServer, opts = {}) {
5980
6463
  image: z.string().describe('local path or URL of the presenter portrait'),
5981
6464
  script: z.string().describe('the words the avatar speaks'),
5982
6465
  voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
5983
- resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
6466
+ resolution: z.string().optional().describe("'1080p' (default) or '480p'/'720p' draft"),
5984
6467
  },
5985
6468
  outputSchema: { ...JOB_OUT },
5986
6469
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -5993,13 +6476,13 @@ export function registerTools(rawServer, opts = {}) {
5993
6476
 
5994
6477
  server.registerTool('stitch_video', {
5995
6478
  title: 'Stitch multi-scene video',
5996
- description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than one model clip (>15s). A ≤15s multi-beat ad renders better and cheaper as ONE single-pass generate_video/render_ad on seedance-2 (it handles the full hook→demo→payoff arc in one take) — never stitch those. Blocks until done. Spends credits.',
6479
+ description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than ONE clip of the chosen model. A multi-beat ad that FITS one clip renders better and cheaper as ONE single-pass generate_video/render_ad (a single generation carries the whole hook→demo→payoff arc) — never stitch those. What fits is the model’s own maximum from hermoso_capabilities, not a fixed number: 15s on most models, 30s on the longest-clip one, so a 30s spot need not be stitched at all if you name that model. Blocks until done. Spends credits.',
5997
6480
  inputSchema: {
5998
6481
  scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
5999
6482
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
6000
6483
  voiceover: z.string().optional().describe('full voiceover script spoken across the scenes'),
6001
6484
  voice: z.string().optional().describe('voiceover voice name, e.g. Rachel / George'),
6002
- resolution: z.string().optional().describe('720p (default), 480p draft, or 1080p final'),
6485
+ resolution: z.string().optional().describe('1080p (default), or 480p/720p for a cheaper draft'),
6003
6486
  model: z.string().optional().describe('video model id from hermoso_capabilities — omit to let the router pick'),
6004
6487
  durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
6005
6488
  },
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.64",
3
+ "version": "0.1.68",
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 (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.",
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 — then read what they achieved in Google Analytics 4: sessions, conversions and revenue by channel, campaign and landing page. MCP server (366 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"
@@ -24,7 +24,9 @@
24
24
  "claude",
25
25
  "cli",
26
26
  "competitor-analysis",
27
+ "ga4",
27
28
  "google-ads",
29
+ "google-analytics",
28
30
  "image-generation",
29
31
  "marketing",
30
32
  "mcp",