hermoso 0.1.38 → 0.1.43

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 (2) hide show
  1. package/mcp/tools.mjs +58 -21
  2. package/package.json +2 -2
package/mcp/tools.mjs CHANGED
@@ -70,7 +70,7 @@ const okVideo = async (text, r) => {
70
70
  const CAPABILITY_MAP = [
71
71
  'What Hermoso can do — the full agent surface (every tool below runs over this MCP):',
72
72
  'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · scrapecreators_fetch (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
73
- '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) · 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).',
73
+ '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).',
74
74
  '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.',
75
75
  '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).',
76
76
  '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 · post_to_meta (Facebook / Instagram / Threads) · 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) · manage_meta_post (edit or delete a published post). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Google Business Profile — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). 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) · list_youtube_comments + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own recent posts with views/likes/comments/shares). LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT: post_to_reddit (submit a text, link or native image post to ONE subreddit — Reddit bans near-identical posts across communities, so write for one subreddit and never fan out) · reddit_post_stats (score, comments, upvote ratio on a post you made). REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · 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: create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · 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). 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). 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) · 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). 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, archive — every spend change and every archive is confirm-gated, and archiving is irreversible because this API has no delete). 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): 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.',
@@ -83,7 +83,7 @@ const CAPABILITY_MAP = [
83
83
  export const MCP_INSTRUCTIONS = [
84
84
  'Hermoso is an AI ad studio you drive over MCP — use it for four jobs: (1) AD SPY / research the ads already winning in any market, (2) CREATE finished on-brand image & video ads, (3) run RAW generations against the full model catalog, and (4) PUBLISH & MANAGE the user’s OWN Meta channels (posts + ads) and Google Drive. Call hermoso_capabilities FIRST (free) to learn valid model ids + exact credit costs. Capability map:',
85
85
  '• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; scrapecreators_fetch; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
86
- '• CREATE (finished ads): get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) → plan_ad → render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
86
+ '• CREATE (finished ads): get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) → plan_ad → render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
87
87
  '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
88
88
  '• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here, each with its own brand, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
89
89
  '• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by pasting an API key; ONE creative format, a text plus image card — no video; Reddit — post_to_reddit (ONE subreddit at a time; never repost the same content across communities), reddit_post_stats; Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights (the brand’s listing on Google Search and Maps); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder (full CRUD over the user’s OneDrive); MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this brand may post to and spend from — fails closed, an empty choice shares nothing), disconnect_connector (confirm-gated: reconnecting needs a browser). Full read+write control over the user’s own channels, not just generation. LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
@@ -1517,14 +1517,17 @@ export function registerTools(server) {
1517
1517
  // account can still be refused — the server's error text names the form rather than leaking PERMISSION_DENIED.
1518
1518
  server.registerTool('list_business_locations', {
1519
1519
  title: 'List Google business listings',
1520
- description: 'List the Google Business Profile listings the connected Google account manages — id, title, address, website and Maps link. Call this before posting whenever the account has more than one listing and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. Read-only, 0 credits. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).',
1521
- inputSchema: { accountId: z.string().optional().describe('restrict to one Business Profile account (accounts/…); omit to list across all of them') },
1522
- outputSchema: { count: z.number().optional(), locations: z.array(z.object({ id: z.string().optional(), account: z.string().optional(), accountName: z.string().optional(), title: z.string().optional(), address: z.string().optional(), website: z.string().optional(), phone: z.string().optional(), mapsUrl: z.string().optional(), canPost: z.boolean().optional() })).optional() },
1520
+ description: 'List the Google Business Profile listings SHARED WITH THIS BRAND — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this brand. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).',
1521
+ inputSchema: {},
1522
+ outputSchema: { count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional(), locations: z.array(z.object({ id: z.string().optional(), account: z.string().optional(), accountName: z.string().optional(), title: z.string().optional(), address: z.string().optional(), website: z.string().optional(), phone: z.string().optional(), mapsUrl: z.string().optional(), canPost: z.boolean().optional() })).optional() },
1523
1523
  annotations: { readOnlyHint: true, openWorldHint: true },
1524
1524
  }, wrap(async (a) => {
1525
- const d = await apiGet('/api/google-business/locations', a.accountId ? { accountId: a.accountId } : {});
1526
- if (!d.count) return ok('That Google account manages no business listings — the user needs to connect the Google account that owns the brand’s Business Profile, or be added as a manager on it.', d);
1527
- return ok(`${d.count} listing${d.count === 1 ? '' : 's'}: ${(d.locations || []).map(l => `${l.title || '(untitled)'} (${l.id})`).join(', ')}.${d.count > 1 ? ' Show these to the user and let them pick which one to post to.' : ''}`, d);
1525
+ // THE SHARED SET, NEVER THE ROSTER. /api/google-business/locations walks every Business Profile account the
1526
+ // Google login manages; it is the owner-only PICKER route, reached through list_connector_accounts alone.
1527
+ const d = await apiGet('/api/google-business/shared-locations');
1528
+ if (!d.shared) return ok('No Google Business listing is shared with this brand yet, so there is nothing to post to. The user chooses which listings belong to this brand: Workspace ▸ Connectors ▸ Google Business Profile ▸ Manage accounts, or list_connector_accounts then set_connector_accounts. Do not name or guess one.', d);
1529
+ if (!d.count) return ok(`The ${d.shared === 1 ? 'listing' : `${d.shared} listings`} shared with this brand ${d.shared === 1 ? 'is' : 'are'} no longer reachable on this Google connection — removed, or the connected account lost manager access. Ask the user to re-pick under Workspace ▸ Connectors ▸ Google Business Profile ▸ Manage accounts.`, d);
1530
+ return ok(`${d.count} listing${d.count === 1 ? '' : 's'} shared with this brand: ${(d.locations || []).map(l => `${l.title || '(untitled)'} (${l.id})`).join(', ')}.${d.count > 1 ? ' Show these to the user and let them pick which one to post to.' : ''} These are the ONLY listings usable here; any other listing on this Google account is not shared and must never be named or offered.`, d);
1528
1531
  }));
1529
1532
  server.registerTool('post_to_google_business', {
1530
1533
  title: 'Post to Google Business Profile',
@@ -3656,7 +3659,7 @@ export function registerTools(server) {
3656
3659
  unitCost: z.number().optional().describe('the bid'),
3657
3660
  currencyCode: z.string().optional(),
3658
3661
  locale: z.record(z.any()).optional(),
3659
- country: z.string().optional().describe('campaign locale country, default US'),
3662
+ country: z.string().optional().describe('campaign locale country, default US — this is the ad UI language market, NOT geo targeting. NEVER derive it from where the ad should run: targeting Canada still uses the US/en locale (LinkedIn refuses en_CA). Leave it alone unless the user explicitly asks for a different interface language.'),
3660
3663
  language: z.string().optional().describe('campaign locale language, default en'),
3661
3664
  locations: z.array(z.string()).optional().describe('REQUIRED unless targetingCriteria is given — geo URNs or bare geo ids from search_linkedin_ads_targeting'),
3662
3665
  include: z.record(z.any()).optional().describe('further targeting facets ANDed onto locations, e.g. {titles:[…], industries:[…], seniorities:[…], staffCountRanges:[…]}'),
@@ -4272,9 +4275,10 @@ export function registerTools(server) {
4272
4275
  // ---------- video / avatar / stitch (job-based, polled to completion) ----------
4273
4276
  server.registerTool('render_ad', {
4274
4277
  title: 'Render ad video',
4275
- 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. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
4278
+ 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.',
4276
4279
  inputSchema: {
4277
4280
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
4281
+ 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.'),
4278
4282
  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)'),
4279
4283
  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.'),
4280
4284
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
@@ -4292,6 +4296,7 @@ export function registerTools(server) {
4292
4296
  needsProductPhoto: z.boolean().optional().describe('true when nothing was rendered because the ad features a product this brand has no photo of'),
4293
4297
  dryRun: z.boolean().optional().describe('true when this was a dry run (no job submitted, nothing charged)'),
4294
4298
  jobType: z.string().optional().describe("the routing decision — 'video' (single pass) or 'stitch' (acts)"),
4299
+ creator: z.any().optional().describe('the saved creator this render RESOLVED to — {id, name, source, consented}. The read-back, not what you typed'),
4295
4300
  input: z.any().optional().describe('the assembled render input (dry run only — resolved model, duration, scenes)'),
4296
4301
  },
4297
4302
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -4304,11 +4309,16 @@ export function registerTools(server) {
4304
4309
  const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
4305
4310
  if (_len) a = { ...a, durationSeconds: _len };
4306
4311
  const _clampNote = (_askedLen && _askedLen !== _len) ? `\n(${_askedLen}s is outside the supported 4–180s range — rendered at ${_len}s.)` : '';
4307
- const { input, jobType, notes, needsProductPhoto } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
4312
+ const { input, jobType, notes, needsProductPhoto, creator } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
4313
+ // THE CAST IS THE READ-BACK, NEVER THE ASK. `creator` is the row the SERVER resolved out of this workspace's own
4314
+ // roster; a half-remembered name that matched nobody, matched two people, or belongs to an unconsented real
4315
+ // person never reaches here at all (the assemble route refuses, free, before a job exists). So this line names
4316
+ // who is actually in the ad, and it names them from the resolution — the same law the ads tree follows.
4317
+ const _castLine = creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consented ? ', likeness consent on file' : '') : ''}.` : '';
4308
4318
  // LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
4309
4319
  // (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
4310
4320
  // acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
4311
- if (a.dryRun) return ok(`DRY RUN — routing decision (no job submitted, nothing charged): jobType=${jobType || 'video'}, model=${input.model}, durationSeconds=${input.durationSeconds}${Array.isArray(input.scenes) ? `, acts=[${input.scenes.map(s => Math.round(s.seconds * 10) / 10).join(', ')}]s` : ' (single pass)'}${input.modelExplicit ? ', modelExplicit (ask-don’t-swap)' : ''}.${_clampNote}\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', input });
4321
+ if (a.dryRun) return ok(`DRY RUN — routing decision (no job submitted, nothing charged): jobType=${jobType || 'video'}, model=${input.model}, durationSeconds=${input.durationSeconds}${Array.isArray(input.scenes) ? `, acts=[${input.scenes.map(s => Math.round(s.seconds * 10) / 10).join(', ')}]s` : ' (single pass)'}${input.modelExplicit ? ', modelExplicit (ask-don’t-swap)' : ''}.${_clampNote}${_castLine}\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', ...(creator ? { creator } : {}), input });
4312
4322
  // ASK BEFORE SPENDING (Dave 2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
4313
4323
  // spent"). `notes` alone was not enough here: on the real path it only reaches the model AFTER renderJob has
4314
4324
  // polled to completion, i.e. after the credits are gone. So when the ad features a product this brand has no
@@ -4320,7 +4330,7 @@ export function registerTools(server) {
4320
4330
  return ok(`NOTHING WAS RENDERED and nothing was charged.${notes || ''}\n\nDo this now: tell the user in ONE short line that you have no photo of their product and the packaging would be invented, then either (a) lock a real photo with set_product_image and call render_ad again, or (b) if they say a generic stand-in is fine, call render_ad again with allowGenericProduct:true. Do NOT describe or claim any render — none happened.`, { needsProductPhoto: true, jobType: jobType || 'video' });
4321
4331
  }
4322
4332
  const r = await renderJob(jobType === 'stitch' ? 'stitch' : 'video', input, 'MCP ad render');
4323
- return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_clampNote}${switchNote(r)}\n${notes || ''}`, r);
4333
+ return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_clampNote}${switchNote(r)}${_castLine}\n${notes || ''}`, { ...r, ...(creator ? { creator } : {}) });
4324
4334
  }));
4325
4335
 
4326
4336
 
@@ -4445,7 +4455,7 @@ export function registerTools(server) {
4445
4455
 
4446
4456
  server.registerTool('make_explainer', {
4447
4457
  title: 'Make an explainer video',
4448
- description: "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, adds gentle Ken-Burns motion, then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds narration-timed CAPS (see both). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
4458
+ description: "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE, exactly as Higgsfield's stills pipeline does it — a slow move on a still shimmers), then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds narration-timed CAPS (see both). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
4449
4459
  inputSchema: {
4450
4460
  topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
4451
4461
  durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
@@ -4977,7 +4987,7 @@ export function registerTools(server) {
4977
4987
  // very `heist.avatars.v1` the web Studio's creator picker renders — save it here, it is in the app's picker.
4978
4988
  server.registerTool('list_creators', {
4979
4989
  title: 'List saved creators',
4980
- description: 'List this workspace’s SAVED CREATORS — the reusable on-camera cast (AI creators made here, a person pulled from a social profile, a consented photo upload). Read-only, FREE. Each entry gives the name, the PORTRAIT URL, where the portrait came from and whether a real person’s likeness consent is on file, how many extra pose plates exist, and any chosen or cloned voice. THE PORTRAIT URL IS THE REUSE HANDLE — pass it as generate_avatar’s `image` (a talking clip of them), generate_video’s `refImage` (they star in the scene), recast_motion’s `image` (they perform a reference clip’s motion), or generate_image’s `refImages`. CALL THIS BEFORE OFFERING TO GENERATE A NEW PERSON: re-casting somebody the workspace already has keeps the SAME face across every ad, while a fresh person costs credits and breaks that continuity. An empty answer means the workspace genuinely has no cast yet — say so and offer generate_avatar / save_creator, never invent a roster.',
4990
+ description: 'List this workspace’s SAVED CREATORS — the reusable on-camera cast (AI creators made here, a person pulled from a social profile, a consented photo upload). Read-only, FREE. Each entry gives the name, the PORTRAIT URL, where the portrait came from and whether a real person’s likeness consent is on file, how many extra pose plates exist, and any chosen or cloned voice. TO PUT ONE IN A FINISHED AD, pass their id or name as render_ad’s `creator` — that casts them for the whole spot (and skips the character-portrait render, so it costs less than not casting anyone). THE PORTRAIT URL IS THE REUSE HANDLE for the raw lanes — pass it as generate_avatar’s `image` (a talking clip of them), generate_video’s `refImage` (they star in the scene), recast_motion’s `image` (they perform a reference clip’s motion), or generate_image’s `refImages`. CALL THIS BEFORE OFFERING TO GENERATE A NEW PERSON: re-casting somebody the workspace already has keeps the SAME face across every ad, while a fresh person costs credits and breaks that continuity. An empty answer means the workspace genuinely has no cast yet — say so and offer generate_avatar / save_creator, never invent a roster.',
4981
4991
  inputSchema: { limit: z.number().optional().describe('max creators to return (default 24)') },
4982
4992
  outputSchema: { creators: z.array(z.any()).optional().describe('the saved cast — {id, name, image, source, consented, poses, voice, voiceClone, look}'), count: z.number().optional() },
4983
4993
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -4985,7 +4995,7 @@ export function registerTools(server) {
4985
4995
  const d = await apiGet('/api/creators', a.limit ? { limit: Math.max(1, Math.min(200, Math.round(+a.limit) || 24)) } : {});
4986
4996
  const creators = (d.creators || []).map(c => ({ ...c, image: abs(c.image) })); // portraits ride abs() like every other asset url here
4987
4997
  if (!creators.length) return ok('This workspace has no saved creators yet — there is nobody to re-cast, so do not offer one. generate_avatar renders a talking clip from a portrait you supply, and save_creator adds a portrait to the reusable cast so the same person can star in later ads.', { creators: [], count: 0 });
4988
- return ok(`${creators.length} saved creator(s) in this workspace:\n${creators.map(creatorLine).join('\n')}\nRe-cast any of them by passing their portrait url as generate_avatar.image / generate_video.refImage / recast_motion.image / generate_image.refImages — that is what keeps the same face across ads.`, { creators, count: creators.length });
4998
+ return ok(`${creators.length} saved creator(s) in this workspace:\n${creators.map(creatorLine).join('\n')}\nCast one into a finished ad with render_ad(creator: "<id or name>"); for the raw lanes, pass their portrait url as generate_avatar.image / generate_video.refImage / recast_motion.image / generate_image.refImages — either way that is what keeps the same face across ads.`, { creators, count: creators.length });
4989
4999
  }));
4990
5000
  server.registerTool('save_creator', {
4991
5001
  title: 'Save a creator',
@@ -5026,7 +5036,7 @@ export function registerTools(server) {
5026
5036
  };
5027
5037
  await writeStore('heist.avatars.v1', [item, ...list].slice(0, 200));
5028
5038
  const warn = (source !== 'generated' && !item.consentAt) ? ' ⚠ This is a REAL person and NO likeness consent is on file — say so to the user, and do not put them in a published ad until they confirm that person agreed.' : '';
5029
- return ok(`Saved “${item.name}” to the workspace cast — they now show up in list_creators and in the app’s creator picker. Re-cast them any time by passing ${abs(item.image)} as generate_avatar.image / generate_video.refImage / recast_motion.image.${warn}`, { ok: true, id: item.id, creator: { id: item.id, name: item.name, image: abs(item.image), source } });
5039
+ return ok(`Saved “${item.name}” to the workspace cast — they now show up in list_creators and in the app’s creator picker. Star them in a finished ad with render_ad(creator: "${item.name}"), or re-cast them in a raw render by passing ${abs(item.image)} as generate_avatar.image / generate_video.refImage / recast_motion.image.${warn}`, { ok: true, id: item.id, creator: { id: item.id, name: item.name, image: abs(item.image), source } });
5030
5040
  }));
5031
5041
  server.registerTool('delete_creator', {
5032
5042
  title: 'Delete a creator',
@@ -5129,8 +5139,14 @@ export function registerTools(server) {
5129
5139
  // workspace was revoked. The route now 403s that case by name instead of quietly scoping the read to the
5130
5140
  // caller's own empty account (R6-6), so it surfaces as an error through wrap() rather than as "0 connected".
5131
5141
  const empty = '\nIf this brand should have connected accounts, check WHICH workspace this connection is on: list_brands names every brand on this account plus every one shared with you, and use_brand switches. Otherwise connect one in the app — Workspace ▸ Connectors (linking needs a browser).';
5132
- const lines = on.map(c => ` • ${c.provider}${c.accountLabel ? ` — ${c.accountLabel}` : ''} (${c.status || 'active'})`);
5133
- return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect: ${(d.providers || []).join(', ') || '(none configured)'}.${on.length ? '' : empty}`, d);
5142
+ // AN AGENT NEVER SEES A PICKER PROVIDER'S RAW `accountLabel` (2026-08-04). It names whoever AUTHORIZED the
5143
+ // connection, and on LinkedIn that is a tickable, postable identity the user has almost certainly NOT ticked
5144
+ // (config.liMember is off by default) — printing it told Studio a personal profile was postable when it was not.
5145
+ // The SERVER decides what may be said (`agentLabel`, from lib/shared-identities.mjs); this only renders it, and
5146
+ // `accountLabel` is stripped from structuredContent too, or the model reads it there instead.
5147
+ const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})`);
5148
+ const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
5149
+ return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect: ${(d.providers || []).join(', ') || '(none configured)'}.${on.length ? '' : empty}`, safe);
5134
5150
  }));
5135
5151
  // ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
5136
5152
  // halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
@@ -5181,11 +5197,31 @@ export function registerTools(server) {
5181
5197
  identities: (d) => (d.accounts || []).map(a => ({ id: String(a.id), name: a.name || a.id, type: 'ad_account', selected: !!a.selected, detail: [a.currency, a.test ? 'TEST account' : '', a.role].filter(Boolean).join(', ') })),
5182
5198
  write: (ids) => ['/api/linkedin/ads-scope', { adAccountIds: ids }],
5183
5199
  },
5200
+ // REDDIT ADS — the same law-4-with-no-path-to-tick gap the linkedin_ads row above was added to close (2026-08-04).
5201
+ // Its tools already say "ask the user to tick the ones it may use (or call set_connector_accounts)", and
5202
+ // set_connector_accounts' enum is DERIVED from this table, so without a row here that instruction named a tool
5203
+ // that would reject the provider.
5204
+ reddit_ads: {
5205
+ label: 'Reddit Ads', read: '/api/reddit-ads/accounts',
5206
+ identities: (d) => (d.accounts || []).map(a => ({ id: String(a.id), name: a.name || a.id, type: 'ad_account', selected: !!a.selected, detail: a.currency || '' })),
5207
+ write: (ids) => ['/api/reddit-ads/scope', { adAccountIds: ids }],
5208
+ },
5184
5209
  microsoft_ads: {
5185
5210
  label: 'Microsoft Ads', read: '/api/microsoft-ads/accounts',
5186
5211
  identities: (d) => (d.accounts || []).map(a => ({ id: String(a.accountId), name: a.name, type: 'ad_account', selected: !!a.selected, detail: a.customerId ? `customer ${a.customerId}` : '' })),
5187
5212
  write: (ids) => ['/api/microsoft-ads/scope', { accountIds: ids }],
5188
5213
  },
5214
+ // GOOGLE BUSINESS PROFILE — the LAST provider that could hold several identities and had no tick list at all
5215
+ // (built 2026-08-04; it was lib/shared-identities.mjs's one recorded NO_IDENTITY_PICKER entry). A Google login
5216
+ // managing three clients' storefronts handed all three, WITH their street addresses, to every brand's agent.
5217
+ // list_business_locations reads the SHARED route now; this table is the only place the full roster appears.
5218
+ // The address rides `detail` HERE and nowhere else: picking is the one job it is needed for, because a chain
5219
+ // names every storefront identically and the title alone is not a choice anyone can make.
5220
+ google_business: {
5221
+ label: 'Google Business Profile', read: '/api/google-business/locations',
5222
+ identities: (d) => (d.locations || []).map(l => ({ id: String(l.id), name: l.title || l.id, type: 'business_listing', selected: !!l.selected, detail: [l.address, l.accountName, l.canPost === false ? 'cannot take Posts' : ''].filter(Boolean).join(' · ') })),
5223
+ write: (ids) => ['/api/google-business/scope', { locationIds: ids }],
5224
+ },
5189
5225
  };
5190
5226
  const CONN_PICKER_IDS = Object.keys(CONN_ACCOUNT_PICKERS);
5191
5227
  const connIdentityLines = (rows) => rows.map(r => ` ${r.selected ? '[x]' : '[ ]'} ${r.name} (id: ${r.id}, ${r.type})${r.detail ? ` — ${r.detail}` : ''}`).join('\n');
@@ -5245,11 +5281,12 @@ export function registerTools(server) {
5245
5281
  const live = ((await apiGet('/api/connectors')).connectors || []).filter(c => c && c.status !== 'revoked');
5246
5282
  const hit = live.find(c => c.provider === provider);
5247
5283
  if (!hit) return { content: [{ type: 'text', text: `Nothing connected for "${provider}". Connected: ${live.map(c => c.provider).join(', ') || '(none)'}.` }], isError: true };
5248
- if (a.confirm !== true) return ok(`This disconnects ${hit.provider}${hit.accountLabel ? ` (${hit.accountLabel})` : ''} from this workspace: our access is revoked at the provider, the stored credentials are deleted, and every ${hit.provider} tool stops working until someone reconnects it IN A BROWSER — I can't do that step. Already-published posts and running campaigns are untouched. Confirm with the user, then call again with confirm:true.`, { ok: false, provider });
5284
+ // agentLabel, never accountLabel the confirm sentence is model-visible text like any other (2026-08-04).
5285
+ if (a.confirm !== true) return ok(`This disconnects ${hit.provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this workspace: our access is revoked at the provider, the stored credentials are deleted, and every ${hit.provider} tool stops working until someone reconnects it IN A BROWSER — I can't do that step. Already-published posts and running campaigns are untouched. Confirm with the user, then call again with confirm:true.`, { ok: false, provider });
5249
5286
  // Straight through the app's own disconnect route → Connectors.remove(), which carries the shared-grant guard
5250
5287
  // (all six google_* connectors ride ONE OAuth client id, so a naive revoke would silently kill the siblings).
5251
5288
  await apiPost(`/api/connectors/${encodeURIComponent(provider)}/disconnect`, {});
5252
- return ok(`Disconnected ${provider}${hit.accountLabel ? ` (${hit.accountLabel})` : ''}. Reconnect from the app: Workspace ▸ Connectors ▸ ${provider}.`, { ok: true, provider, disconnected: true });
5289
+ return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''}. Reconnect from the app: Workspace ▸ Connectors ▸ ${provider}.`, { ok: true, provider, disconnected: true });
5253
5290
  }));
5254
5291
  server.registerTool('list_team', {
5255
5292
  title: 'List team members',
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.38",
3
+ "version": "0.1.43",
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 spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic 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 (262 tools), CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
5
+ "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic \u2014 then publish to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest and build & manage the ad campaigns behind them on Meta, Google Ads, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads. MCP server (262 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"