hermoso 0.1.20 → 0.1.21

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 +481 -34
  2. package/package.json +2 -2
package/mcp/tools.mjs CHANGED
@@ -458,7 +458,9 @@ export function registerTools(server) {
458
458
  const d = await apiGet('/api/billing/status');
459
459
  const ar = d.autoReload || {};
460
460
  const arLine = ar.available === false ? 'set in the app (not via API)' : (ar.enabled ? `on (below ${ar.thresholdCredits} cr → +${ar.reloadCredits} cr)` : 'off');
461
- const text = `Plan: ${d.plan?.label} ($${d.plan?.monthlyUsd}/mo)\nBalance: ${d.balanceCredits} credits\nAuto-reload: ${arLine}\nCard on file: ${d.paymentMethodOnFile ? `yes${d.card ? ` (${d.card.brand} ····${d.card.last4})` : ''}` : 'no'}\nYour billing role: ${d.role}${d.isAdmin ? ' — you can change the plan / auto-reload' : ' — read-only; ask an admin to change the plan or auto-reload'}`;
461
+ const _per = d.plan?.period === 'yr' ? 'yr' : 'mo';
462
+ const _price = _per === 'yr' ? (d.plan?.priceUsd ?? d.plan?.monthlyUsd) : (d.plan?.monthlyUsd ?? d.plan?.priceUsd);
463
+ const text = `Plan: ${d.plan?.label} ($${_price}/${_per})\nBalance: ${d.balanceCredits} credits\nAuto-reload: ${arLine}\nCard on file: ${d.paymentMethodOnFile ? `yes${d.card ? ` (${d.card.brand} ····${d.card.last4})` : ''}` : 'no'}\nYour billing role: ${d.role}${d.isAdmin ? ' — you can change the plan / auto-reload' : ' — read-only; ask an admin to change the plan or auto-reload'}`;
462
464
  return ok(text, d);
463
465
  }));
464
466
 
@@ -719,6 +721,19 @@ export function registerTools(server) {
719
721
  return ok(`Deleted Threads post ${d.deleted}.`, d);
720
722
  }));
721
723
 
724
+
725
+ server.registerTool('list_threads_mentions', {
726
+ title: 'Threads mentions of the brand',
727
+ description: 'Posts where someone MENTIONED the brand on Threads — anywhere, not just under your own posts. This is brand listening: real objections, questions and the exact language customers use, which is strong raw material for ad copy and for mine_angles. Use list_threads_replies instead when you want the conversation under one specific post.',
728
+ inputSchema: { limit: z.number().optional().describe('how many mentions (1–50, default 25)') },
729
+ outputSchema: { username: z.string().optional(), count: z.number().optional(), mentions: z.array(z.any()).optional() },
730
+ annotations: { readOnlyHint: true, openWorldHint: true },
731
+ }, wrap(async (a) => {
732
+ const d = await apiGet('/api/threads/mentions', { limit: a.limit });
733
+ const lines = (d.mentions || []).map(m => `• @${m.author}: ${String(m.text).replace(/\s+/g, ' ').slice(0, 90)} — ${m.permalink || m.id}`);
734
+ return ok(`${d.count} mention(s) of @${d.username}:\n${lines.join('\n') || '(none)'}`, d);
735
+ }));
736
+
722
737
  server.registerTool('search_threads_keyword', {
723
738
  title: 'Search Threads by keyword',
724
739
  description: 'Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.',
@@ -748,8 +763,8 @@ export function registerTools(server) {
748
763
  return ok(`Pages: ${pages.map(p => p.name + (p.instagram ? ` (IG @${p.instagram.username})` : '')).join(', ') || 'none'}\nAd accounts: ${adAccounts.map(a => `${a.name} (act_${a.accountId}, ${a.currency}${a.active ? '' : ', inactive'})`).join(', ') || 'none'}`, { pages, adAccounts });
749
764
  }));
750
765
  // Ingest an ARBITRARY user file (desktop media, etc. — nothing to do with a Hermoso render) into Hermoso and get back a
751
- // durable public URL to feed post_to_meta / upload_meta_asset / create_meta_ad. This is what makes the publishing tools
752
- // work on the user's OWN files, not just generated ones.
766
+ // durable public URL to feed post_to_meta / upload_meta_asset / create_meta_ad. Makes the publishing tools work on the
767
+ // user's OWN files, not just generated ones.
753
768
  const EXT_MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4' };
754
769
  server.registerTool('upload_file', {
755
770
  title: 'Upload a local file → durable public URL',
@@ -776,6 +791,23 @@ export function registerTools(server) {
776
791
  const d = await apiUpload('/api/upload', buf, { contentType, fileName });
777
792
  return ok(`Uploaded ${d.kind || 'file'} (${d.bytes || buf.length} bytes) → ${d.url}. Pass this url to post_to_meta / upload_meta_asset / create_meta_ad.`, { url: d.url, kind: d.kind, bytes: d.bytes });
778
793
  }));
794
+
795
+ server.registerTool('search_threads_locations', {
796
+ title: 'Find a place to tag on Threads',
797
+ description: 'Search Threads’ public place index by name (or by latitude+longitude) and get location ids. Use this when the brand has a PHYSICAL location — a restaurant, salon, gym, store — so the post can be geotagged to it. Pass the chosen id as post_to_meta(locationId) with target:"threads".',
798
+ inputSchema: {
799
+ q: z.string().optional().describe('place name to search, e.g. "Osteria Francescana"'),
800
+ latitude: z.number().optional().describe('latitude (use with longitude to search near a point)'),
801
+ longitude: z.number().optional().describe('longitude'),
802
+ },
803
+ outputSchema: { count: z.number().optional(), locations: z.array(z.any()).optional() },
804
+ annotations: { readOnlyHint: true, openWorldHint: true },
805
+ }, wrap(async (a) => {
806
+ const d = await apiGet('/api/threads/locations', { q: a.q, latitude: a.latitude, longitude: a.longitude });
807
+ const lines = (d.locations || []).map(l => `• ${l.name}${l.address ? ` — ${l.address}` : ''}${l.city ? `, ${l.city}` : ''} — id ${l.id}`);
808
+ return ok(`${d.count} place(s):\n${lines.join('\n') || '(none)'}`, d);
809
+ }));
810
+
779
811
  server.registerTool('post_to_meta', {
780
812
  title: 'Post to Facebook, Instagram or Threads',
781
813
  description: 'Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings ▸ Connectors ▸ Meta) with posting permission; Threads needs its own connection.',
@@ -785,6 +817,7 @@ export function registerTools(server) {
785
817
  videoUrl: z.string().optional().describe('public https URL, data: URI, or /generated path — FB video post / IG Reel'),
786
818
  link: z.string().optional().describe('a URL to attach (FB text post only)'),
787
819
  target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account'),
820
+ locationId: z.string().optional().describe('Threads only — a place id from search_threads_locations, to geotag the post to a physical location (restaurant, storefront)'),
788
821
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
789
822
  },
790
823
  outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), url: z.string().optional(), target: z.string().optional(), page: z.string().optional(), account: z.string().optional() },
@@ -833,6 +866,38 @@ export function registerTools(server) {
833
866
  const d = await apiGet('/api/youtube/channel', {});
834
867
  return ok(`${d.title} — ${d.subscribers} subscribers, ${d.videos} videos, ${d.views} total views.`, d);
835
868
  }));
869
+ server.registerTool('tiktok_creator_info', {
870
+ title: 'Read the connected TikTok creator’s posting options',
871
+ description: 'Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).',
872
+ inputSchema: {},
873
+ outputSchema: { nickname: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), privacyOptions: z.array(z.string()).optional(), commentDisabled: z.boolean().optional(), duetDisabled: z.boolean().optional(), stitchDisabled: z.boolean().optional(), maxDurationSeconds: z.number().nullable().optional() },
874
+ annotations: { readOnlyHint: true, openWorldHint: true },
875
+ }, wrap(async () => {
876
+ const d = await apiGet('/api/tiktok/creator-info', {});
877
+ return ok(`TikTok creator ${d.nickname || d.username || '(unnamed)'} — privacy levels they can use: ${(d.privacyOptions || []).join(', ') || '(none returned)'}; comments ${d.commentDisabled ? 'disabled' : 'available'}, duet ${d.duetDisabled ? 'disabled' : 'available'}, stitch ${d.stitchDisabled ? 'disabled' : 'available'}; max ${d.maxDurationSeconds || '?'}s. Show the user these exact options and let THEM choose the privacy level.`, d);
878
+ }));
879
+ server.registerTool('post_to_tiktok', {
880
+ title: 'Post a video to TikTok',
881
+ description: 'Publish a finished video to the user’s connected TikTok account. TWO destinations: destination:"post" puts it LIVE on their profile now — that requires `privacy`, and you must call tiktok_creator_info first, show the creator’s real privacy options and get an explicit yes before calling. destination:"draft" (the default, and the safer one) sends it to their TikTok drafts so they review and post it themselves from the app. Pass a Hermoso render URL (or an upload_file url for a local/external file). Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).',
882
+ inputSchema: {
883
+ videoUrl: z.string().describe('the video to post — a Hermoso render URL or an upload_file url'),
884
+ destination: z.enum(['post', 'draft']).optional().describe('"post" = live on the profile now (needs privacy + an explicit user yes); "draft" = into their TikTok drafts to review first. Default "draft".'),
885
+ title: z.string().optional().describe('the caption (≤2200 chars) — hashtags go here'),
886
+ privacy: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']).optional().describe('REQUIRED for destination:"post". Must be one the creator actually allows — read them from tiktok_creator_info, never guess.'),
887
+ disableComment: z.boolean().optional(),
888
+ disableDuet: z.boolean().optional(),
889
+ disableStitch: z.boolean().optional(),
890
+ coverTimestampMs: z.number().optional().describe('which frame to use as the cover, in ms'),
891
+ brandedContent: z.boolean().optional().describe('discloses a paid partnership — cannot be combined with SELF_ONLY privacy'),
892
+ yourBrand: z.boolean().optional().describe('discloses that this promotes the creator’s own brand'),
893
+ },
894
+ outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional() },
895
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
896
+ }, wrap(async (a) => {
897
+ const d = await apiPost('/api/tiktok/post', a);
898
+ if (d.destination === 'draft') return ok(`Sent to TikTok drafts${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app under drafts.${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
899
+ return ok(`Posted to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}`, d);
900
+ }));
836
901
  server.registerTool('upload_meta_asset', {
837
902
  title: 'Upload an asset to a Meta ad account',
838
903
  description: 'Upload creative(s) — a finished Hermoso ad OR arbitrary user files (e.g. a folder of media from the user’s desktop) — into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use them in their OWN campaigns. Pass `url` for one file, or `urls` (up to 20) to BULK-upload in a single call. Each accepts a public https URL, a data: URI, or a Hermoso /generated path; for LOCAL files call upload_file first and pass the url(s) it returns. Image → image hash; video → video id. Pass adAccountId from list_meta_pages.',
@@ -879,30 +944,126 @@ export function registerTools(server) {
879
944
  const d = await apiPost('/api/meta/campaign/status', a);
880
945
  return ok(d.note || `Campaign ${a.campaignId} → ${a.status}.`, d);
881
946
  }));
947
+ // ── Meta ad-set targeting, shared by create_meta_ad and create_meta_adset. Ids come from find_meta_audiences —
948
+ // interests/behaviours/cities/languages are all opaque on Meta, so never guess one.
949
+ const metaGeoShape = z.object({
950
+ countries: z.array(z.string()).optional().describe('2-letter codes, e.g. ["US","CA"]'),
951
+ regions: z.array(z.object({ key: z.string() })).optional().describe('region KEYS from find_meta_audiences(type:"adgeolocation")'),
952
+ cities: z.array(z.object({ key: z.string(), radius: z.number().optional(), distanceUnit: z.enum(['mile', 'kilometer']).optional() })).optional().describe('city KEYS; radius works here (10–50 mi / 17–80 km)'),
953
+ zips: z.array(z.object({ key: z.string() })).optional(),
954
+ geoMarkets: z.array(z.object({ key: z.string() })).optional().describe('DMA keys, e.g. {key:"DMA:807"}'),
955
+ customLocations: z.array(z.object({ latitude: z.number(), longitude: z.number(), radius: z.number().optional(), distanceUnit: z.enum(['mile', 'kilometer']).optional() })).optional().describe('drop a pin + radius'),
956
+ locationTypes: z.array(z.enum(['home', 'recent', 'travel_in'])).optional().describe('people who LIVE there vs were recently there'),
957
+ }).optional();
958
+ const metaIdList = z.array(z.object({ id: z.string(), name: z.string().optional() })).optional();
959
+ const metaTargetingShape = z.object({
960
+ geo: metaGeoShape.describe('where the ad runs'),
961
+ excludedGeo: metaGeoShape.describe('places to exclude'),
962
+ ageMin: z.number().optional().describe('13–65'), ageMax: z.number().optional().describe('13–65 (65 means 65+)'),
963
+ genders: z.enum(['all', 'men', 'women']).optional(),
964
+ interests: metaIdList.describe('interest ids from find_meta_audiences(type:"adinterest")'),
965
+ behaviors: metaIdList.describe('behaviour ids from find_meta_audiences(type:"adTargetingCategory", class:"behaviors")'),
966
+ excludedInterests: metaIdList, excludedBehaviors: metaIdList,
967
+ flexibleSpec: z.array(z.object({ interests: metaIdList, behaviors: metaIdList })).optional().describe('AND across entries, OR within one'),
968
+ customAudiences: metaIdList.describe('saved audiences AND lookalikes — a lookalike IS a custom audience id'),
969
+ excludedCustomAudiences: metaIdList,
970
+ locales: z.array(z.number()).optional().describe('Meta language ids from find_meta_audiences(type:"adlocale")'),
971
+ publisherPlatforms: z.array(z.enum(['facebook', 'instagram', 'audience_network', 'messenger', 'threads'])).optional(),
972
+ facebookPositions: z.array(z.string()).optional().describe('feed, story, facebook_reels, marketplace, video_feeds, search, instream_video, right_hand_column, …'),
973
+ instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, explore, profile_feed, …'),
974
+ messengerPositions: z.array(z.string()).optional(), audienceNetworkPositions: z.array(z.string()).optional(),
975
+ devicePlatforms: z.array(z.enum(['mobile', 'desktop'])).optional(), userOs: z.array(z.enum(['iOS', 'Android'])).optional(),
976
+ advantageAudience: z.boolean().optional().describe('let Meta expand beyond your audience (Advantage+ audience)'),
977
+ }).optional();
978
+ const metaAdSetFields = {
979
+ dailyBudgetUsd: z.number().optional().describe('ad-set daily budget USD (1–10000, default 10) — spends only once ACTIVE'),
980
+ lifetimeBudgetUsd: z.number().optional().describe('a fixed total instead of a daily budget — REQUIRES endTime'),
981
+ country: z.string().optional().describe('2-letter shorthand when you are not passing full targeting (default US)'),
982
+ targeting: metaTargetingShape.describe('full Meta ad-set targeting — age, gender, geo, interests, behaviours, audiences, languages, placements, devices'),
983
+ pixelId: z.string().optional().describe('Meta Pixel id — with this the ad set optimizes for a real CONVERSION instead of falling back to link clicks'),
984
+ conversionEvent: z.string().optional().describe('PURCHASE | LEAD | COMPLETE_REGISTRATION | ADD_TO_CART | INITIATED_CHECKOUT | …'),
985
+ customConversionId: z.string().optional(),
986
+ applicationId: z.string().optional().describe('app-promotion ads'), objectStoreUrl: z.string().optional(),
987
+ optimizationGoal: z.string().optional().describe('override, e.g. OFFSITE_CONVERSIONS / LANDING_PAGE_VIEWS / THRUPLAY / VALUE'),
988
+ billingEvent: z.string().optional().describe('default IMPRESSIONS'),
989
+ bidStrategy: z.enum(['LOWEST_COST_WITHOUT_CAP', 'LOWEST_COST_WITH_BID_CAP', 'COST_CAP', 'LOWEST_COST_WITH_MIN_ROAS']).optional(),
990
+ bidAmountUsd: z.number().optional().describe('REQUIRED for a bid cap / cost cap'),
991
+ minRoas: z.number().optional().describe('REQUIRED for LOWEST_COST_WITH_MIN_ROAS, e.g. 1.1'),
992
+ startTime: z.string().optional().describe('ISO-8601 with offset, e.g. 2026-08-01T09:00:00-0700'),
993
+ endTime: z.string().optional().describe('REQUIRED with lifetimeBudgetUsd'),
994
+ adsetSchedule: z.array(z.object({ startMinute: z.number(), endMinute: z.number(), days: z.array(z.number()) })).optional().describe('dayparting — minutes from midnight (0–1440), days 0=Sunday…6=Saturday'),
995
+ attributionSpec: z.array(z.any()).optional().describe('e.g. [{event_type:"CLICK_THROUGH",window_days:7}]'),
996
+ };
882
997
  server.registerTool('create_meta_ad', {
883
998
  title: 'Build a full Meta ad (campaign → ad set → ad, paused)',
884
- description: 'Build a complete, ready-to-run Meta ad from image creative(s): campaign → ad set (targeting + daily budget) → creative → ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. Pass adAccountId (from list_meta_pages), an imageUrl (or imageUrls for one ad each), the primary message, and a destination link. IMAGE ads only for now. Needs ads-management on the connected account.',
999
+ description: 'Build a complete, ready-to-run Meta ad: campaign → ad set (FULL targeting + budget + schedule + bidding) → creative → ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim. Needs ads-management on the connected account.',
885
1000
  inputSchema: {
886
1001
  adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
1002
+ format: z.enum(['auto', 'carousel']).optional().describe('auto = one ad per asset (image or video); carousel = ONE multi-card ad'),
887
1003
  imageUrl: z.string().optional().describe('public https image URL for the ad creative'),
888
- imageUrls: z.array(z.string()).optional().describe('several image URLs → one ad each'),
1004
+ imageUrls: z.array(z.string()).optional().describe('several image URLs → one ad each, or the carousel cards in order'),
1005
+ videoUrl: z.string().optional().describe('a video URL → a real Meta VIDEO ad (uploaded + transcoded + thumbnailed for you)'),
1006
+ thumbnailUrl: z.string().optional().describe('custom video thumbnail (otherwise Meta picks a frame)'),
889
1007
  message: z.string().optional().describe('primary ad text'),
890
- headline: z.string().optional().describe('optional headline'),
1008
+ headline: z.string().optional().describe('headline'),
1009
+ description: z.string().optional().describe('the smaller description line under the headline'),
1010
+ cards: z.array(z.object({ headline: z.string().optional(), description: z.string().optional(), link: z.string().optional() })).optional().describe('carousel cards in order — each may set its own headline/description/link'),
1011
+ carouselEndCard: z.boolean().optional().describe('append the Page end card to a carousel'),
891
1012
  link: z.string().optional().describe('destination URL (defaults to the brand domain)'),
892
1013
  cta: z.string().optional().describe('call-to-action, e.g. SHOP_NOW / LEARN_MORE / SIGN_UP (default LEARN_MORE)'),
893
1014
  objective: z.enum(['OUTCOME_TRAFFIC', 'OUTCOME_AWARENESS', 'OUTCOME_ENGAGEMENT', 'OUTCOME_LEADS', 'OUTCOME_SALES']).optional().describe('default OUTCOME_TRAFFIC'),
894
- dailyBudgetUsd: z.number().optional().describe('ad-set daily budget USD (1–10000, default 10) — spends only once ACTIVE'),
895
- country: z.string().optional().describe('2-letter targeting country (default US)'),
1015
+ ...metaAdSetFields,
1016
+ specialAdCategories: z.array(z.enum(['HOUSING', 'EMPLOYMENT', 'CREDIT', 'ISSUES_ELECTIONS_POLITICS', 'ONLINE_GAMBLING_AND_GAMING', 'FINANCIAL_PRODUCTS_SERVICES'])).optional().describe('legally required when the ad falls in one of these categories — it restricts targeting'),
1017
+ instagramUserId: z.string().optional().describe('run it on Instagram under the brand’s own handle'),
896
1018
  name: z.string().optional().describe('base name for the campaign/ad set/ads'),
897
1019
  campaignId: z.string().optional().describe('attach to an existing campaign instead of creating one'),
1020
+ adSetId: z.string().optional().describe('attach the ad(s) to an EXISTING ad set (skips ad-set creation)'),
898
1021
  pageId: z.string().optional().describe('Page id from list_meta_pages; omit = first Page'),
899
1022
  },
900
- outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), adSetId: z.string().optional(), count: z.number().optional(), status: z.string().optional(), dailyBudgetUsd: z.number().optional() },
1023
+ outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), adSetId: z.string().optional(), count: z.number().optional(), status: z.string().optional(), dailyBudgetUsd: z.number().optional(), summary: z.string().optional() },
901
1024
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
902
1025
  }, wrap(async (a) => {
903
1026
  const { imageUrls, ...rest } = a;
904
1027
  const d = await apiPost('/api/meta/ad', imageUrls?.length ? { ...rest, urls: imageUrls } : rest);
905
- return ok(`Built a PAUSED campaign with ${d.count} ad(s) campaign ${d.campaignId}, ad set ${d.adSetId}, $${d.dailyBudgetUsd}/day, optimizing for ${d.optimization}. It spends NOTHING until you activate it with set_meta_campaign_status(confirm:true). ${d.note || ''}`, d);
1028
+ // The server READS THE ADS BACK from the Graph API and ships one honest sentence in d.summary print that.
1029
+ // Never recompute a claim from d.count here: this twin used to narrate "Built a PAUSED campaign with N ad(s)"
1030
+ // straight from the POST responses, which is exactly how a user was told "1 image ad" for an empty account.
1031
+ return ok(`${d.summary || `Meta returned no verified ads for campaign ${d.campaignId}.`} It spends NOTHING until you activate it with set_meta_campaign_status(confirm:true).`, d);
1032
+ }));
1033
+ server.registerTool('create_meta_adset', {
1034
+ title: 'Create a Meta ad set (audience + budget + schedule)',
1035
+ description: 'Create an AD SET on an EXISTING Meta campaign — the level that holds the audience, budget, schedule and bidding. Use it to hang SEVERAL ad sets off ONE campaign, which is how you actually test audiences on Meta (one ad set per audience, same campaign, same creative). Takes the same full `targeting`, pixelId/conversionEvent, bidStrategy, schedule and budget fields as create_meta_ad. Created PAUSED and read back from Meta. It has NO ads until you call create_meta_ad(adSetId:…).',
1036
+ inputSchema: {
1037
+ adAccountId: z.string().describe('ad account id (act_… or digits)'),
1038
+ campaignId: z.string().describe('the campaign this ad set belongs to'),
1039
+ name: z.string().optional().describe('ad set name'),
1040
+ ...metaAdSetFields,
1041
+ pageId: z.string().optional().describe('Page id; omit = first Page'),
1042
+ },
1043
+ outputSchema: { ok: z.boolean().optional(), adSetId: z.string().optional(), campaignId: z.string().optional(), summary: z.string().optional() },
1044
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1045
+ }, wrap(async (a) => {
1046
+ const d = await apiPost('/api/meta/adset', a);
1047
+ return ok(d.summary || `Meta returned ad set ${d.adSetId} but no verified summary.`, d);
1048
+ }));
1049
+ server.registerTool('find_meta_audiences', {
1050
+ title: 'Look up Meta targeting ids',
1051
+ description: 'Look up the Meta targeting ids you need before building an ad set — interests, behaviours, cities/regions/zips/DMAs, languages, employers, job titles and schools. type:"adinterest" (q:"yoga") returns interest ids + audience size; type:"adTargetingCategory" with class:"behaviors" returns behaviour ids; type:"adgeolocation" (q:"Toronto", optionally locationTypes:"city") returns the geo KEYS that go in targeting.geo.cities/regions/zips; type:"adlocale" (q:"french") returns language ids for targeting.locales. Read-only and free. Use it whenever the user names an audience in words — never guess an id.',
1052
+ inputSchema: {
1053
+ type: z.enum(['adinterest', 'adTargetingCategory', 'adgeolocation', 'adlocale', 'adcountry', 'adzipcode', 'adeducationschool', 'adeducationmajor', 'adworkemployer', 'adworkposition']).describe('what kind of targeting object to search'),
1054
+ q: z.string().optional().describe('what to search for'),
1055
+ class: z.string().optional().describe('for adTargetingCategory, e.g. "behaviors" or "interests"'),
1056
+ locationTypes: z.string().optional().describe('comma-separated: country,region,city,zip,geo_market'),
1057
+ countryCode: z.string().optional().describe('2-letter hint to disambiguate a city name'),
1058
+ adAccountId: z.string().optional().describe('search with that ad account’s token'),
1059
+ limit: z.number().optional(),
1060
+ },
1061
+ outputSchema: { count: z.number().optional(), results: z.array(z.any()).optional() },
1062
+ annotations: { readOnlyHint: true, openWorldHint: true },
1063
+ }, wrap(async (a) => {
1064
+ const d = await apiGet('/api/meta/targeting-search', a);
1065
+ if (!d.count) return ok(`Meta has no ${a.type} matching "${a.q || ''}". Try a broader word.`, d);
1066
+ return ok(`${d.count} match(es): ${d.results.slice(0, 25).map(r => `${r.name}${r.id ? ` (id ${r.id})` : ''}${r.key ? ` (key ${r.key})` : ''}${r.type ? ` [${r.type}]` : ''}${r.countryName ? `, ${r.countryName}` : ''}`).join(' | ')}. Use the id in targeting.interests/behaviors/locales, or the key in targeting.geo.cities/regions/zips.`, d);
906
1067
  }));
907
1068
 
908
1069
  // ---------- Meta: READ / MEASURE / EDIT / DELETE existing objects (drive a whole ad account, not just create) ----------
@@ -983,9 +1144,38 @@ export function registerTools(server) {
983
1144
  const d = await apiPost('/api/google-ads/report', a);
984
1145
  return ok(`${d.count} row(s) from Google Ads.`, d);
985
1146
  }));
1147
+ // ── Google Ads: campaign → ad group → ad → keywords → targeting → bidding. Google's object graph REQUIRES all
1148
+ // three levels: a campaign alone can never serve an impression, so create_google_ads_campaign can build the
1149
+ // whole tree in ONE atomic mutate and the granular tools below fill in / edit an existing account.
1150
+ const gadsAdShape = {
1151
+ finalUrls: z.array(z.string()).optional().describe('the landing page(s) — at least one is required'),
1152
+ headlines: z.array(z.union([z.string(), z.object({ text: z.string(), pin: z.enum(['HEADLINE_1', 'HEADLINE_2', 'HEADLINE_3', 'DESCRIPTION_1', 'DESCRIPTION_2']).optional() })])).optional().describe('SEARCH: 3–15 headlines, each ≤30 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to PIN one to a fixed slot (a brand name or legal line).'),
1153
+ descriptions: z.array(z.union([z.string(), z.object({ text: z.string(), pin: z.enum(['HEADLINE_1', 'HEADLINE_2', 'HEADLINE_3', 'DESCRIPTION_1', 'DESCRIPTION_2']).optional() })])).optional().describe('SEARCH: 2–4 descriptions, each ≤90 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to pin it.'),
1154
+ path1: z.string().optional().describe('SEARCH only — display-URL path segment, ≤15 chars'),
1155
+ path2: z.string().optional().describe('SEARCH only — second display-URL path segment, ≤15 chars'),
1156
+ longHeadline: z.string().optional().describe('DISPLAY only — ≤90 characters'),
1157
+ businessName: z.string().optional().describe('DISPLAY only — ≤25 characters'),
1158
+ marketingImages: z.array(z.string()).optional().describe('DISPLAY only — landscape 1.91:1 asset resource names from upload_google_ads_asset'),
1159
+ squareMarketingImages: z.array(z.string()).optional().describe('DISPLAY only — square 1:1 asset resource names'),
1160
+ logoImages: z.array(z.string()).optional().describe('DISPLAY only — logo asset resource names'),
1161
+ };
1162
+ const gadsKeywordShape = z.array(z.object({
1163
+ text: z.string().describe('≤80 characters, ≤10 words'),
1164
+ matchType: z.enum(['EXACT', 'PHRASE', 'BROAD']).optional().describe('default PHRASE'),
1165
+ negative: z.boolean().optional().describe('true = BLOCK this term instead of targeting it'),
1166
+ cpcBidUsd: z.number().optional().describe('per-keyword max CPC'),
1167
+ paused: z.boolean().optional(),
1168
+ }));
1169
+ const gadsBiddingShape = z.object({
1170
+ strategy: z.enum(['MANUAL_CPC', 'MAXIMIZE_CLICKS', 'MAXIMIZE_CONVERSIONS', 'MAXIMIZE_CONVERSION_VALUE', 'TARGET_CPA', 'TARGET_ROAS']),
1171
+ targetCpaUsd: z.number().optional().describe('REQUIRED for TARGET_CPA — cost per conversion you will pay'),
1172
+ targetRoas: z.number().optional().describe('REQUIRED for TARGET_ROAS — e.g. 4 = $4 revenue per $1 spent'),
1173
+ maxCpcUsd: z.number().optional().describe('MAXIMIZE_CLICKS only — optional max CPC ceiling'),
1174
+ enhancedCpc: z.boolean().optional().describe('MANUAL_CPC only'),
1175
+ }).optional();
986
1176
  server.registerTool('create_google_ads_campaign', {
987
- title: 'Create a Google Ads campaign (paused)',
988
- description: 'Create a campaign on a connected Google Ads account. ALWAYS created PAUSED — it spends NOTHING until you enable it with set_google_ads_status(confirm:true). Google Ads requires a budget, so pass dailyBudgetUsd (a budget is created inline) or an existing budgetResourceName. Default channel SEARCH (Manual CPC). Pass customerId (from list_google_ads_campaigns) + a name. Needs Google Ads connected with a Basic/Standard developer token.',
1177
+ title: 'Build a Google Ads campaign (paused)',
1178
+ description: 'Build a campaign on a connected Google Ads account. ALWAYS created PAUSED — it spends NOTHING until you enable it with set_google_ads_status(confirm:true). Google\'s object graph is campaign → ad group → ad, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds budget + campaign + location/language targeting + ad group + ad + keywords in ONE ATOMIC operation (if any part is rejected, nothing at all is created no half-built campaign to clean up). Also here: bidding strategy, locations by NAME ("United States", "Toronto" — resolved for you), languages, and start/end dates. Google requires 3–15 headlines (≤30 chars) and 2–4 descriptions (≤90 chars) on a search ad. Everything is READ BACK from Google before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
989
1179
  inputSchema: {
990
1180
  customerId: z.string().optional().describe('10-digit account id (from list_google_ads_campaigns) — omit to use the brand’s selected default account'),
991
1181
  name: z.string().describe('campaign name'),
@@ -993,14 +1183,136 @@ export function registerTools(server) {
993
1183
  budgetResourceName: z.string().optional().describe('reuse an existing budget instead of creating one'),
994
1184
  channelType: z.enum(['SEARCH', 'DISPLAY']).optional().describe('default SEARCH'),
995
1185
  searchPartners: z.boolean().optional().describe('SEARCH only — also serve on Google search partners (default false)'),
996
- dryRun: z.boolean().optional().describe('validate the config against Google without creating anything proves the account accepts writes and that the fields are valid. Nothing is written and no budget is consumed'),
1186
+ bidding: gadsBiddingShape.describe('how the campaign bidsdefault MANUAL_CPC'),
1187
+ locations: z.array(z.string()).optional().describe('location NAMES to target, e.g. ["United States"] or ["Toronto","Vancouver"]. WITHOUT this the campaign runs WORLDWIDE — the most expensive default in Google Ads'),
1188
+ excludedLocations: z.array(z.string()).optional().describe('location names to EXCLUDE'),
1189
+ languages: z.array(z.string()).optional().describe('ISO language codes, e.g. ["en","fr"]'),
1190
+ startDate: z.string().optional().describe('YYYY-MM-DD'),
1191
+ endDate: z.string().optional().describe('YYYY-MM-DD'),
1192
+ adGroup: z.object({ name: z.string().optional(), cpcBidUsd: z.number().optional(), ad: z.object(gadsAdShape).optional(), keywords: gadsKeywordShape.optional() }).optional().describe('build the serving tree in the same atomic call — WITHOUT this you get a campaign shell that can never show an ad'),
1193
+ containsEuPoliticalAds: z.boolean().optional().describe('EU Political Advertising Regulation declaration. Google REQUIRES one on every campaign. Default false (a normal commercial ad) — set true ONLY for genuine EU political advertising'),
1194
+ dryRun: z.boolean().optional().describe('validate the WHOLE tree against Google without creating anything. Nothing is written and no budget is consumed'),
997
1195
  loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
998
1196
  },
999
- outputSchema: { ok: z.boolean().optional(), campaignResourceName: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), budgetResourceName: z.string().optional() },
1197
+ outputSchema: { ok: z.boolean().optional(), campaignResourceName: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), budgetResourceName: z.string().optional(), note: z.string().optional() },
1000
1198
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1001
1199
  }, wrap(async (a) => {
1002
1200
  const d = await apiPost('/api/google-ads/campaign', a);
1003
- return ok(`Created campaign ${d.campaignId} (PAUSED). ${d.note || ''} To make it spend, use set_google_ads_status(confirm:true) after the user approves.`, d);
1201
+ // d.note is written from a READ-BACK of the whole tree (the route 502s rather than returning an unverified id),
1202
+ // and it states outright whether the campaign can serve. Print it; never re-assert "Created campaign X" here.
1203
+ return ok(`${d.dryRun ? d.note : `${d.note} To make it spend, use set_google_ads_status(confirm:true) after the user approves.`}`, d);
1204
+ }));
1205
+ server.registerTool('create_google_ads_ad_group', {
1206
+ title: 'Add an ad group to a Google Ads campaign',
1207
+ description: 'Add an ad group to an EXISTING Google Ads campaign — the level between a campaign and its ads. Google requires it: a campaign with no ad group cannot serve. Optionally build its ad and keywords in the same ATOMIC call. The ad-group type is taken from the campaign\'s channel automatically. Created PAUSED and read back from Google before you are told it exists. If the parent campaign is already LIVE (ENABLED), creating this ENABLED starts REAL AD SPEND immediately — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it PAUSED never needs confirmation.',
1208
+ inputSchema: {
1209
+ customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
1210
+ campaignId: z.string().describe('the campaign this ad group belongs to'),
1211
+ name: z.string().describe('ad group name'),
1212
+ cpcBidUsd: z.number().optional().describe('max CPC for this ad group — omit to inherit the campaign bidding'),
1213
+ status: z.enum(['ENABLED', 'PAUSED']).optional().describe('default PAUSED'),
1214
+ ad: z.object(gadsAdShape).optional().describe('build the ad in the same atomic call'),
1215
+ keywords: gadsKeywordShape.optional().describe('a SEARCH ad group with no keywords never shows'),
1216
+ confirm: z.boolean().optional().describe('set true ONLY after the user explicitly approved starting spend — required when the parent campaign is already LIVE (ENABLED) and this object would serve immediately'),
1217
+ dryRun: z.boolean().optional(),
1218
+ loginCustomerId: z.string().optional(),
1219
+ },
1220
+ outputSchema: { ok: z.boolean().optional(), adGroupId: z.string().optional(), adGroupResourceName: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
1221
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1222
+ }, wrap(async (a) => {
1223
+ const d = await apiPost('/api/google-ads/ad-group', a);
1224
+ return ok(d.note, d);
1225
+ }));
1226
+ server.registerTool('create_google_ads_ad', {
1227
+ title: 'Create a Google Ads ad',
1228
+ description: 'Create the actual AD inside a Google Ads ad group — this is the object that carries the creative; a campaign or ad group alone shows nothing. On a SEARCH campaign it builds a RESPONSIVE SEARCH AD: 3–15 headlines (≤30 chars), 2–4 descriptions (≤90 chars), at least one finalUrl, optional path1/path2. On a DISPLAY campaign it builds a RESPONSIVE DISPLAY AD: headlines, longHeadline, descriptions, businessName plus BOTH a landscape (1.91:1) and a square (1:1) image asset from upload_google_ads_asset. The right format is chosen from the campaign\'s channel. Created PAUSED and read back from Google. If the parent campaign is already LIVE (ENABLED), creating this ENABLED starts REAL AD SPEND immediately — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it PAUSED never needs confirmation.',
1229
+ inputSchema: {
1230
+ customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
1231
+ adGroupId: z.string().describe('the ad group this ad lives in'),
1232
+ ...gadsAdShape,
1233
+ status: z.enum(['ENABLED', 'PAUSED']).optional().describe('default PAUSED'),
1234
+ confirm: z.boolean().optional().describe('set true ONLY after the user explicitly approved starting spend — required when the parent campaign is already LIVE (ENABLED) and this object would serve immediately'),
1235
+ dryRun: z.boolean().optional(),
1236
+ loginCustomerId: z.string().optional(),
1237
+ },
1238
+ outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), adResourceName: z.string().optional(), type: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
1239
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1240
+ }, wrap(async (a) => {
1241
+ const d = await apiPost('/api/google-ads/ad', a);
1242
+ return ok(d.note, d);
1243
+ }));
1244
+ server.registerTool('add_google_ads_keywords', {
1245
+ title: 'Add Google Ads keywords',
1246
+ description: 'Add keywords — and NEGATIVE keywords — to a Google Ads ad group. A Search ad group with no keywords never shows. Each keyword takes text (≤80 chars, ≤10 words) and matchType EXACT | PHRASE | BROAD (default PHRASE). Set negative:true to BLOCK a term instead of targeting it, which is the cheapest way to stop wasted spend. Read back from Google before you are told they exist. If the parent campaign and ad group are already LIVE, a positive keyword starts bidding real money at once — get an explicit yes and pass confirm:true, or add it with paused:true. Negative keywords only restrict spend and never need confirmation.',
1247
+ inputSchema: {
1248
+ customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
1249
+ adGroupId: z.string().describe('the ad group to add them to'),
1250
+ keywords: gadsKeywordShape.describe('the keywords to add'),
1251
+ confirm: z.boolean().optional().describe('set true ONLY after the user explicitly approved starting spend — required when the parent campaign is already LIVE (ENABLED) and this object would serve immediately'),
1252
+ dryRun: z.boolean().optional(),
1253
+ loginCustomerId: z.string().optional(),
1254
+ },
1255
+ outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), adGroupId: z.string().optional(), note: z.string().optional() },
1256
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1257
+ }, wrap(async (a) => {
1258
+ const d = await apiPost('/api/google-ads/keywords', a);
1259
+ return ok(d.note, d);
1260
+ }));
1261
+ server.registerTool('set_google_ads_targeting', {
1262
+ title: 'Set Google Ads location & language targeting',
1263
+ description: 'Set WHERE and in what LANGUAGE an existing Google Ads campaign runs. Pass locations by NAME ("United States", "California", "Toronto") — they are resolved to Google\'s geo target ids for you; excludedLocations blocks places; languages takes ISO codes ("en","fr"). A campaign with NO location targeting runs WORLDWIDE, which is the most expensive default in Google Ads. Changing a LIVE campaign\'s targeting moves real spend immediately, so that needs confirm:true.',
1264
+ inputSchema: {
1265
+ customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
1266
+ campaignId: z.string().describe('the campaign to target'),
1267
+ locations: z.array(z.string()).optional().describe('location NAMES to target'),
1268
+ excludedLocations: z.array(z.string()).optional().describe('location NAMES to exclude'),
1269
+ languages: z.array(z.string()).optional().describe('ISO language codes, e.g. ["en","es"]'),
1270
+ countryCode: z.string().optional().describe('2-letter hint to disambiguate a city name, e.g. CA for "London"'),
1271
+ confirm: z.boolean().optional().describe('REQUIRED true to change a LIVE (ENABLED) campaign'),
1272
+ dryRun: z.boolean().optional(),
1273
+ loginCustomerId: z.string().optional(),
1274
+ },
1275
+ outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), note: z.string().optional() },
1276
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1277
+ }, wrap(async (a) => {
1278
+ const d = await apiPost('/api/google-ads/targeting', a);
1279
+ return ok(d.note, d);
1280
+ }));
1281
+ server.registerTool('set_google_ads_bidding', {
1282
+ title: 'Set a Google Ads bidding strategy',
1283
+ description: 'Change how an existing Google Ads campaign bids: MANUAL_CPC (optionally enhanced), MAXIMIZE_CLICKS (needs maxCpcUsd on an existing campaign — Google requires the CPC ceiling on that change), MAXIMIZE_CONVERSIONS, MAXIMIZE_CONVERSION_VALUE, TARGET_CPA (needs targetCpaUsd) or TARGET_ROAS (needs targetRoas, e.g. 4 = $4 revenue per $1 spent). TARGET_CPA and TARGET_ROAS are applied as Google\'s own v25 equivalents — maximize-conversions with a target CPA, and maximize-conversion-value with a target ROAS — so the read-back reports them as MAXIMIZE_CONVERSIONS / MAXIMIZE_CONVERSION_VALUE; report what the read-back says. The conversion-based strategies only deliver once conversion tracking is configured on the account. Changing a LIVE campaign\'s bidding changes what it pays immediately, so that needs confirm:true.',
1284
+ inputSchema: {
1285
+ customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
1286
+ campaignId: z.string().describe('the campaign to change'),
1287
+ strategy: z.enum(['MANUAL_CPC', 'MAXIMIZE_CLICKS', 'MAXIMIZE_CONVERSIONS', 'MAXIMIZE_CONVERSION_VALUE', 'TARGET_CPA', 'TARGET_ROAS']).describe('the bidding strategy'),
1288
+ targetCpaUsd: z.number().optional().describe('REQUIRED for TARGET_CPA'),
1289
+ targetRoas: z.number().optional().describe('REQUIRED for TARGET_ROAS — e.g. 4 = $4 revenue per $1 spent'),
1290
+ maxCpcUsd: z.number().optional().describe('MAXIMIZE_CLICKS — the max CPC ceiling; REQUIRED when switching an existing campaign to it'),
1291
+ enhancedCpc: z.boolean().optional().describe('MANUAL_CPC only'),
1292
+ confirm: z.boolean().optional().describe('REQUIRED true to change a LIVE (ENABLED) campaign'),
1293
+ dryRun: z.boolean().optional(),
1294
+ loginCustomerId: z.string().optional(),
1295
+ },
1296
+ outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), biddingStrategyType: z.string().optional(), note: z.string().optional() },
1297
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1298
+ }, wrap(async (a) => {
1299
+ const d = await apiPost('/api/google-ads/bidding', a);
1300
+ return ok(d.note, d);
1301
+ }));
1302
+ server.registerTool('find_google_ads_locations', {
1303
+ title: 'Look up Google Ads locations',
1304
+ description: 'Look up Google Ads location targets by name — turns "Toronto" / "California" / "United Kingdom" into the geo target ids Google needs, with each one\'s type (COUNTRY, STATE, CITY, POSTAL_CODE…) and reach. Use it when a location name is ambiguous, or to show the user exactly which place you are about to target. Read-only and free.',
1305
+ inputSchema: {
1306
+ query: z.string().describe('one location name, or several comma-separated (up to 25)'),
1307
+ countryCode: z.string().optional().describe('2-letter hint, e.g. CA to disambiguate "London"'),
1308
+ loginCustomerId: z.string().optional(),
1309
+ },
1310
+ outputSchema: { count: z.number().optional(), locations: z.array(z.any()).optional() },
1311
+ annotations: { readOnlyHint: true, openWorldHint: true },
1312
+ }, wrap(async (a) => {
1313
+ const d = await apiGet('/api/google-ads/locations', a);
1314
+ if (!d.count) return ok(`Google has no location matching "${a.query}". Try a broader name (the country, or the city without the region).`, d);
1315
+ return ok(`${d.count} match(es): ${d.locations.slice(0, 20).map(g => `${g.name} — ${g.type}${g.countryCode ? `, ${g.countryCode}` : ''} (id ${g.id})`).join(' | ')}. Pass the exact NAME to set_google_ads_targeting or create_google_ads_campaign.`, d);
1004
1316
  }));
1005
1317
  server.registerTool('set_google_ads_budget', {
1006
1318
  title: 'Set a Google Ads campaign budget',
@@ -1020,21 +1332,26 @@ export function registerTools(server) {
1020
1332
  return ok(`Budget set to $${d.dailyBudgetUsd}/day (${d.budgetResourceName}).`, d);
1021
1333
  }));
1022
1334
  server.registerTool('set_google_ads_status', {
1023
- title: 'Enable or pause a Google Ads campaign',
1024
- description: 'Turn a campaign ON (ENABLED) or OFF (PAUSED). ENABLING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"ENABLED" and confirm:true. Pausing is always safe.',
1335
+ title: 'Enable, pause or remove a Google Ads campaign / ad group / ad',
1336
+ description: 'Turn a campaign, AD GROUP or AD ON (ENABLED), OFF (PAUSED) or REMOVED. Pass level:"campaign" + campaignId, level:"adGroup" + adGroupId, or level:"ad" + BOTH adGroupId and adId (Google keys an ad by adGroupId~adId). ENABLING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"ENABLED" and confirm:true. REMOVED is PERMANENT in Google Ads and also requires confirm:true. Pausing is always safe. The resulting status is READ BACK from Google before you are told it took.',
1025
1337
  inputSchema: {
1026
1338
  customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
1027
- campaignId: z.string().optional().describe('campaign id (or pass campaignResourceName)'),
1028
- campaignResourceName: z.string().optional().describe('full resource name customers/{cid}/campaigns/{id}'),
1029
- status: z.enum(['ENABLED', 'PAUSED']).describe('ENABLED = start spending; PAUSED = stop'),
1030
- confirm: z.boolean().optional().describe('REQUIRED true to enable (real spend)'),
1339
+ level: z.enum(['campaign', 'adGroup', 'ad']).optional().describe('what to change default campaign'),
1340
+ campaignId: z.string().optional().describe('campaign id (level:"campaign")'),
1341
+ adGroupId: z.string().optional().describe('ad group id (level:"adGroup", or with adId for level:"ad")'),
1342
+ adId: z.string().optional().describe('ad id (level:"ad" pass adGroupId too)'),
1343
+ campaignResourceName: z.string().optional().describe('full resource name, e.g. customers/{cid}/campaigns/{id}'),
1344
+ status: z.enum(['ENABLED', 'PAUSED', 'REMOVED']).describe('ENABLED = start spending; PAUSED = stop; REMOVED = permanent'),
1345
+ confirm: z.boolean().optional().describe('REQUIRED true to ENABLE (real spend) or to REMOVE (permanent)'),
1031
1346
  loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
1032
1347
  },
1033
- outputSchema: { ok: z.boolean().optional(), campaignResourceName: z.string().optional(), status: z.string().optional() },
1348
+ outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), resourceName: z.string().optional(), status: z.string().optional(), verifiedStatus: z.string().optional(), note: z.string().optional() },
1034
1349
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1035
1350
  }, wrap(async (a) => {
1036
1351
  const d = await apiPost('/api/google-ads/status', a);
1037
- return ok(d.note || `Campaign ${a.status}.`, d);
1352
+ // d.note is written from the READ-BACK and says so when Google reports a status different to the one we asked
1353
+ // for — print it rather than re-asserting `a.status`, which would be a claim about the request, not the account.
1354
+ return ok(d.note || `${d.level || 'campaign'} → ${d.verifiedStatus || a.status}.`, d);
1038
1355
  }));
1039
1356
  server.registerTool('upload_google_ads_asset', {
1040
1357
  title: 'Upload a creative to Google Ads',
@@ -1388,7 +1705,17 @@ export function registerTools(server) {
1388
1705
  },
1389
1706
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1390
1707
  }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
1391
- const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
1708
+ let brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
1709
+ // A BARE STRING brand name used to become the literal object {name:"Fly By Jing"} — no domain, no productImages —
1710
+ // and because an EXPLICIT brand suppresses hydrateAgentContext, that stripped-down object then got stamped onto
1711
+ // creative.brand (below) and preferred by /api/render/assemble over the workspace brand. Net: naming your own
1712
+ // saved brand as a string silently threw away its domain, logo and every product photo, and the render invented
1713
+ // the packaging. Re-attach the SAVED brand when the string names it (normalized compare) — a DIFFERENT brand name
1714
+ // still falls through untouched, so the 2026-07-17 multi-brand contamination fix stands.
1715
+ if (typeof brand === 'string' && brand.trim()) {
1716
+ const _n = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
1717
+ try { const cur = await apiGet('/api/brand/current'); if (cur?.hasBrand && cur.brand && _n(cur.brand.name) && _n(cur.brand.name) === _n(brand)) brandObj = cur.brand; } catch {}
1718
+ }
1392
1719
  const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
1393
1720
  const c = d.creative || d;
1394
1721
  // EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
@@ -1479,9 +1806,11 @@ export function registerTools(server) {
1479
1806
  lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
1480
1807
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
1481
1808
  dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged'),
1809
+ allowGenericProduct: z.boolean().optional().describe('proceed even though this brand has NO product photo on file and the ad features a product — the packaging will be INVENTED. Only pass true after telling the user that and hearing they are fine with a generic stand-in'),
1482
1810
  },
1483
1811
  outputSchema: {
1484
1812
  ...JOB_OUT,
1813
+ needsProductPhoto: z.boolean().optional().describe('true when nothing was rendered because the ad features a product this brand has no photo of'),
1485
1814
  dryRun: z.boolean().optional().describe('true when this was a dry run (no job submitted, nothing charged)'),
1486
1815
  jobType: z.string().optional().describe("the routing decision — 'video' (single pass) or 'stitch' (acts)"),
1487
1816
  input: z.any().optional().describe('the assembled render input (dry run only — resolved model, duration, scenes)'),
@@ -1489,11 +1818,21 @@ export function registerTools(server) {
1489
1818
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1490
1819
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your video ad…', 'Video ad ready'),
1491
1820
  }, wrap(async (a) => {
1492
- const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
1821
+ const { input, jobType, notes, needsProductPhoto } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
1493
1822
  // LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
1494
1823
  // (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
1495
1824
  // acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
1496
1825
  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)' : ''}.\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', input });
1826
+ // ASK BEFORE SPENDING (Dave 2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
1827
+ // spent"). `notes` alone was not enough here: on the real path it only reaches the model AFTER renderJob has
1828
+ // polled to completion, i.e. after the credits are gone. So when the ad features a product this brand has no
1829
+ // photo of, STOP and say so — the same honesty contract as templateGapMessage: nothing was rendered, nothing was
1830
+ // charged, tell the user exactly what is missing and how to fix it. NOT a permanent block: the user can supply a
1831
+ // photo, or the caller re-calls with allowGenericProduct:true once they have actually said they are fine with a
1832
+ // stand-in. (dryRun already returns before this — it charges nothing either way.)
1833
+ if (needsProductPhoto && !a.allowGenericProduct) {
1834
+ 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' });
1835
+ }
1497
1836
  const r = await renderJob(jobType === 'stitch' ? 'stitch' : 'video', input, 'MCP ad render');
1498
1837
  return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]\n${notes || ''}`, r);
1499
1838
  }));
@@ -1501,7 +1840,7 @@ export function registerTools(server) {
1501
1840
 
1502
1841
  server.registerTool('make_template_ad', {
1503
1842
  title: 'Make template ad',
1504
- description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
1843
+ description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'kinetic-type' (VIDEO ~9-15s typographic motion design with NO VOICEOVER — it is NOT a silent asset: it always carries its own synthesised SFX (whoosh/tick/chime) and, once a curated track is on file, the family's loudest music bed at -16 LUFS; config.music:'off' silences the bed but never the SFX: 3-6 short phrases each land word by word on a full-bleed brand card (product beats caption the phrase over the photo instead), the longest word picked out in the brand accent, and a skewed accent slab wipes every cut; supply productImages and every OTHER beat becomes a full-bleed product shot with its phrase captioned over it — with none it renders as pure typography, so it needs NO photos; config: { phrases: string[] (3-6, ≤34 chars each — punchy, declarative, ONE idea per phrase, a finished thought never a clipped clause), productImages?: string[] (up to 4 DISTINCT photos), palette?: string[], fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Every VIDEO format except myth-vs-fact (VO-first, deliberately dry) also gets a mood-matched MUSIC BED when a curated track is on file (the library ships empty — no track means no bed, never a paid generation) under its own SFX, from the curated library — free, no model, no extra credits; set config.music:'off' for a silent cut or a mood name (upbeat/calm/warm/epic/tense/playful/elegant/hype/chill/dramatic) to re-mood it. Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
1505
1844
  inputSchema: {
1506
1845
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
1507
1846
  },
@@ -1590,6 +1929,83 @@ export function registerTools(server) {
1590
1929
  return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
1591
1930
  }));
1592
1931
 
1932
+ // ── THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for
1933
+ // months but was reachable ONLY from the web app (the + menu modals and the client-side sizzle router) — zero tools
1934
+ // on either surface, so no agent could touch them. These expose them 1:1; the app keeps its own entry points.
1935
+ // The credit figures below are the reserve HOLD the server itself publishes at GET /api/generate/status
1936
+ // (clipCredits = quoteCredits(0.06) = 7, explainCredits = quoteCredits(0.30) = 31) and, for the sizzle, its one paid
1937
+ // leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
1938
+ server.registerTool('clip_video', {
1939
+ title: 'Clip a long video',
1940
+ description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video.",
1941
+ inputSchema: {
1942
+ video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
1943
+ count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
1944
+ aspectRatio: z.enum(['9:16', '1:1', '16:9', 'keep']).optional().describe("clip shape — '9:16' (default) vertical for Reels/Shorts/TikTok; 'keep' leaves the source framing untouched"),
1945
+ },
1946
+ outputSchema: { ...JOB_OUT },
1947
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1948
+ }, wrap(async (a) => {
1949
+ const r = await renderJob('clipper', { video: a.video, count: a.count, aspectRatio: a.aspectRatio }, 'MCP clipper');
1950
+ if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the clips when it lands
1951
+ const clips = Array.isArray(r?.raw?.clips) ? r.raw.clips : [];
1952
+ if (!clips.length) return ok(`That video produced no clips [job ${r.jobId}]`, r);
1953
+ const lines = clips.map((c, i) => `${i + 1}. ${c.title || 'Clip ' + (i + 1)} — score ${c.score ?? '—'} · ${c.durationSeconds ?? '?'}s from ${c.start ?? 0}s · ${abs(c.video)}${c.caption ? `\n caption: ${c.caption}` : ''}`);
1954
+ // Say it out loud when the ~40-minute transcription ceiling cut the source short — page ingest makes 60-90min
1955
+ // podcasts routine, and silently clipping only the first stretch reads as "it missed the best part".
1956
+ const trunc = r?.raw?.truncated ? `\nNOTE: the source runs ${Math.round((r.raw.sourceDuration || 0) / 60)} min and only the first ${Math.round((r.raw.analyzedSeconds || 0) / 60)} min was analysed — these clips all come from that stretch.` : '';
1957
+ return ok(`Cut ${clips.length} ranked clip${clips.length === 1 ? '' : 's'} [job ${r.jobId}]:\n${lines.join('\n')}${trunc}`, r);
1958
+ }));
1959
+
1960
+ server.registerTool('make_explainer', {
1961
+ title: 'Make an explainer video',
1962
+ description: "Turn a TOPIC into a finished narrated, captioned explainer video. Writes a sectioned script, paints one image per section, narrates each with TTS, adds gentle Ken-Burns motion, then composites the on-screen text + end card with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). It is an image-slide 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: a ~31-credit hold for a ~6-section 60s explainer on the default style; a styled one renders that extra key and routes each scene through the compositing model, so budget a hold of up to ~58 credits for the same 6 sections. Both settle to the exact per-section image + narration spend (a longer target = more sections = more). 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.",
1963
+ inputSchema: {
1964
+ topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
1965
+ durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
1966
+ aspectRatio: z.enum(['9:16', '16:9', '1:1', '4:5', '3:4']).optional().describe("'9:16' default"),
1967
+ style: z.enum(['cinematic', 'editorial_collage', 'flat_vector', 'stickman', 'whiteboard', 'ink_marker', 'silhouette', 'storybook', 'paper_diorama', 'isometric', 'claymation', 'pixel_art']).optional().describe("visual style. 'cinematic' (default) is photoreal; the rest are non-photoreal styled looks — editorial_collage (halftone cutouts + marker accents), flat_vector, stickman, whiteboard, ink_marker, silhouette, storybook (gouache), paper_diorama, isometric, claymation, pixel_art. Ask the user which they want rather than picking silently; a styled pick costs more (see the cost note)."),
1968
+ voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
1969
+ captions: z.boolean().optional().describe('burn on-screen text (default true)'),
1970
+ subtitles: z.boolean().optional().describe('burn CAPS SUBTITLES timed to the narration instead of one held key point per section (default false). Free — no extra render, no extra credits.'),
1971
+ endCard: z.boolean().optional().describe('append the branded end card (default true)'),
1972
+ brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
1973
+ },
1974
+ outputSchema: { ...JOB_OUT },
1975
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1976
+ }, wrap(async (a) => {
1977
+ const r = await renderJob('explainer', { topic: a.topic, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, style: a.style, subtitles: a.subtitles, voice: a.voice, captions: a.captions, endCard: a.endCard, brandName: a.brandName }, 'MCP explainer');
1978
+ const d = r?.raw || {};
1979
+ return okVideo(`Explainer ready${d.sections ? ` — ${d.sections} sections, ${d.durationSeconds}s` : ''}${d.style && d.style !== 'cinematic' ? ` in the ${String(d.style).replace(/_/g, ' ')} style${d.styleLocked ? '' : ' (style key unavailable — the look rides on the prompt only)'}` : ''}: ${r.url} [job ${r.jobId}]`, r);
1980
+ }));
1981
+
1982
+ server.registerTool('product_sizzle', {
1983
+ title: 'Product sizzle (music-led)',
1984
+ description: "Render an 18-30s music-led PRODUCT SIZZLE: ONE 15s Seedance 2.0 hero clip of the product, diced into fast cuts and intercut with typeset spec/CTA cards on a brand-coloured grain background, mixed to a music bed. Faceless by design — no people, no voiceover, no spoken lines; the cards carry every word, so nothing is left to a video model's spelling. Pass a real packshot as refImage or the label will not be yours. EXPENSIVE — the hero clip is the only paid leg and it is a full 15s Seedance render: ≈1,040 credits at the DEFAULT 1080p, ≈470 at 720p, ≈220 at 480p, ≈4,130 at 4k (call hermoso_capabilities for the live seedance-2 per-duration numbers; the dicing and the cards are free, and the music bed is already included in the quoted figure). Confirm the spend with the user before calling. For a talking/UGC ad use render_ad or generate_avatar; for a cheap deterministic format use make_template_ad.",
1985
+ inputSchema: {
1986
+ prompt: z.string().describe('what the sizzle should show — the product, the setting, the look'),
1987
+ seconds: z.number().optional().describe('finished length, clamped to 18-30s (default 25). The PAID hero render is always 15s regardless — this only changes how the cuts and cards are packed'),
1988
+ refImage: z.string().optional().describe('product packshot URL that anchors the real label — strongly recommended'),
1989
+ aspectRatio: z.string().optional().describe("'9:16' default; anything the seedance-2 catalog entry does not list falls back to 9:16"),
1990
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("hero-clip resolution and therefore the whole cost — DEFAULT '1080p' (≈1,040 credits); '720p' ≈470, '480p' ≈220, '4k' ≈4,130"),
1991
+ specs: z.array(z.string()).optional().describe('up to 4 spec lines for the typeset cards, ≤26 chars each'),
1992
+ cta: z.string().optional().describe('closing CTA line, ≤30 chars'),
1993
+ brandName: z.string().optional().describe('brand name on the cards — defaults to the workspace brand'),
1994
+ musicMood: z.string().optional().describe('music-bed mood, e.g. driving / cinematic / upbeat'),
1995
+ },
1996
+ outputSchema: { ...JOB_OUT },
1997
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1998
+ }, wrap(async (a) => {
1999
+ let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // the cards want the REAL palette/logo, same as post_edit
2000
+ const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
2001
+ const r = await renderJob('hypermotion', {
2002
+ prompt: a.prompt, seconds: a.seconds, refImage: a.refImage, aspectRatio: a.aspectRatio, resolution: a.resolution, musicMood: a.musicMood,
2003
+ cardCopy: { specs: a.specs, cta: a.cta || b.cta || '' },
2004
+ brand: { name: a.brandName || b.name || '', domain: b.domain || '', logo: b.logo || '', palette: pal },
2005
+ }, 'MCP product sizzle');
2006
+ return okVideo(`Product sizzle ready: ${r.url} [job ${r.jobId}]`, r);
2007
+ }));
2008
+
1593
2009
  server.registerTool('generate_video', {
1594
2010
  title: 'Generate video',
1595
2011
  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. 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).',
@@ -1693,6 +2109,8 @@ export function registerTools(server) {
1693
2109
  }));
1694
2110
 
1695
2111
  // ---------- skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles) ----------
2112
+ // The bundle dirs/content may still carry the pre-rename brand — always serve them under the product name.
2113
+ const brandSkillText = (s) => String(s).replace(/HEIST_/g, 'HERMOSO_').replace(/heist-/g, 'hermoso-').replace(/Hermoso/g, 'Hermoso').replace(/\bheist\b/g, 'hermoso');
1696
2114
  server.registerTool('list_skills', {
1697
2115
  title: 'List skills',
1698
2116
  description: 'List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.',
@@ -1712,7 +2130,7 @@ export function registerTools(server) {
1712
2130
  try {
1713
2131
  const md = await readFile(new URL(`../skills/${n}/SKILL.md`, import.meta.url), 'utf8');
1714
2132
  const desc = (/description:\s*>?-?\s*\n?([\s\S]*?)\n[a-z_-]+:/.exec(md)?.[1] || '').replace(/\s+/g, ' ').trim().slice(0, 220);
1715
- return { name: n, description: desc };
2133
+ return { name: brandSkillText(n), description: brandSkillText(desc) }; // legacy-named bundles surface under the product name
1716
2134
  } catch { return null; }
1717
2135
  }))).filter(Boolean);
1718
2136
  } catch {}
@@ -1735,9 +2153,10 @@ export function registerTools(server) {
1735
2153
  }, wrap(async ({ name }) => {
1736
2154
  const safe = String(name).replace(/[^a-z0-9-]/gi, '');
1737
2155
  const { readFile } = await import('node:fs/promises');
1738
- const md = await readFile(new URL(`../skills/${safe}/SKILL.md`, import.meta.url), 'utf8').catch(() => null);
2156
+ const tryRead = (n) => readFile(new URL(`../skills/${n}/SKILL.md`, import.meta.url), 'utf8').catch(() => null);
2157
+ const md = await tryRead(safe) || await tryRead(safe.replace(/^hermoso-/, 'heist-')) || await tryRead(safe.replace(/^heist-/, 'hermoso-')); // bundle dirs may carry the legacy prefix
1739
2158
  if (!md) return { content: [{ type: 'text', text: `No skill bundle named "${safe}" — call list_skills for the catalog.` }], isError: true };
1740
- return ok(md.slice(0, 24000), { name: safe });
2159
+ return ok(brandSkillText(md.slice(0, 24000)), { name: safe });
1741
2160
  }));
1742
2161
 
1743
2162
  // ---------- workspace management: Memory / Skills / Employees / Brand / Connectors / Team / raw store (r-m-w over the store seam) ----------
@@ -1994,7 +2413,7 @@ export function registerTools(server) {
1994
2413
  title: 'Find competitors',
1995
2414
  description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 ScrapeCreators credits.",
1996
2415
  inputSchema: {
1997
- domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
2416
+ domain: z.string().describe('the brand domain, e.g. flourish.com'),
1998
2417
  mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
1999
2418
  },
2000
2419
  outputSchema: {
@@ -2373,6 +2792,31 @@ export function registerTools(server) {
2373
2792
  }, wrap(async ({ save, ...a }) => {
2374
2793
  const d = await apiPost('/api/brand/draft', a);
2375
2794
  const p = d.profile || d;
2795
+ // ALWAYS TRY THE WEBSITE (Dave 2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
2796
+ // product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
2797
+ // later plan_ad/render_ad on it invented the packaging. This tool's own outputSchema has advertised `logo`,
2798
+ // `products` and `productImages` since it shipped; nothing ever filled them. Pull them from the SAME endpoint the
2799
+ // web onboarding uses (Shopify/JSON-LD catalog → scrape → fail-closed vision gate → durable persisted URLs) rather
2800
+ // than growing a second, drift-prone extractor. Best-effort: a slow/blocked/anti-bot site must still return the
2801
+ // drafted profile, so every failure degrades to "no photos", never to a failed draft.
2802
+ // GATED ON physical_product, exactly like the web path (public/app.js writes productImages/product only when
2803
+ // `pp`). Ungated, a SERVICE or APP brand acquired a "product library" from its own og:image — and brandContext
2804
+ // would then print "SERVICE BUSINESS — there is NO physical product. NEVER invent a box, bottle, package…"
2805
+ // directly above a populated photo library, while assembleAdRender attached that image to every render.
2806
+ if (p && p.domain && p.physical_product !== false) {
2807
+ try {
2808
+ const site = await apiGet('/api/site/images', { url: p.domain });
2809
+ const imgs = [...(site?.images || [])].filter(Boolean);
2810
+ if (imgs.length) { p.productImages = imgs.slice(0, 12); if (!p.product) p.product = imgs[0]; }
2811
+ // products is an array of product-NAME STRINGS everywhere else (public/app.js writes `_prodNames`), and
2812
+ // brandContext joins it straight into "use these EXACT names". /api/site/images returns {title,image}
2813
+ // OBJECTS, so storing them raw printed "[object Object], [object Object]" into every subsequent plan —
2814
+ // strictly worse than the empty line it replaced. Map to titles.
2815
+ const names = (Array.isArray(site?.products) ? site.products : []).map(x => String(x?.title || x || '').trim()).filter(Boolean);
2816
+ if (names.length) p.products = [...new Set(names)].slice(0, 12);
2817
+ if (!p.logo && site?.logo) p.logo = site.logo;
2818
+ } catch {}
2819
+ }
2376
2820
  let saved = false;
2377
2821
  if (save !== false) {
2378
2822
  try {
@@ -2492,16 +2936,19 @@ export function registerTools(server) {
2492
2936
 
2493
2937
  server.registerTool('dub_video', {
2494
2938
  title: 'Dub video',
2495
- description: "Remake a finished video ad's voiceover in another language (translated script, re-voiced, re-muxed). Paid; returns the served URL of the localized video.",
2939
+ description: "Localize a finished video into another language WITHOUT re-rendering it: the spoken track is transcribed, translated, re-voiced and lip-synced back onto the SAME footage, so the visuals, timing and edit are untouched. Just pass the video and the language — the script is read off the source automatically (pass `script` only to override what it heard). Paid; returns the served URL of the localized video.",
2496
2940
  inputSchema: {
2497
2941
  video: z.string().describe('the source video URL'),
2498
2942
  language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
2499
- script: z.string().optional().describe('the original spoken script if knownimproves translation fidelity'),
2943
+ script: z.string().optional().describe('OPTIONAL override for the original spoken words. Leave this out the source video is transcribed automatically. Only pass it when you already know the exact script and the auto-transcript got it wrong.'),
2944
+ voice: z.string().optional().describe("optional target voice preset, e.g. 'Aria' (warm female) or 'George' (confident male). Defaults to a voice matching the source speaker's register."),
2500
2945
  },
2501
2946
  outputSchema: { ...JOB_OUT },
2502
2947
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
2503
- }, wrap(async ({ video, language, script }) => {
2504
- const r = await renderJob('dub', { video, language, script: script || '' }, `Dub ${language}`);
2948
+ }, wrap(async ({ video, language, script, voice }) => {
2949
+ // Forward `script` ONLY when the caller actually supplied one. Sending '' used to hit the worker's
2950
+ // empty-script guard, so the documented {video, language} call could never succeed.
2951
+ const r = await renderJob('dub', { video, language, ...(String(script || '').trim() ? { script } : {}), ...(voice ? { voice } : {}) }, `Dub → ${language}`);
2505
2952
  return okVideo(`Localized video (${language}): ${r.url}`, r);
2506
2953
  }));
2507
2954
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
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 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, 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 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, 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"