hermoso 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/tools.mjs +547 -35
- package/package.json +2 -2
package/mcp/tools.mjs
CHANGED
|
@@ -34,7 +34,7 @@ const CAPABILITY_MAP = [
|
|
|
34
34
|
'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / use_brand · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · 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).',
|
|
35
35
|
'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.',
|
|
36
36
|
'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).',
|
|
37
|
-
'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). META: list_meta_pages · post_to_meta (Facebook / Instagram / Threads) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC) · 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). YOUTUBE: post_to_youtube (publish a finished video to the brand’s channel — defaults to
|
|
37
|
+
'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). META: list_meta_pages · post_to_meta (Facebook / Instagram / Threads) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC) · 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). 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) · 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) · 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 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). 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). GOOGLE DRIVE (full CRUD over the files Hermoso created there): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (export data to a spreadsheet the app creates — drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (export copy/brief/report as a doc — 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.',
|
|
38
38
|
].join('\n');
|
|
39
39
|
|
|
40
40
|
// Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
|
|
@@ -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
|
|
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.
|
|
752
|
-
//
|
|
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,103 @@ 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
|
+
// ── YOUTUBE: MEASURE + MANAGE (2026-07-30). We requested yt-analytics.readonly and youtube.force-ssl from day one
|
|
870
|
+
// and shipped nothing that used them, so an agent could publish to YouTube and then neither measure nor manage it.
|
|
871
|
+
// No reconnect needed — every connected user already granted these. See docs/mcp-connector-gap-map.md.
|
|
872
|
+
server.registerTool('youtube_video_insights', {
|
|
873
|
+
title: 'Performance of one of your YouTube videos',
|
|
874
|
+
description: 'Per-VIDEO performance for a video on the connected channel — views, estimated minutes watched, average view duration, average view PERCENTAGE (the retention number that tells you whether the hook held), likes, comments, shares and subscribers gained. Use it for "how did that video do", "which upload performed best", or to judge an ad before spending more behind it. youtube_channel only returns channel-wide totals and cannot answer this. Defaults to the last 28 days; pass startDate/endDate (YYYY-MM-DD) for another window. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
875
|
+
inputSchema: { videoId: z.string().describe('the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)'), startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago'), endDate: z.string().optional().describe('YYYY-MM-DD, default today') },
|
|
876
|
+
outputSchema: { videoId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), views: z.number().optional(), estimatedMinutesWatched: z.number().optional(), averageViewDuration: z.number().optional(), averageViewPercentage: z.number().optional(), likes: z.number().optional(), comments: z.number().optional(), shares: z.number().optional(), subscribersGained: z.number().optional(), url: z.string().optional() },
|
|
877
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
878
|
+
}, wrap(async (a) => {
|
|
879
|
+
const d = await apiGet('/api/youtube/video-insights', { videoId: a.videoId, ...(a.startDate ? { startDate: a.startDate } : {}), ...(a.endDate ? { endDate: a.endDate } : {}) });
|
|
880
|
+
return ok(`${d.views ?? 0} views, ${d.averageViewPercentage ?? 0}% average retention, ${d.estimatedMinutesWatched ?? 0} minutes watched (${d.startDate} → ${d.endDate}).`, d);
|
|
881
|
+
}));
|
|
882
|
+
server.registerTool('update_youtube_video', {
|
|
883
|
+
title: 'Update a YouTube video’s title, description, tags or privacy',
|
|
884
|
+
description: 'Edit an existing video on the connected channel: title, description, tags, and/or privacy (unlisted | public | private). THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC — post_to_youtube defaults to UNLISTED, and without this there was no way to publish it afterwards. Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched. Needs a connected YouTube channel.',
|
|
885
|
+
inputSchema: { videoId: z.string().describe('the YouTube video id'), title: z.string().optional().describe('≤100 chars'), description: z.string().optional().describe('≤5000 chars'), tags: z.array(z.string()).optional(), privacy: z.enum(['unlisted', 'public', 'private']).optional().describe('public = live on the channel; confirm with the user first') },
|
|
886
|
+
outputSchema: { videoId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), url: z.string().optional() },
|
|
887
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
888
|
+
}, wrap(async (a) => {
|
|
889
|
+
const d = await apiPost('/api/youtube/update-video', a);
|
|
890
|
+
return ok(`Updated — “${d.title}” is now ${d.privacy}. ${d.url}`, d);
|
|
891
|
+
}));
|
|
892
|
+
server.registerTool('list_youtube_comments', {
|
|
893
|
+
title: 'Read comments on one of your YouTube videos',
|
|
894
|
+
description: 'Read the comments under a video on the connected channel — the questions, objections and exact wording real viewers use. Same raw material for ad copy that list_meta_comments gives you on Meta. Returns author, text, like count, timestamp and reply count, newest first. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
895
|
+
inputSchema: { videoId: z.string().describe('the YouTube video id'), limit: z.number().optional().describe('max comments, default 25, cap 100') },
|
|
896
|
+
outputSchema: { videoId: z.string().optional(), count: z.number().optional(), comments: z.array(z.object({ id: z.string().optional(), author: z.string().optional(), text: z.string().optional(), likes: z.number().optional(), at: z.string().optional(), replies: z.number().optional() })).optional() },
|
|
897
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
898
|
+
}, wrap(async (a) => {
|
|
899
|
+
const d = await apiGet('/api/youtube/comments', { videoId: a.videoId, ...(a.limit ? { limit: a.limit } : {}) });
|
|
900
|
+
return ok(`${d.count} comment${d.count === 1 ? '' : 's'} on ${d.videoId}.`, d);
|
|
901
|
+
}));
|
|
902
|
+
server.registerTool('reply_to_youtube_comment', {
|
|
903
|
+
title: 'Reply to a YouTube comment',
|
|
904
|
+
description: 'Post a public reply to a comment on the connected channel, as the channel. This is PUBLIC and immediate — show the user the exact reply text and get an explicit yes before calling. Get commentId from list_youtube_comments. Needs a connected YouTube channel.',
|
|
905
|
+
inputSchema: { commentId: z.string().describe('id of the comment to reply to (from list_youtube_comments)'), text: z.string().describe('the reply, shown publicly under the video') },
|
|
906
|
+
outputSchema: { id: z.string().optional(), text: z.string().optional(), at: z.string().optional() },
|
|
907
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
908
|
+
}, wrap(async (a) => {
|
|
909
|
+
const d = await apiPost('/api/youtube/reply-comment', a);
|
|
910
|
+
return ok('Reply posted.', d);
|
|
911
|
+
}));
|
|
912
|
+
server.registerTool('tiktok_creator_info', {
|
|
913
|
+
title: 'Read the connected TikTok creator’s posting options',
|
|
914
|
+
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).',
|
|
915
|
+
inputSchema: {},
|
|
916
|
+
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() },
|
|
917
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
918
|
+
}, wrap(async () => {
|
|
919
|
+
const d = await apiGet('/api/tiktok/creator-info', {});
|
|
920
|
+
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);
|
|
921
|
+
}));
|
|
922
|
+
server.registerTool('post_to_tiktok', {
|
|
923
|
+
title: 'Post a video to TikTok',
|
|
924
|
+
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).',
|
|
925
|
+
inputSchema: {
|
|
926
|
+
videoUrl: z.string().describe('the video to post — a Hermoso render URL or an upload_file url'),
|
|
927
|
+
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".'),
|
|
928
|
+
title: z.string().optional().describe('the caption (≤2200 chars) — hashtags go here'),
|
|
929
|
+
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.'),
|
|
930
|
+
disableComment: z.boolean().optional(),
|
|
931
|
+
disableDuet: z.boolean().optional(),
|
|
932
|
+
disableStitch: z.boolean().optional(),
|
|
933
|
+
coverTimestampMs: z.number().optional().describe('which frame to use as the cover, in ms'),
|
|
934
|
+
brandedContent: z.boolean().optional().describe('discloses a paid partnership — cannot be combined with SELF_ONLY privacy'),
|
|
935
|
+
yourBrand: z.boolean().optional().describe('discloses that this promotes the creator’s own brand'),
|
|
936
|
+
},
|
|
937
|
+
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() },
|
|
938
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
939
|
+
}, wrap(async (a) => {
|
|
940
|
+
const d = await apiPost('/api/tiktok/post', a);
|
|
941
|
+
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);
|
|
942
|
+
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);
|
|
943
|
+
}));
|
|
944
|
+
server.registerTool('tiktok_account', {
|
|
945
|
+
title: 'Read the connected TikTok account',
|
|
946
|
+
description: 'Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).',
|
|
947
|
+
inputSchema: {},
|
|
948
|
+
outputSchema: { openId: z.string().nullable().optional(), displayName: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), bio: z.string().nullable().optional(), profileLink: z.string().nullable().optional(), verified: z.boolean().optional(), followers: z.number().nullable().optional(), following: z.number().nullable().optional(), likes: z.number().nullable().optional(), videos: z.number().nullable().optional(), partial: z.boolean().optional() },
|
|
949
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
950
|
+
}, wrap(async () => {
|
|
951
|
+
const d = await apiGet('/api/tiktok/me', { full: '1' });
|
|
952
|
+
const bits = [d.followers != null ? `${d.followers} followers` : null, d.likes != null ? `${d.likes} total likes` : null, d.videos != null ? `${d.videos} videos` : null].filter(Boolean);
|
|
953
|
+
return ok(`TikTok: ${d.displayName || d.username || '(unnamed)'}${d.username ? ` (@${d.username})` : ''}${d.verified ? ' — verified' : ''}. ${bits.join(' · ') || 'no public stats returned'}.${d.partial ? ' This connection predates the profile/stats permissions — reconnect TikTok to see followers and stats.' : ''}`, d);
|
|
954
|
+
}));
|
|
955
|
+
server.registerTool('list_tiktok_videos', {
|
|
956
|
+
title: 'List the connected account’s TikTok posts',
|
|
957
|
+
description: 'List the connected account’s own recent PUBLIC TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. Use it for “how did our last TikToks do”, “which of our videos performed best”, or to pick a reference before making a new ad. Only ever the connected user’s OWN videos. Read-only. Needs TikTok connected.',
|
|
958
|
+
inputSchema: { limit: z.number().optional().describe('1-20, default 10') },
|
|
959
|
+
outputSchema: { videos: z.array(z.object({ id: z.string().nullable().optional(), title: z.string().optional(), durationSeconds: z.number().nullable().optional(), cover: z.string().nullable().optional(), url: z.string().nullable().optional(), postedAt: z.string().nullable().optional(), views: z.number().nullable().optional(), likes: z.number().nullable().optional(), comments: z.number().nullable().optional(), shares: z.number().nullable().optional() })).optional(), cursor: z.number().nullable().optional(), hasMore: z.boolean().optional() },
|
|
960
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
961
|
+
}, wrap(async (a) => {
|
|
962
|
+
const d = await apiGet('/api/tiktok/videos', a);
|
|
963
|
+
const rows = (d.videos || []).map((v, i) => `${i + 1}. ${(v.title || '(no caption)').slice(0, 70)} — ${v.views ?? '?'} views, ${v.likes ?? '?'} likes${v.url ? ` — ${v.url}` : ''}`);
|
|
964
|
+
return ok(rows.length ? `${rows.length} recent TikTok post(s)${d.hasMore ? ' (more available)' : ''}:\n${rows.join('\n')}` : 'No public videos on that TikTok account yet.', d);
|
|
965
|
+
}));
|
|
836
966
|
server.registerTool('upload_meta_asset', {
|
|
837
967
|
title: 'Upload an asset to a Meta ad account',
|
|
838
968
|
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 +1009,126 @@ export function registerTools(server) {
|
|
|
879
1009
|
const d = await apiPost('/api/meta/campaign/status', a);
|
|
880
1010
|
return ok(d.note || `Campaign ${a.campaignId} → ${a.status}.`, d);
|
|
881
1011
|
}));
|
|
1012
|
+
// ── Meta ad-set targeting, shared by create_meta_ad and create_meta_adset. Ids come from find_meta_audiences —
|
|
1013
|
+
// interests/behaviours/cities/languages are all opaque on Meta, so never guess one.
|
|
1014
|
+
const metaGeoShape = z.object({
|
|
1015
|
+
countries: z.array(z.string()).optional().describe('2-letter codes, e.g. ["US","CA"]'),
|
|
1016
|
+
regions: z.array(z.object({ key: z.string() })).optional().describe('region KEYS from find_meta_audiences(type:"adgeolocation")'),
|
|
1017
|
+
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)'),
|
|
1018
|
+
zips: z.array(z.object({ key: z.string() })).optional(),
|
|
1019
|
+
geoMarkets: z.array(z.object({ key: z.string() })).optional().describe('DMA keys, e.g. {key:"DMA:807"}'),
|
|
1020
|
+
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'),
|
|
1021
|
+
locationTypes: z.array(z.enum(['home', 'recent', 'travel_in'])).optional().describe('people who LIVE there vs were recently there'),
|
|
1022
|
+
}).optional();
|
|
1023
|
+
const metaIdList = z.array(z.object({ id: z.string(), name: z.string().optional() })).optional();
|
|
1024
|
+
const metaTargetingShape = z.object({
|
|
1025
|
+
geo: metaGeoShape.describe('where the ad runs'),
|
|
1026
|
+
excludedGeo: metaGeoShape.describe('places to exclude'),
|
|
1027
|
+
ageMin: z.number().optional().describe('13–65'), ageMax: z.number().optional().describe('13–65 (65 means 65+)'),
|
|
1028
|
+
genders: z.enum(['all', 'men', 'women']).optional(),
|
|
1029
|
+
interests: metaIdList.describe('interest ids from find_meta_audiences(type:"adinterest")'),
|
|
1030
|
+
behaviors: metaIdList.describe('behaviour ids from find_meta_audiences(type:"adTargetingCategory", class:"behaviors")'),
|
|
1031
|
+
excludedInterests: metaIdList, excludedBehaviors: metaIdList,
|
|
1032
|
+
flexibleSpec: z.array(z.object({ interests: metaIdList, behaviors: metaIdList })).optional().describe('AND across entries, OR within one'),
|
|
1033
|
+
customAudiences: metaIdList.describe('saved audiences AND lookalikes — a lookalike IS a custom audience id'),
|
|
1034
|
+
excludedCustomAudiences: metaIdList,
|
|
1035
|
+
locales: z.array(z.number()).optional().describe('Meta language ids from find_meta_audiences(type:"adlocale")'),
|
|
1036
|
+
publisherPlatforms: z.array(z.enum(['facebook', 'instagram', 'audience_network', 'messenger', 'threads'])).optional(),
|
|
1037
|
+
facebookPositions: z.array(z.string()).optional().describe('feed, story, facebook_reels, marketplace, video_feeds, search, instream_video, right_hand_column, …'),
|
|
1038
|
+
instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, explore, profile_feed, …'),
|
|
1039
|
+
messengerPositions: z.array(z.string()).optional(), audienceNetworkPositions: z.array(z.string()).optional(),
|
|
1040
|
+
devicePlatforms: z.array(z.enum(['mobile', 'desktop'])).optional(), userOs: z.array(z.enum(['iOS', 'Android'])).optional(),
|
|
1041
|
+
advantageAudience: z.boolean().optional().describe('let Meta expand beyond your audience (Advantage+ audience)'),
|
|
1042
|
+
}).optional();
|
|
1043
|
+
const metaAdSetFields = {
|
|
1044
|
+
dailyBudgetUsd: z.number().optional().describe('ad-set daily budget USD (1–10000, default 10) — spends only once ACTIVE'),
|
|
1045
|
+
lifetimeBudgetUsd: z.number().optional().describe('a fixed total instead of a daily budget — REQUIRES endTime'),
|
|
1046
|
+
country: z.string().optional().describe('2-letter shorthand when you are not passing full targeting (default US)'),
|
|
1047
|
+
targeting: metaTargetingShape.describe('full Meta ad-set targeting — age, gender, geo, interests, behaviours, audiences, languages, placements, devices'),
|
|
1048
|
+
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'),
|
|
1049
|
+
conversionEvent: z.string().optional().describe('PURCHASE | LEAD | COMPLETE_REGISTRATION | ADD_TO_CART | INITIATED_CHECKOUT | …'),
|
|
1050
|
+
customConversionId: z.string().optional(),
|
|
1051
|
+
applicationId: z.string().optional().describe('app-promotion ads'), objectStoreUrl: z.string().optional(),
|
|
1052
|
+
optimizationGoal: z.string().optional().describe('override, e.g. OFFSITE_CONVERSIONS / LANDING_PAGE_VIEWS / THRUPLAY / VALUE'),
|
|
1053
|
+
billingEvent: z.string().optional().describe('default IMPRESSIONS'),
|
|
1054
|
+
bidStrategy: z.enum(['LOWEST_COST_WITHOUT_CAP', 'LOWEST_COST_WITH_BID_CAP', 'COST_CAP', 'LOWEST_COST_WITH_MIN_ROAS']).optional(),
|
|
1055
|
+
bidAmountUsd: z.number().optional().describe('REQUIRED for a bid cap / cost cap'),
|
|
1056
|
+
minRoas: z.number().optional().describe('REQUIRED for LOWEST_COST_WITH_MIN_ROAS, e.g. 1.1'),
|
|
1057
|
+
startTime: z.string().optional().describe('ISO-8601 with offset, e.g. 2026-08-01T09:00:00-0700'),
|
|
1058
|
+
endTime: z.string().optional().describe('REQUIRED with lifetimeBudgetUsd'),
|
|
1059
|
+
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'),
|
|
1060
|
+
attributionSpec: z.array(z.any()).optional().describe('e.g. [{event_type:"CLICK_THROUGH",window_days:7}]'),
|
|
1061
|
+
};
|
|
882
1062
|
server.registerTool('create_meta_ad', {
|
|
883
1063
|
title: 'Build a full Meta ad (campaign → ad set → ad, paused)',
|
|
884
|
-
description: 'Build a complete, ready-to-run Meta ad
|
|
1064
|
+
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
1065
|
inputSchema: {
|
|
886
1066
|
adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
|
|
1067
|
+
format: z.enum(['auto', 'carousel']).optional().describe('auto = one ad per asset (image or video); carousel = ONE multi-card ad'),
|
|
887
1068
|
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'),
|
|
1069
|
+
imageUrls: z.array(z.string()).optional().describe('several image URLs → one ad each, or the carousel cards in order'),
|
|
1070
|
+
videoUrl: z.string().optional().describe('a video URL → a real Meta VIDEO ad (uploaded + transcoded + thumbnailed for you)'),
|
|
1071
|
+
thumbnailUrl: z.string().optional().describe('custom video thumbnail (otherwise Meta picks a frame)'),
|
|
889
1072
|
message: z.string().optional().describe('primary ad text'),
|
|
890
|
-
headline: z.string().optional().describe('
|
|
1073
|
+
headline: z.string().optional().describe('headline'),
|
|
1074
|
+
description: z.string().optional().describe('the smaller description line under the headline'),
|
|
1075
|
+
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'),
|
|
1076
|
+
carouselEndCard: z.boolean().optional().describe('append the Page end card to a carousel'),
|
|
891
1077
|
link: z.string().optional().describe('destination URL (defaults to the brand domain)'),
|
|
892
1078
|
cta: z.string().optional().describe('call-to-action, e.g. SHOP_NOW / LEARN_MORE / SIGN_UP (default LEARN_MORE)'),
|
|
893
1079
|
objective: z.enum(['OUTCOME_TRAFFIC', 'OUTCOME_AWARENESS', 'OUTCOME_ENGAGEMENT', 'OUTCOME_LEADS', 'OUTCOME_SALES']).optional().describe('default OUTCOME_TRAFFIC'),
|
|
894
|
-
|
|
895
|
-
|
|
1080
|
+
...metaAdSetFields,
|
|
1081
|
+
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'),
|
|
1082
|
+
instagramUserId: z.string().optional().describe('run it on Instagram under the brand’s own handle'),
|
|
896
1083
|
name: z.string().optional().describe('base name for the campaign/ad set/ads'),
|
|
897
1084
|
campaignId: z.string().optional().describe('attach to an existing campaign instead of creating one'),
|
|
1085
|
+
adSetId: z.string().optional().describe('attach the ad(s) to an EXISTING ad set (skips ad-set creation)'),
|
|
898
1086
|
pageId: z.string().optional().describe('Page id from list_meta_pages; omit = first Page'),
|
|
899
1087
|
},
|
|
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() },
|
|
1088
|
+
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
1089
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
902
1090
|
}, wrap(async (a) => {
|
|
903
1091
|
const { imageUrls, ...rest } = a;
|
|
904
1092
|
const d = await apiPost('/api/meta/ad', imageUrls?.length ? { ...rest, urls: imageUrls } : rest);
|
|
905
|
-
|
|
1093
|
+
// The server READS THE ADS BACK from the Graph API and ships one honest sentence in d.summary — print that.
|
|
1094
|
+
// Never recompute a claim from d.count here: this twin used to narrate "Built a PAUSED campaign with N ad(s)"
|
|
1095
|
+
// straight from the POST responses, which is exactly how a user was told "1 image ad" for an empty account.
|
|
1096
|
+
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);
|
|
1097
|
+
}));
|
|
1098
|
+
server.registerTool('create_meta_adset', {
|
|
1099
|
+
title: 'Create a Meta ad set (audience + budget + schedule)',
|
|
1100
|
+
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:…).',
|
|
1101
|
+
inputSchema: {
|
|
1102
|
+
adAccountId: z.string().describe('ad account id (act_… or digits)'),
|
|
1103
|
+
campaignId: z.string().describe('the campaign this ad set belongs to'),
|
|
1104
|
+
name: z.string().optional().describe('ad set name'),
|
|
1105
|
+
...metaAdSetFields,
|
|
1106
|
+
pageId: z.string().optional().describe('Page id; omit = first Page'),
|
|
1107
|
+
},
|
|
1108
|
+
outputSchema: { ok: z.boolean().optional(), adSetId: z.string().optional(), campaignId: z.string().optional(), summary: z.string().optional() },
|
|
1109
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1110
|
+
}, wrap(async (a) => {
|
|
1111
|
+
const d = await apiPost('/api/meta/adset', a);
|
|
1112
|
+
return ok(d.summary || `Meta returned ad set ${d.adSetId} but no verified summary.`, d);
|
|
1113
|
+
}));
|
|
1114
|
+
server.registerTool('find_meta_audiences', {
|
|
1115
|
+
title: 'Look up Meta targeting ids',
|
|
1116
|
+
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.',
|
|
1117
|
+
inputSchema: {
|
|
1118
|
+
type: z.enum(['adinterest', 'adTargetingCategory', 'adgeolocation', 'adlocale', 'adcountry', 'adzipcode', 'adeducationschool', 'adeducationmajor', 'adworkemployer', 'adworkposition']).describe('what kind of targeting object to search'),
|
|
1119
|
+
q: z.string().optional().describe('what to search for'),
|
|
1120
|
+
class: z.string().optional().describe('for adTargetingCategory, e.g. "behaviors" or "interests"'),
|
|
1121
|
+
locationTypes: z.string().optional().describe('comma-separated: country,region,city,zip,geo_market'),
|
|
1122
|
+
countryCode: z.string().optional().describe('2-letter hint to disambiguate a city name'),
|
|
1123
|
+
adAccountId: z.string().optional().describe('search with that ad account’s token'),
|
|
1124
|
+
limit: z.number().optional(),
|
|
1125
|
+
},
|
|
1126
|
+
outputSchema: { count: z.number().optional(), results: z.array(z.any()).optional() },
|
|
1127
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1128
|
+
}, wrap(async (a) => {
|
|
1129
|
+
const d = await apiGet('/api/meta/targeting-search', a);
|
|
1130
|
+
if (!d.count) return ok(`Meta has no ${a.type} matching "${a.q || ''}". Try a broader word.`, d);
|
|
1131
|
+
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
1132
|
}));
|
|
907
1133
|
|
|
908
1134
|
// ---------- Meta: READ / MEASURE / EDIT / DELETE existing objects (drive a whole ad account, not just create) ----------
|
|
@@ -983,9 +1209,38 @@ export function registerTools(server) {
|
|
|
983
1209
|
const d = await apiPost('/api/google-ads/report', a);
|
|
984
1210
|
return ok(`${d.count} row(s) from Google Ads.`, d);
|
|
985
1211
|
}));
|
|
1212
|
+
// ── Google Ads: campaign → ad group → ad → keywords → targeting → bidding. Google's object graph REQUIRES all
|
|
1213
|
+
// three levels: a campaign alone can never serve an impression, so create_google_ads_campaign can build the
|
|
1214
|
+
// whole tree in ONE atomic mutate and the granular tools below fill in / edit an existing account.
|
|
1215
|
+
const gadsAdShape = {
|
|
1216
|
+
finalUrls: z.array(z.string()).optional().describe('the landing page(s) — at least one is required'),
|
|
1217
|
+
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).'),
|
|
1218
|
+
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.'),
|
|
1219
|
+
path1: z.string().optional().describe('SEARCH only — display-URL path segment, ≤15 chars'),
|
|
1220
|
+
path2: z.string().optional().describe('SEARCH only — second display-URL path segment, ≤15 chars'),
|
|
1221
|
+
longHeadline: z.string().optional().describe('DISPLAY only — ≤90 characters'),
|
|
1222
|
+
businessName: z.string().optional().describe('DISPLAY only — ≤25 characters'),
|
|
1223
|
+
marketingImages: z.array(z.string()).optional().describe('DISPLAY only — landscape 1.91:1 asset resource names from upload_google_ads_asset'),
|
|
1224
|
+
squareMarketingImages: z.array(z.string()).optional().describe('DISPLAY only — square 1:1 asset resource names'),
|
|
1225
|
+
logoImages: z.array(z.string()).optional().describe('DISPLAY only — logo asset resource names'),
|
|
1226
|
+
};
|
|
1227
|
+
const gadsKeywordShape = z.array(z.object({
|
|
1228
|
+
text: z.string().describe('≤80 characters, ≤10 words'),
|
|
1229
|
+
matchType: z.enum(['EXACT', 'PHRASE', 'BROAD']).optional().describe('default PHRASE'),
|
|
1230
|
+
negative: z.boolean().optional().describe('true = BLOCK this term instead of targeting it'),
|
|
1231
|
+
cpcBidUsd: z.number().optional().describe('per-keyword max CPC'),
|
|
1232
|
+
paused: z.boolean().optional(),
|
|
1233
|
+
}));
|
|
1234
|
+
const gadsBiddingShape = z.object({
|
|
1235
|
+
strategy: z.enum(['MANUAL_CPC', 'MAXIMIZE_CLICKS', 'MAXIMIZE_CONVERSIONS', 'MAXIMIZE_CONVERSION_VALUE', 'TARGET_CPA', 'TARGET_ROAS']),
|
|
1236
|
+
targetCpaUsd: z.number().optional().describe('REQUIRED for TARGET_CPA — cost per conversion you will pay'),
|
|
1237
|
+
targetRoas: z.number().optional().describe('REQUIRED for TARGET_ROAS — e.g. 4 = $4 revenue per $1 spent'),
|
|
1238
|
+
maxCpcUsd: z.number().optional().describe('MAXIMIZE_CLICKS only — optional max CPC ceiling'),
|
|
1239
|
+
enhancedCpc: z.boolean().optional().describe('MANUAL_CPC only'),
|
|
1240
|
+
}).optional();
|
|
986
1241
|
server.registerTool('create_google_ads_campaign', {
|
|
987
|
-
title: '
|
|
988
|
-
description: '
|
|
1242
|
+
title: 'Build a Google Ads campaign (paused)',
|
|
1243
|
+
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
1244
|
inputSchema: {
|
|
990
1245
|
customerId: z.string().optional().describe('10-digit account id (from list_google_ads_campaigns) — omit to use the brand’s selected default account'),
|
|
991
1246
|
name: z.string().describe('campaign name'),
|
|
@@ -993,14 +1248,136 @@ export function registerTools(server) {
|
|
|
993
1248
|
budgetResourceName: z.string().optional().describe('reuse an existing budget instead of creating one'),
|
|
994
1249
|
channelType: z.enum(['SEARCH', 'DISPLAY']).optional().describe('default SEARCH'),
|
|
995
1250
|
searchPartners: z.boolean().optional().describe('SEARCH only — also serve on Google search partners (default false)'),
|
|
996
|
-
|
|
1251
|
+
bidding: gadsBiddingShape.describe('how the campaign bids — default MANUAL_CPC'),
|
|
1252
|
+
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'),
|
|
1253
|
+
excludedLocations: z.array(z.string()).optional().describe('location names to EXCLUDE'),
|
|
1254
|
+
languages: z.array(z.string()).optional().describe('ISO language codes, e.g. ["en","fr"]'),
|
|
1255
|
+
startDate: z.string().optional().describe('YYYY-MM-DD'),
|
|
1256
|
+
endDate: z.string().optional().describe('YYYY-MM-DD'),
|
|
1257
|
+
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'),
|
|
1258
|
+
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'),
|
|
1259
|
+
dryRun: z.boolean().optional().describe('validate the WHOLE tree against Google without creating anything. Nothing is written and no budget is consumed'),
|
|
997
1260
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
998
1261
|
},
|
|
999
|
-
outputSchema: { ok: z.boolean().optional(), campaignResourceName: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), budgetResourceName: z.string().optional() },
|
|
1262
|
+
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
1263
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1001
1264
|
}, wrap(async (a) => {
|
|
1002
1265
|
const d = await apiPost('/api/google-ads/campaign', a);
|
|
1003
|
-
|
|
1266
|
+
// d.note is written from a READ-BACK of the whole tree (the route 502s rather than returning an unverified id),
|
|
1267
|
+
// and it states outright whether the campaign can serve. Print it; never re-assert "Created campaign X" here.
|
|
1268
|
+
return ok(`${d.dryRun ? d.note : `${d.note} To make it spend, use set_google_ads_status(confirm:true) after the user approves.`}`, d);
|
|
1269
|
+
}));
|
|
1270
|
+
server.registerTool('create_google_ads_ad_group', {
|
|
1271
|
+
title: 'Add an ad group to a Google Ads campaign',
|
|
1272
|
+
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.',
|
|
1273
|
+
inputSchema: {
|
|
1274
|
+
customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
|
|
1275
|
+
campaignId: z.string().describe('the campaign this ad group belongs to'),
|
|
1276
|
+
name: z.string().describe('ad group name'),
|
|
1277
|
+
cpcBidUsd: z.number().optional().describe('max CPC for this ad group — omit to inherit the campaign bidding'),
|
|
1278
|
+
status: z.enum(['ENABLED', 'PAUSED']).optional().describe('default PAUSED'),
|
|
1279
|
+
ad: z.object(gadsAdShape).optional().describe('build the ad in the same atomic call'),
|
|
1280
|
+
keywords: gadsKeywordShape.optional().describe('a SEARCH ad group with no keywords never shows'),
|
|
1281
|
+
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'),
|
|
1282
|
+
dryRun: z.boolean().optional(),
|
|
1283
|
+
loginCustomerId: z.string().optional(),
|
|
1284
|
+
},
|
|
1285
|
+
outputSchema: { ok: z.boolean().optional(), adGroupId: z.string().optional(), adGroupResourceName: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
1286
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1287
|
+
}, wrap(async (a) => {
|
|
1288
|
+
const d = await apiPost('/api/google-ads/ad-group', a);
|
|
1289
|
+
return ok(d.note, d);
|
|
1290
|
+
}));
|
|
1291
|
+
server.registerTool('create_google_ads_ad', {
|
|
1292
|
+
title: 'Create a Google Ads ad',
|
|
1293
|
+
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.',
|
|
1294
|
+
inputSchema: {
|
|
1295
|
+
customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
|
|
1296
|
+
adGroupId: z.string().describe('the ad group this ad lives in'),
|
|
1297
|
+
...gadsAdShape,
|
|
1298
|
+
status: z.enum(['ENABLED', 'PAUSED']).optional().describe('default PAUSED'),
|
|
1299
|
+
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'),
|
|
1300
|
+
dryRun: z.boolean().optional(),
|
|
1301
|
+
loginCustomerId: z.string().optional(),
|
|
1302
|
+
},
|
|
1303
|
+
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() },
|
|
1304
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1305
|
+
}, wrap(async (a) => {
|
|
1306
|
+
const d = await apiPost('/api/google-ads/ad', a);
|
|
1307
|
+
return ok(d.note, d);
|
|
1308
|
+
}));
|
|
1309
|
+
server.registerTool('add_google_ads_keywords', {
|
|
1310
|
+
title: 'Add Google Ads keywords',
|
|
1311
|
+
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.',
|
|
1312
|
+
inputSchema: {
|
|
1313
|
+
customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
|
|
1314
|
+
adGroupId: z.string().describe('the ad group to add them to'),
|
|
1315
|
+
keywords: gadsKeywordShape.describe('the keywords to add'),
|
|
1316
|
+
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'),
|
|
1317
|
+
dryRun: z.boolean().optional(),
|
|
1318
|
+
loginCustomerId: z.string().optional(),
|
|
1319
|
+
},
|
|
1320
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), adGroupId: z.string().optional(), note: z.string().optional() },
|
|
1321
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1322
|
+
}, wrap(async (a) => {
|
|
1323
|
+
const d = await apiPost('/api/google-ads/keywords', a);
|
|
1324
|
+
return ok(d.note, d);
|
|
1325
|
+
}));
|
|
1326
|
+
server.registerTool('set_google_ads_targeting', {
|
|
1327
|
+
title: 'Set Google Ads location & language targeting',
|
|
1328
|
+
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.',
|
|
1329
|
+
inputSchema: {
|
|
1330
|
+
customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
|
|
1331
|
+
campaignId: z.string().describe('the campaign to target'),
|
|
1332
|
+
locations: z.array(z.string()).optional().describe('location NAMES to target'),
|
|
1333
|
+
excludedLocations: z.array(z.string()).optional().describe('location NAMES to exclude'),
|
|
1334
|
+
languages: z.array(z.string()).optional().describe('ISO language codes, e.g. ["en","es"]'),
|
|
1335
|
+
countryCode: z.string().optional().describe('2-letter hint to disambiguate a city name, e.g. CA for "London"'),
|
|
1336
|
+
confirm: z.boolean().optional().describe('REQUIRED true to change a LIVE (ENABLED) campaign'),
|
|
1337
|
+
dryRun: z.boolean().optional(),
|
|
1338
|
+
loginCustomerId: z.string().optional(),
|
|
1339
|
+
},
|
|
1340
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), note: z.string().optional() },
|
|
1341
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1342
|
+
}, wrap(async (a) => {
|
|
1343
|
+
const d = await apiPost('/api/google-ads/targeting', a);
|
|
1344
|
+
return ok(d.note, d);
|
|
1345
|
+
}));
|
|
1346
|
+
server.registerTool('set_google_ads_bidding', {
|
|
1347
|
+
title: 'Set a Google Ads bidding strategy',
|
|
1348
|
+
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.',
|
|
1349
|
+
inputSchema: {
|
|
1350
|
+
customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
|
|
1351
|
+
campaignId: z.string().describe('the campaign to change'),
|
|
1352
|
+
strategy: z.enum(['MANUAL_CPC', 'MAXIMIZE_CLICKS', 'MAXIMIZE_CONVERSIONS', 'MAXIMIZE_CONVERSION_VALUE', 'TARGET_CPA', 'TARGET_ROAS']).describe('the bidding strategy'),
|
|
1353
|
+
targetCpaUsd: z.number().optional().describe('REQUIRED for TARGET_CPA'),
|
|
1354
|
+
targetRoas: z.number().optional().describe('REQUIRED for TARGET_ROAS — e.g. 4 = $4 revenue per $1 spent'),
|
|
1355
|
+
maxCpcUsd: z.number().optional().describe('MAXIMIZE_CLICKS — the max CPC ceiling; REQUIRED when switching an existing campaign to it'),
|
|
1356
|
+
enhancedCpc: z.boolean().optional().describe('MANUAL_CPC only'),
|
|
1357
|
+
confirm: z.boolean().optional().describe('REQUIRED true to change a LIVE (ENABLED) campaign'),
|
|
1358
|
+
dryRun: z.boolean().optional(),
|
|
1359
|
+
loginCustomerId: z.string().optional(),
|
|
1360
|
+
},
|
|
1361
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), biddingStrategyType: z.string().optional(), note: z.string().optional() },
|
|
1362
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1363
|
+
}, wrap(async (a) => {
|
|
1364
|
+
const d = await apiPost('/api/google-ads/bidding', a);
|
|
1365
|
+
return ok(d.note, d);
|
|
1366
|
+
}));
|
|
1367
|
+
server.registerTool('find_google_ads_locations', {
|
|
1368
|
+
title: 'Look up Google Ads locations',
|
|
1369
|
+
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.',
|
|
1370
|
+
inputSchema: {
|
|
1371
|
+
query: z.string().describe('one location name, or several comma-separated (up to 25)'),
|
|
1372
|
+
countryCode: z.string().optional().describe('2-letter hint, e.g. CA to disambiguate "London"'),
|
|
1373
|
+
loginCustomerId: z.string().optional(),
|
|
1374
|
+
},
|
|
1375
|
+
outputSchema: { count: z.number().optional(), locations: z.array(z.any()).optional() },
|
|
1376
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1377
|
+
}, wrap(async (a) => {
|
|
1378
|
+
const d = await apiGet('/api/google-ads/locations', a);
|
|
1379
|
+
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);
|
|
1380
|
+
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
1381
|
}));
|
|
1005
1382
|
server.registerTool('set_google_ads_budget', {
|
|
1006
1383
|
title: 'Set a Google Ads campaign budget',
|
|
@@ -1020,21 +1397,26 @@ export function registerTools(server) {
|
|
|
1020
1397
|
return ok(`Budget set to $${d.dailyBudgetUsd}/day (${d.budgetResourceName}).`, d);
|
|
1021
1398
|
}));
|
|
1022
1399
|
server.registerTool('set_google_ads_status', {
|
|
1023
|
-
title: 'Enable or
|
|
1024
|
-
description: 'Turn a campaign ON (ENABLED)
|
|
1400
|
+
title: 'Enable, pause or remove a Google Ads campaign / ad group / ad',
|
|
1401
|
+
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
1402
|
inputSchema: {
|
|
1026
1403
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1404
|
+
level: z.enum(['campaign', 'adGroup', 'ad']).optional().describe('what to change — default campaign'),
|
|
1405
|
+
campaignId: z.string().optional().describe('campaign id (level:"campaign")'),
|
|
1406
|
+
adGroupId: z.string().optional().describe('ad group id (level:"adGroup", or with adId for level:"ad")'),
|
|
1407
|
+
adId: z.string().optional().describe('ad id (level:"ad" — pass adGroupId too)'),
|
|
1408
|
+
campaignResourceName: z.string().optional().describe('full resource name, e.g. customers/{cid}/campaigns/{id}'),
|
|
1409
|
+
status: z.enum(['ENABLED', 'PAUSED', 'REMOVED']).describe('ENABLED = start spending; PAUSED = stop; REMOVED = permanent'),
|
|
1410
|
+
confirm: z.boolean().optional().describe('REQUIRED true to ENABLE (real spend) or to REMOVE (permanent)'),
|
|
1031
1411
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
1032
1412
|
},
|
|
1033
|
-
outputSchema: { ok: z.boolean().optional(),
|
|
1413
|
+
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
1414
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1035
1415
|
}, wrap(async (a) => {
|
|
1036
1416
|
const d = await apiPost('/api/google-ads/status', a);
|
|
1037
|
-
|
|
1417
|
+
// d.note is written from the READ-BACK and says so when Google reports a status different to the one we asked
|
|
1418
|
+
// for — print it rather than re-asserting `a.status`, which would be a claim about the request, not the account.
|
|
1419
|
+
return ok(d.note || `${d.level || 'campaign'} → ${d.verifiedStatus || a.status}.`, d);
|
|
1038
1420
|
}));
|
|
1039
1421
|
server.registerTool('upload_google_ads_asset', {
|
|
1040
1422
|
title: 'Upload a creative to Google Ads',
|
|
@@ -1388,7 +1770,17 @@ export function registerTools(server) {
|
|
|
1388
1770
|
},
|
|
1389
1771
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1390
1772
|
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
|
|
1391
|
-
|
|
1773
|
+
let brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
|
|
1774
|
+
// A BARE STRING brand name used to become the literal object {name:"Fly By Jing"} — no domain, no productImages —
|
|
1775
|
+
// and because an EXPLICIT brand suppresses hydrateAgentContext, that stripped-down object then got stamped onto
|
|
1776
|
+
// creative.brand (below) and preferred by /api/render/assemble over the workspace brand. Net: naming your own
|
|
1777
|
+
// saved brand as a string silently threw away its domain, logo and every product photo, and the render invented
|
|
1778
|
+
// the packaging. Re-attach the SAVED brand when the string names it (normalized compare) — a DIFFERENT brand name
|
|
1779
|
+
// still falls through untouched, so the 2026-07-17 multi-brand contamination fix stands.
|
|
1780
|
+
if (typeof brand === 'string' && brand.trim()) {
|
|
1781
|
+
const _n = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
1782
|
+
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 {}
|
|
1783
|
+
}
|
|
1392
1784
|
const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
|
|
1393
1785
|
const c = d.creative || d;
|
|
1394
1786
|
// 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 +1871,11 @@ export function registerTools(server) {
|
|
|
1479
1871
|
lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
|
|
1480
1872
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
1481
1873
|
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'),
|
|
1874
|
+
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
1875
|
},
|
|
1483
1876
|
outputSchema: {
|
|
1484
1877
|
...JOB_OUT,
|
|
1878
|
+
needsProductPhoto: z.boolean().optional().describe('true when nothing was rendered because the ad features a product this brand has no photo of'),
|
|
1485
1879
|
dryRun: z.boolean().optional().describe('true when this was a dry run (no job submitted, nothing charged)'),
|
|
1486
1880
|
jobType: z.string().optional().describe("the routing decision — 'video' (single pass) or 'stitch' (acts)"),
|
|
1487
1881
|
input: z.any().optional().describe('the assembled render input (dry run only — resolved model, duration, scenes)'),
|
|
@@ -1489,11 +1883,21 @@ export function registerTools(server) {
|
|
|
1489
1883
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
1490
1884
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your video ad…', 'Video ad ready'),
|
|
1491
1885
|
}, 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
|
|
1886
|
+
const { input, jobType, notes, needsProductPhoto } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
|
|
1493
1887
|
// LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
|
|
1494
1888
|
// (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
|
|
1495
1889
|
// acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
|
|
1496
1890
|
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 });
|
|
1891
|
+
// ASK BEFORE SPENDING (Dave 2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
|
|
1892
|
+
// spent"). `notes` alone was not enough here: on the real path it only reaches the model AFTER renderJob has
|
|
1893
|
+
// polled to completion, i.e. after the credits are gone. So when the ad features a product this brand has no
|
|
1894
|
+
// photo of, STOP and say so — the same honesty contract as templateGapMessage: nothing was rendered, nothing was
|
|
1895
|
+
// charged, tell the user exactly what is missing and how to fix it. NOT a permanent block: the user can supply a
|
|
1896
|
+
// photo, or the caller re-calls with allowGenericProduct:true once they have actually said they are fine with a
|
|
1897
|
+
// stand-in. (dryRun already returns before this — it charges nothing either way.)
|
|
1898
|
+
if (needsProductPhoto && !a.allowGenericProduct) {
|
|
1899
|
+
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' });
|
|
1900
|
+
}
|
|
1497
1901
|
const r = await renderJob(jobType === 'stitch' ? 'stitch' : 'video', input, 'MCP ad render');
|
|
1498
1902
|
return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]\n${notes || ''}`, r);
|
|
1499
1903
|
}));
|
|
@@ -1501,7 +1905,7 @@ export function registerTools(server) {
|
|
|
1501
1905
|
|
|
1502
1906
|
server.registerTool('make_template_ad', {
|
|
1503
1907
|
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.",
|
|
1908
|
+
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
1909
|
inputSchema: {
|
|
1506
1910
|
config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
|
|
1507
1911
|
},
|
|
@@ -1590,6 +1994,83 @@ export function registerTools(server) {
|
|
|
1590
1994
|
return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
|
|
1591
1995
|
}));
|
|
1592
1996
|
|
|
1997
|
+
// ── THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for
|
|
1998
|
+
// months but was reachable ONLY from the web app (the + menu modals and the client-side sizzle router) — zero tools
|
|
1999
|
+
// on either surface, so no agent could touch them. These expose them 1:1; the app keeps its own entry points.
|
|
2000
|
+
// The credit figures below are the reserve HOLD the server itself publishes at GET /api/generate/status
|
|
2001
|
+
// (clipCredits = quoteCredits(0.06) = 7, explainCredits = quoteCredits(0.30) = 31) and, for the sizzle, its one paid
|
|
2002
|
+
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
2003
|
+
server.registerTool('clip_video', {
|
|
2004
|
+
title: 'Clip a long video',
|
|
2005
|
+
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.",
|
|
2006
|
+
inputSchema: {
|
|
2007
|
+
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'),
|
|
2008
|
+
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
2009
|
+
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"),
|
|
2010
|
+
},
|
|
2011
|
+
outputSchema: { ...JOB_OUT },
|
|
2012
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2013
|
+
}, wrap(async (a) => {
|
|
2014
|
+
const r = await renderJob('clipper', { video: a.video, count: a.count, aspectRatio: a.aspectRatio }, 'MCP clipper');
|
|
2015
|
+
if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the clips when it lands
|
|
2016
|
+
const clips = Array.isArray(r?.raw?.clips) ? r.raw.clips : [];
|
|
2017
|
+
if (!clips.length) return ok(`That video produced no clips [job ${r.jobId}]`, r);
|
|
2018
|
+
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}` : ''}`);
|
|
2019
|
+
// Say it out loud when the ~40-minute transcription ceiling cut the source short — page ingest makes 60-90min
|
|
2020
|
+
// podcasts routine, and silently clipping only the first stretch reads as "it missed the best part".
|
|
2021
|
+
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.` : '';
|
|
2022
|
+
return ok(`Cut ${clips.length} ranked clip${clips.length === 1 ? '' : 's'} [job ${r.jobId}]:\n${lines.join('\n')}${trunc}`, r);
|
|
2023
|
+
}));
|
|
2024
|
+
|
|
2025
|
+
server.registerTool('make_explainer', {
|
|
2026
|
+
title: 'Make an explainer video',
|
|
2027
|
+
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.",
|
|
2028
|
+
inputSchema: {
|
|
2029
|
+
topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
|
|
2030
|
+
durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
|
|
2031
|
+
aspectRatio: z.enum(['9:16', '16:9', '1:1', '4:5', '3:4']).optional().describe("'9:16' default"),
|
|
2032
|
+
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)."),
|
|
2033
|
+
voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
|
|
2034
|
+
captions: z.boolean().optional().describe('burn on-screen text (default true)'),
|
|
2035
|
+
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.'),
|
|
2036
|
+
endCard: z.boolean().optional().describe('append the branded end card (default true)'),
|
|
2037
|
+
brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
|
|
2038
|
+
},
|
|
2039
|
+
outputSchema: { ...JOB_OUT },
|
|
2040
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2041
|
+
}, wrap(async (a) => {
|
|
2042
|
+
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');
|
|
2043
|
+
const d = r?.raw || {};
|
|
2044
|
+
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);
|
|
2045
|
+
}));
|
|
2046
|
+
|
|
2047
|
+
server.registerTool('product_sizzle', {
|
|
2048
|
+
title: 'Product sizzle (music-led)',
|
|
2049
|
+
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.",
|
|
2050
|
+
inputSchema: {
|
|
2051
|
+
prompt: z.string().describe('what the sizzle should show — the product, the setting, the look'),
|
|
2052
|
+
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'),
|
|
2053
|
+
refImage: z.string().optional().describe('product packshot URL that anchors the real label — strongly recommended'),
|
|
2054
|
+
aspectRatio: z.string().optional().describe("'9:16' default; anything the seedance-2 catalog entry does not list falls back to 9:16"),
|
|
2055
|
+
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"),
|
|
2056
|
+
specs: z.array(z.string()).optional().describe('up to 4 spec lines for the typeset cards, ≤26 chars each'),
|
|
2057
|
+
cta: z.string().optional().describe('closing CTA line, ≤30 chars'),
|
|
2058
|
+
brandName: z.string().optional().describe('brand name on the cards — defaults to the workspace brand'),
|
|
2059
|
+
musicMood: z.string().optional().describe('music-bed mood, e.g. driving / cinematic / upbeat'),
|
|
2060
|
+
},
|
|
2061
|
+
outputSchema: { ...JOB_OUT },
|
|
2062
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2063
|
+
}, wrap(async (a) => {
|
|
2064
|
+
let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // the cards want the REAL palette/logo, same as post_edit
|
|
2065
|
+
const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
|
|
2066
|
+
const r = await renderJob('hypermotion', {
|
|
2067
|
+
prompt: a.prompt, seconds: a.seconds, refImage: a.refImage, aspectRatio: a.aspectRatio, resolution: a.resolution, musicMood: a.musicMood,
|
|
2068
|
+
cardCopy: { specs: a.specs, cta: a.cta || b.cta || '' },
|
|
2069
|
+
brand: { name: a.brandName || b.name || '', domain: b.domain || '', logo: b.logo || '', palette: pal },
|
|
2070
|
+
}, 'MCP product sizzle');
|
|
2071
|
+
return okVideo(`Product sizzle ready: ${r.url} [job ${r.jobId}]`, r);
|
|
2072
|
+
}));
|
|
2073
|
+
|
|
1593
2074
|
server.registerTool('generate_video', {
|
|
1594
2075
|
title: 'Generate video',
|
|
1595
2076
|
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 +2174,8 @@ export function registerTools(server) {
|
|
|
1693
2174
|
}));
|
|
1694
2175
|
|
|
1695
2176
|
// ---------- skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles) ----------
|
|
2177
|
+
// The bundle dirs/content may still carry the pre-rename brand — always serve them under the product name.
|
|
2178
|
+
const brandSkillText = (s) => String(s).replace(/HEIST_/g, 'HERMOSO_').replace(/heist-/g, 'hermoso-').replace(/Hermoso/g, 'Hermoso').replace(/\bheist\b/g, 'hermoso');
|
|
1696
2179
|
server.registerTool('list_skills', {
|
|
1697
2180
|
title: 'List skills',
|
|
1698
2181
|
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 +2195,7 @@ export function registerTools(server) {
|
|
|
1712
2195
|
try {
|
|
1713
2196
|
const md = await readFile(new URL(`../skills/${n}/SKILL.md`, import.meta.url), 'utf8');
|
|
1714
2197
|
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 };
|
|
2198
|
+
return { name: brandSkillText(n), description: brandSkillText(desc) }; // legacy-named bundles surface under the product name
|
|
1716
2199
|
} catch { return null; }
|
|
1717
2200
|
}))).filter(Boolean);
|
|
1718
2201
|
} catch {}
|
|
@@ -1735,9 +2218,10 @@ export function registerTools(server) {
|
|
|
1735
2218
|
}, wrap(async ({ name }) => {
|
|
1736
2219
|
const safe = String(name).replace(/[^a-z0-9-]/gi, '');
|
|
1737
2220
|
const { readFile } = await import('node:fs/promises');
|
|
1738
|
-
const
|
|
2221
|
+
const tryRead = (n) => readFile(new URL(`../skills/${n}/SKILL.md`, import.meta.url), 'utf8').catch(() => null);
|
|
2222
|
+
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
2223
|
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 });
|
|
2224
|
+
return ok(brandSkillText(md.slice(0, 24000)), { name: safe });
|
|
1741
2225
|
}));
|
|
1742
2226
|
|
|
1743
2227
|
// ---------- workspace management: Memory / Skills / Employees / Brand / Connectors / Team / raw store (r-m-w over the store seam) ----------
|
|
@@ -1994,7 +2478,7 @@ export function registerTools(server) {
|
|
|
1994
2478
|
title: 'Find competitors',
|
|
1995
2479
|
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
2480
|
inputSchema: {
|
|
1997
|
-
domain: z.string().describe('the brand domain, e.g.
|
|
2481
|
+
domain: z.string().describe('the brand domain, e.g. flourish.com'),
|
|
1998
2482
|
mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
|
|
1999
2483
|
},
|
|
2000
2484
|
outputSchema: {
|
|
@@ -2373,6 +2857,31 @@ export function registerTools(server) {
|
|
|
2373
2857
|
}, wrap(async ({ save, ...a }) => {
|
|
2374
2858
|
const d = await apiPost('/api/brand/draft', a);
|
|
2375
2859
|
const p = d.profile || d;
|
|
2860
|
+
// ALWAYS TRY THE WEBSITE (Dave 2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
|
|
2861
|
+
// product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
|
|
2862
|
+
// later plan_ad/render_ad on it invented the packaging. This tool's own outputSchema has advertised `logo`,
|
|
2863
|
+
// `products` and `productImages` since it shipped; nothing ever filled them. Pull them from the SAME endpoint the
|
|
2864
|
+
// web onboarding uses (Shopify/JSON-LD catalog → scrape → fail-closed vision gate → durable persisted URLs) rather
|
|
2865
|
+
// than growing a second, drift-prone extractor. Best-effort: a slow/blocked/anti-bot site must still return the
|
|
2866
|
+
// drafted profile, so every failure degrades to "no photos", never to a failed draft.
|
|
2867
|
+
// GATED ON physical_product, exactly like the web path (public/app.js writes productImages/product only when
|
|
2868
|
+
// `pp`). Ungated, a SERVICE or APP brand acquired a "product library" from its own og:image — and brandContext
|
|
2869
|
+
// would then print "SERVICE BUSINESS — there is NO physical product. NEVER invent a box, bottle, package…"
|
|
2870
|
+
// directly above a populated photo library, while assembleAdRender attached that image to every render.
|
|
2871
|
+
if (p && p.domain && p.physical_product !== false) {
|
|
2872
|
+
try {
|
|
2873
|
+
const site = await apiGet('/api/site/images', { url: p.domain });
|
|
2874
|
+
const imgs = [...(site?.images || [])].filter(Boolean);
|
|
2875
|
+
if (imgs.length) { p.productImages = imgs.slice(0, 12); if (!p.product) p.product = imgs[0]; }
|
|
2876
|
+
// products is an array of product-NAME STRINGS everywhere else (public/app.js writes `_prodNames`), and
|
|
2877
|
+
// brandContext joins it straight into "use these EXACT names". /api/site/images returns {title,image}
|
|
2878
|
+
// OBJECTS, so storing them raw printed "[object Object], [object Object]" into every subsequent plan —
|
|
2879
|
+
// strictly worse than the empty line it replaced. Map to titles.
|
|
2880
|
+
const names = (Array.isArray(site?.products) ? site.products : []).map(x => String(x?.title || x || '').trim()).filter(Boolean);
|
|
2881
|
+
if (names.length) p.products = [...new Set(names)].slice(0, 12);
|
|
2882
|
+
if (!p.logo && site?.logo) p.logo = site.logo;
|
|
2883
|
+
} catch {}
|
|
2884
|
+
}
|
|
2376
2885
|
let saved = false;
|
|
2377
2886
|
if (save !== false) {
|
|
2378
2887
|
try {
|
|
@@ -2492,16 +3001,19 @@ export function registerTools(server) {
|
|
|
2492
3001
|
|
|
2493
3002
|
server.registerTool('dub_video', {
|
|
2494
3003
|
title: 'Dub video',
|
|
2495
|
-
description: "
|
|
3004
|
+
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
3005
|
inputSchema: {
|
|
2497
3006
|
video: z.string().describe('the source video URL'),
|
|
2498
3007
|
language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
|
|
2499
|
-
script: z.string().optional().describe('the original spoken
|
|
3008
|
+
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.'),
|
|
3009
|
+
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
3010
|
},
|
|
2501
3011
|
outputSchema: { ...JOB_OUT },
|
|
2502
3012
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2503
|
-
}, wrap(async ({ video, language, script }) => {
|
|
2504
|
-
|
|
3013
|
+
}, wrap(async ({ video, language, script, voice }) => {
|
|
3014
|
+
// Forward `script` ONLY when the caller actually supplied one. Sending '' used to hit the worker's
|
|
3015
|
+
// empty-script guard, so the documented {video, language} call could never succeed.
|
|
3016
|
+
const r = await renderJob('dub', { video, language, ...(String(script || '').trim() ? { script } : {}), ...(voice ? { voice } : {}) }, `Dub → ${language}`);
|
|
2505
3017
|
return okVideo(`Localized video (${language}): ${r.url}`, r);
|
|
2506
3018
|
}));
|
|
2507
3019
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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
|
|
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"
|