hermoso 0.1.525 → 0.1.527
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/http.mjs +11 -2
- package/mcp/tools.mjs +99 -54
- package/package.json +1 -1
package/mcp/http.mjs
CHANGED
|
@@ -156,10 +156,19 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
156
156
|
// Unbounded, "one host holds too many streams" saturates the instance for every other caller.
|
|
157
157
|
// The GET stream is optional in the spec (a server may answer 405 or close it at any time; clients reconnect),
|
|
158
158
|
// and this server sends nothing on it but a one-shot tools/list_changed nudge. So: a stream lives at most
|
|
159
|
-
// MCP_GET_STREAM_MS (
|
|
159
|
+
// MCP_GET_STREAM_MS (30s, env-tunable — see below) and the instance holds at most MCP_GET_STREAM_MAX of them at once —
|
|
160
160
|
// past that a GET is answered 503 + Retry-After, which costs the client a reconnect and costs tool calls nothing
|
|
161
161
|
// (they are POSTs and never touch this counter). Pinned by tools/mcp-get-stream-cap-check.mjs.
|
|
162
|
-
|
|
162
|
+
// ── 30 SECONDS, NOT 5 MINUTES: AN OPEN STREAM IS WHAT KEPT PROD DOWN WHILE CLOUD RUN SWAPPED ITS ONE INSTANCE (2026-11-07) ──
|
|
163
|
+
// Cloud Run sometimes replaces the serving revision's single min instance. It stops routing to the old instance,
|
|
164
|
+
// then waits for that instance's in-flight requests to finish before it sends SIGTERM — and with maxScale=1 the
|
|
165
|
+
// replacement cannot start until the old one is gone. On 2026-11-07 the old instances' last requests were
|
|
166
|
+
// these streams, and the SIGTERMs (11:20:58, 11:21:56) landed on the second each stream hit its 300s cap. That
|
|
167
|
+
// was 385 refused requests and ~5 minutes of 429s. Clients reopen the stream within a few seconds once it ends
|
|
168
|
+
// (measured over 2h: copilot-cli and python-httpx reopen the GET in under 5s, they do not re-initialize), and
|
|
169
|
+
// nothing but a one-shot list_changed nudge 2.5s after initialize is ever sent on it, so a short life costs a
|
|
170
|
+
// reconnect and bounds that outage to ~30s plus a boot. docs/knowledge/platform-core.md has the measurement.
|
|
171
|
+
const GET_STREAM_MS = Math.max(1000, Number(process.env.MCP_GET_STREAM_MS || 30e3));
|
|
163
172
|
const MCP_CALL_WALL_MS = Number(process.env.MCP_CALL_WALL_MS) || 10 * 60 * 1000; // a tools/call still open after this is ended, so a ghost cannot pin a deploy or a slot
|
|
164
173
|
const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [body]; return msgs.map(m => m?.method === 'tools/call' ? String(m.params?.name || '?').slice(0, 60) : '').filter(Boolean).join(',') || '?'; };
|
|
165
174
|
// A JSON-RPC id as the SDK's own Map sees it: 7 and "7" are different keys there, so they are different here.
|
package/mcp/tools.mjs
CHANGED
|
@@ -362,16 +362,16 @@ export const MCP_INSTRUCTIONS = [
|
|
|
362
362
|
'• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
|
|
363
363
|
'• CREATE (on-brand ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
364
364
|
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_alert_settings / set_alert_events / send_test_alert (where alerts go: Slack, Discord or a signed webhook); list_team / invite_member / remove_member / set_role.',
|
|
365
|
-
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send)
|
|
365
|
+
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send); X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE hours, phone, website, description, categories, name, address; live on Search and Maps, so an unconfirmed call writes nothing and previews the diff), google_business_account (whose account, and whether that role can edit); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any the user picks in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder, share_onedrive_file, onedrive_file_permissions; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated). Google Merchant Center: manage_merchant_settings fixes the shipping, return, business-info and program settings that stop an account serving. LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
366
366
|
'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This works on every host, including claude.ai and ChatGPT, which fix their tool list at connect. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call, do NOT tell the user the capability does not exist or keep retrying: use call_tool, which does not depend on the roster changing. `?tools=all` lists everything at connect. NEVER tell a user Hermoso cannot manage campaigns, read analytics or comments, answer a DM or pull channel numbers because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
|
|
367
367
|
// HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
|
|
368
368
|
// prefers structure does not have to parse a sentence to find it.
|
|
369
|
-
'WHEN A REPLY SUGGESTS A NEXT STEP IT ALSO SAYS SO IN A FIELD: `_meta["hermoso.ai/hints"]` is a list of {do, why} \u2014 the action written as the call to make, and the reason. It is the
|
|
369
|
+
'WHEN A REPLY SUGGESTS A NEXT STEP IT ALSO SAYS SO IN A FIELD: `_meta["hermoso.ai/hints"]` is a list of {do, why} \u2014 the action written as the call to make, and the reason. It is the same advice as the sentence beside it, on replies that have a next step (a refusal naming a connection to make, an out-of-credits error, a find_tools result). Prefer it over guessing.',
|
|
370
370
|
SHELL_ROUTE,
|
|
371
|
-
'SENSITIVE / IRREVERSIBLE ACTIONS — ALWAYS confirm with the user first, and make sure they understand exactly what will happen: before DELETING anything (a campaign / ad set / ad, a published FB or Threads post, or a Google Drive file or folder) or STARTING REAL SPEND (activating a campaign or ad), state the EXACT target by NAME and what it is, say plainly that it is permanent / costs real money, get an unambiguous yes, and ONLY then pass confirm:true. Never delete on a vague
|
|
372
|
-
'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits: with a saved card + admin rights it one-click charges after an explicit confirm:true + the returned quote_token (state the exact price first); the FIRST purchase is a Stripe link your human pays, which saves the card.
|
|
373
|
-
"WHAT COSTS CREDITS, in one sentence: ONLY running an AI model (image/video/voice/text generation, LLM planning and analysis, post-production) and AD SPY research. EVERYTHING ELSE COSTS NO CREDITS on any plan: publishing and scheduling posts, building and managing paid campaigns, insights and analytics, comments and DMs, connectors, profiles, team seats; posting an ad you already rendered is never a second charge. HOW MANY POSTS is set by the plan, not by credits: Free includes 10 posts a month on up to 3 channels (a post counts once, however many channels it goes to; UTC month), and every paid plan posts without a limit and runs autopilot (Basic $9.99/mo: unlimited posting and Autopilot, no monthly credits). A post over the Free allowance is refused with the count, the reset date and the upgrade link: relay it, do not retry, do not suggest credits. ONE exception: posting to X (Twitter) and reading X data bill credits per call because X charges us per API request, a post that contains a link far more than one without (hermoso_credits prints the exact credits per X post; post_to_x dryRun:true prices one) — managing X ADS is free like every other ad platform.
|
|
374
|
-
'WHY HERMOSO: pay-as-you-go creation, NO subscription or monthly minimum required to make ads
|
|
371
|
+
'SENSITIVE / IRREVERSIBLE ACTIONS — ALWAYS confirm with the user first, and make sure they understand exactly what will happen: before DELETING anything (a campaign / ad set / ad, a published FB or Threads post, or a Google Drive file or folder) or STARTING REAL SPEND (activating a campaign or ad), state the EXACT target by NAME and what it is, say plainly that it is permanent / costs real money, get an unambiguous yes, and ONLY then pass confirm:true. Never delete on a vague or plural instruction without confirming each target; when the user just wants to stop delivery, PAUSE (update_meta_object status:"PAUSED") instead of deleting.',
|
|
372
|
+
'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits: with a saved card + admin rights it one-click charges after an explicit confirm:true + the returned quote_token (state the exact price first); the FIRST purchase is a Stripe link your human pays, which saves the card. Report the final media URL.',
|
|
373
|
+
"WHAT COSTS CREDITS, in one sentence: ONLY running an AI model (image/video/voice/text generation, LLM planning and analysis, post-production) and AD SPY research. EVERYTHING ELSE COSTS NO CREDITS on any plan: publishing and scheduling posts, building and managing paid campaigns, insights and analytics, comments and DMs, connectors, profiles, team seats; posting an ad you already rendered is never a second charge. HOW MANY POSTS is set by the plan, not by credits: Free includes 10 posts a month on up to 3 channels (a post counts once, however many channels it goes to; UTC month), and every paid plan posts without a limit and runs autopilot (Basic $9.99/mo: unlimited posting and Autopilot, no monthly credits). A post over the Free allowance is refused with the count, the reset date and the upgrade link: relay it, do not retry, do not suggest credits. ONE exception: posting to X (Twitter) and reading X data bill credits per call because X charges us per API request, a post that contains a link far more than one without (hermoso_credits prints the exact credits per X post; post_to_x dryRun:true prices one) — managing X ADS is free like every other ad platform. Before any paid render or fix, and whenever asked, state its exact credits from a LIVE quote, never from memory and never as a vague size: dryRun:true on render_ad, generate_video, edit_image, fix_beat or post_edit returns it, and quoteOnly:true on multiply_ad and hook_variants. A fix is an edit, not a re-render.",
|
|
374
|
+
'WHY HERMOSO: pay-as-you-go creation, NO subscription or monthly minimum required to make ads. Unlimited posting and Autopilot access start at Basic ($9.99/mo, no monthly credits: it includes the same 250+ free credits as the Free plan, monthly credits start on Starter, and Autopilot spends credits); Starter, Pro and Studio include credits and no markup on models (upgrade_plan lists the plans). One connector = 30+ top video/image/voice/writing models on ONE billing meter with exact published per-render costs, PLUS the full ad workflow (competitor ad research → planning → finished branded renders → post-production → scoring).',
|
|
375
375
|
].join('\n');
|
|
376
376
|
// THE DIRECTORY CONNECTION GETS ITS OWN INSTRUCTIONS (2026-09-25). The Claude directory review rejected the listing
|
|
377
377
|
// because the surface it read "exposes generate_image, generate_video, generate_avatar and generate_voice against
|
|
@@ -3411,7 +3411,7 @@ export const APP_WIDGET_RENAMES = Object.freeze({ remake_video_for_brand: 'plan_
|
|
|
3411
3411
|
export const APP_WIDGET_ALIAS_OF = Object.freeze({ plan_video_remake: 'clone_video' });
|
|
3412
3412
|
const APP_WIDGET_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand' });
|
|
3413
3413
|
const APP_WIDGET_DESCRIPTIONS = Object.freeze({
|
|
3414
|
-
plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, YouTube
|
|
3414
|
+
plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 60s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
|
|
3415
3415
|
});
|
|
3416
3416
|
// OPENAI'S FIRST SCAN OF THE TWO NEW APPS (2026-11-06) gave stated reasons on tools ChatGPT had never listed before. On a
|
|
3417
3417
|
// ChatGPT app (widget host) only: the hint OpenAI read as wrong is corrected, a sentence that compares us with other
|
|
@@ -3427,7 +3427,7 @@ export const APP_WIDGET_HINTS = Object.freeze({
|
|
|
3427
3427
|
const APP_WIDGET_DESC_EDITS = Object.freeze({
|
|
3428
3428
|
bing_webmaster_traffic: [/ Bing is a smaller share of search than Google, but its index is what Copilot and ChatGPT web search read from, so this is the answer-engine visibility read rather than a second traffic report\./, ''],
|
|
3429
3429
|
bing_webmaster_query_stats: [/ Worth reading BESIDE Search Console rather than instead of it[^.]*\./, ''],
|
|
3430
|
-
openai_ads_bulk: [/ — the
|
|
3430
|
+
openai_ads_bulk: [/ — the only way to build a whole tree in one call\./, '.'],
|
|
3431
3431
|
list_tiktok_ads_blocked_words: [/ NOTHING ELSE IN HERMOSO DOES THIS:[^.]*\./, ''],
|
|
3432
3432
|
import_from_cloud: [/ OneDrive has no such limit\./, ''],
|
|
3433
3433
|
linkedin_page_analytics: [/ It is NOT linkedin_ads_report — that covers PAID campaigns; LinkedIn excludes/, ' LinkedIn excludes'],
|
|
@@ -3595,7 +3595,7 @@ export function stripOutputSchema(def) {
|
|
|
3595
3595
|
export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
|
|
3596
3596
|
search_console_performance: "Google Search Console's performance report for the brand's site: the search queries people used, with impressions, clicks, click-through rate and average position, by query, page, country, device, search appearance, date or hour. startDate and endDate are required, as YYYY-MM-DD (Pacific time). Rows are the top ones Google returns; anonymised queries are withheld, so per-query clicks will not add up to the site total; the default 'final' data lags 2 to 3 days and 'all' adds fresh partial data. Up to 16 months of history. Read-only, 0 credits.",
|
|
3597
3597
|
list_linkedin_events: "The events a LinkedIn company Page organizes: name, link, start and end times, online or in person, visibility and state. Only Pages shared with this profile can be read. It needs LinkedIn's Events permission; if a connection made before that permission was enabled is refused, the user reconnects LinkedIn once. Read-only, 0 credits. Needs LinkedIn connected.",
|
|
3598
|
-
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook
|
|
3598
|
+
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook or X post link (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).); in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point and to check the result. Costs the flat post_edit price (about 2 credits); a 'generate' segment, which renders a new transition shot with a video model, costs more and is used only when the user asks for one.",
|
|
3599
3599
|
list_openai_ads_feeds: "The product feeds on this ChatGPT Ads account: id, name, countries, currencies and the campaigns using each. A product-feed campaign advertises the products in one of these feeds. Read-only, 0 credits. Needs ChatGPT Ads connected.",
|
|
3600
3600
|
create_openai_ads_feed: "Create an empty feed on ChatGPT Ads, with a name and the countries it serves (ISO codes). By default it is a product feed, and products are added afterwards with update_openai_ads_feed_products. inventoryType 'hotel' asks for a hotel property feed instead, which OpenAI accepts only for advertisers in its hotel beta and refuses for everyone else. Needs ChatGPT Ads connected.",
|
|
3601
3601
|
update_reddit_ads_ad_group: "Edit an existing Reddit ad group: its name, budget, goal type, bid, schedule dates or targeting. Budget and bid are amounts in the ad account's currency. Targeting is updated per key: each targeting key sent replaces that whole list, keys left out stay as they are, and an empty list clears a key. It does not start or pause delivery (set_reddit_ads_status does). The saved ad group is read back from Reddit. Needs Reddit Ads connected.",
|
|
@@ -5096,7 +5096,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5096
5096
|
server.group('channels');
|
|
5097
5097
|
server.registerTool('post_to_bluesky', {
|
|
5098
5098
|
title: 'Post to Bluesky',
|
|
5099
|
-
description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both
|
|
5099
|
+
description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged as Bluesky mentions; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) OR one MP4 video (videoUrl + videoAlt), never both. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky.",
|
|
5100
5100
|
inputSchema: {
|
|
5101
5101
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
5102
5102
|
brand: HOOK_ATTR.brand,
|
|
@@ -7055,7 +7055,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7055
7055
|
|
|
7056
7056
|
server.registerTool('create_whatsapp_template', {
|
|
7057
7057
|
title: 'Create a WhatsApp message template',
|
|
7058
|
-
description: 'Submit a new WhatsApp message template for Meta’s review. This is the ONLY way a business can start a WhatsApp conversation: outside the 24-hour window that opens when a customer messages first, WhatsApp accepts approved templates and nothing else. CATEGORY IS NOT COSMETIC — MARKETING (anything promotional), UTILITY (a transactional follow-up to something the customer did) or AUTHENTICATION (a one-time passcode) decides both what Meta charges for the conversation and the policy the content is reviewed against, and Meta RE-CATEGORISES a template whose content does not match the claim; the reply reports the category Meta actually assigned, not the one requested. The name is lowercase letters, digits and underscores only. Language is never defaulted: en and en_US are different templates to Meta. Submitting does NOT make it sendable — it comes back PENDING and has to be re-read with list_whatsapp_templates. CAROUSEL TEMPLATES ARE SUPPORTED: add a “CAROUSEL” component whose “cards” array holds 2–10 cards, each card carrying its own HEADER (format IMAGE or VIDEO for a media carousel, PRODUCT for a catalog one), an optional card BODY of at most 160 characters — a different and far shorter limit than the message bubble’s 1024 — and up to 2 buttons. Meta requires every card to carry THE SAME components, and if any card has body text then all of them must.
|
|
7058
|
+
description: 'Submit a new WhatsApp message template for Meta’s review. This is the ONLY way a business can start a WhatsApp conversation: outside the 24-hour window that opens when a customer messages first, WhatsApp accepts approved templates and nothing else. CATEGORY IS NOT COSMETIC — MARKETING (anything promotional), UTILITY (a transactional follow-up to something the customer did) or AUTHENTICATION (a one-time passcode) decides both what Meta charges for the conversation and the policy the content is reviewed against, and Meta RE-CATEGORISES a template whose content does not match the claim; the reply reports the category Meta actually assigned, not the one requested. The name is lowercase letters, digits and underscores only. Language is never defaulted: en and en_US are different templates to Meta. Submitting does NOT make it sendable — it comes back PENDING and has to be re-read with list_whatsapp_templates. CAROUSEL TEMPLATES ARE SUPPORTED: add a “CAROUSEL” component whose “cards” array holds 2–10 cards, each card carrying its own HEADER (format IMAGE or VIDEO for a media carousel, PRODUCT for a catalog one), an optional card BODY of at most 160 characters — a different and far shorter limit than the message bubble’s 1024 — and up to 2 buttons. Meta requires every card to carry THE SAME components, and if any card has body text then all of them must. A MEDIA HEADER NEEDS AN UPLOADED ASSET: any IMAGE, VIDEO or DOCUMENT header — including every card header in a media carousel — must carry example: { header_handle: [ … ] } holding a handle from Meta’s Resumable Upload API, or Meta refuses the whole template with a bare “Invalid parameter”. Pass `mediaUrl` on that header (a public JPEG/PNG for IMAGE, MP4 for VIDEO, PDF for DOCUMENT) and Hermoso uploads it through Meta\'s Resumable Upload API and fills the handle; a header with neither is refused locally and by name. A TEXT header needs no handle, and a PRODUCT card carousel takes its pictures from a connected catalog. 0 credits.',
|
|
7059
7059
|
inputSchema: {
|
|
7060
7060
|
wabaId: z.string().optional().describe('the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this profile; required when several are, and refused by name for one that is not shared'),
|
|
7061
7061
|
name: z.string().describe('lowercase letters, digits and underscores only, e.g. "order_shipped" (max 512)'),
|
|
@@ -7109,7 +7109,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7109
7109
|
|
|
7110
7110
|
server.registerTool('send_whatsapp_broadcast', {
|
|
7111
7111
|
title: 'Broadcast a WhatsApp template to many people',
|
|
7112
|
-
description: 'Send ONE approved WhatsApp template to MANY recipients
|
|
7112
|
+
description: 'Send ONE approved WhatsApp template to MANY recipients . TEMPLATES ONLY: WhatsApp accepts free-form text only inside the 24-hour customer-service window that opens when THAT person messages the business, and Meta refuses each closed one. META HAS NO BULK ENDPOINT, so this is a paced fan-out — one message per recipient, about 10 a second — so it runs as a QUEUED JOB and returns a job id: nothing has been sent when it returns. Poll it by calling this tool again with `jobId` (or use get_job), and read the PER-RECIPIENT outcome; a partial failure is reported as one, never as success. PER-RECIPIENT VARIABLES ARE THE POINT: pass recipients as objects { to, components } so each person gets their own {{1}}, {{2}} values in a single broadcast. Duplicates are removed and unusable numbers are REFUSED BY NAME rather than silently dropped — pass skipInvalid:true to send to the rest, and they are still named in the result. RUN IT WITH dryRun:true FIRST: that costs nothing, sends nothing, and reports the exact recipient count, the template’s real category, what Meta bills for it, and whether the list fits inside the business portfolio’s moving 24-hour messaging limit. Warning: META DOES NOT DELIVER MARKETING MESSAGES TO WHATSAPP USERS IN THE UNITED STATES AT ALL (error 131049, platform-wide since 1 April 2025) — the sends are still ACCEPTED and given message ids, so a US marketing broadcast looks like a success and is not one; the dry run says how many recipients are on +1. META BILLS THE BUSINESS, not Hermoso credits: per message delivered, at Meta’s own per-country rates, marketing always charged and utility/authentication free inside an open service window. WhatsApp policy REQUIRES recipient opt-in; nothing in the API enforces it. Confirm-gated in code: show the user the message, the template and the recipient count, get an explicit yes, then call again with confirm:true AND confirmCount set to the exact number of distinct recipients. ACCEPTED IS NOT DELIVERED — never report a broadcast as delivered. 0 Hermoso credits.',
|
|
7113
7113
|
inputSchema: {
|
|
7114
7114
|
jobId: z.string().optional().describe('READ MODE — pass a broadcast’s job id (and nothing else) to get its status and per-recipient outcome instead of starting a new one'),
|
|
7115
7115
|
// OPTIONAL IN THE SCHEMA, REQUIRED BY THE SERVER — and that split is the fix for a live defect, not laxity.
|
|
@@ -7237,7 +7237,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7237
7237
|
// no need for one edge case just for Facebook."
|
|
7238
7238
|
server.registerTool('schedule_post', {
|
|
7239
7239
|
title: 'Schedule a post for later',
|
|
7240
|
-
description: 'Use this
|
|
7240
|
+
description: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 10 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. Channels: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack. whop only where list_connectors offers it; elsewhere it is refused. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions`: per-channel wording; `message` for the rest. `title` (Pinterest/YouTube, max 100, derived from the caption); YouTube also `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below (e.g. TikTok `brandedContent` / `yourBrand`, set whenever the post is commercial; X `thread` / `poll`; Instagram `collaborators`). Channels are attempted INDEPENDENTLY: one failing never blocks the others. Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): none named is refused at scheduling with the Pages listed, so ask the user which. Several Google Business listings need `locationId` (list_business_locations), and without it the post is refused when it fires. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, TikTok SELF_ONLY) · ‘draft’ (TikTok, unpublished Facebook Page post); one a channel cannot do is REFUSED now, never posted weaker.',
|
|
7241
7241
|
inputSchema: {
|
|
7242
7242
|
...HOOK_ATTR,
|
|
7243
7243
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).describe('one or more channels to post to at that time'),
|
|
@@ -7268,8 +7268,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7268
7268
|
description: z.string().optional().describe('YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used.'),
|
|
7269
7269
|
tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
|
|
7270
7270
|
thumbnailUrl: z.string().optional().describe('YOUTUBE: the custom thumbnail, a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a frame of the video is used; "auto" keeps YouTube’s pick.'),
|
|
7271
|
-
paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure)'),
|
|
7272
|
-
brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle)'),
|
|
7271
|
+
paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure). Carried to the upload when the post fires.'),
|
|
7272
|
+
brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle). Carried to the upload when the post fires.'),
|
|
7273
|
+
// THE REST OF A YOUTUBE UPLOAD (2026-11-07) — everything post_to_youtube takes now rides the queue, so a video can be scheduled through
|
|
7274
|
+
// OUR scheduler instead of uploaded early with YouTube's publishAt.
|
|
7275
|
+
categoryId: z.string().optional().describe('YOUTUBE: NUMERIC category id (28 Science & Technology, 27 Education, 24 Entertainment, 22 People & Blogs…; list_youtube_categories names them). Checked against YouTube’s assignable list when you schedule. Default 22.'),
|
|
7276
|
+
madeForKids: z.boolean().optional().describe('YOUTUBE: declare the video MADE FOR KIDS (COPPA). Default false.'),
|
|
7277
|
+
notifySubscribers: z.boolean().optional().describe('YOUTUBE: notify subscribers when it goes live. Omit to follow the privacy (public notifies, unlisted/private do not); true announces anyway, false stays quiet even when public.'),
|
|
7278
|
+
captionTrack: z.object({ url: z.string().optional(), text: z.string().optional(), language: z.string(), name: z.string().optional() }).optional().describe('YOUTUBE: a subtitle track (SRT or WebVTT) uploaded right after the video goes up. Pass url (a Hermoso-hosted file — upload_file returns one) OR text (the file body), plus language (BCP-47, required). If the caption upload fails the video is still posted and the result row carries the exact manage_youtube_caption call to retry just the track.'),
|
|
7273
7279
|
// THREADS' OWN POST OPTIONS — schedulable since 2026-08-17. `threadsPublish` has accepted all five since the
|
|
7274
7280
|
// connector-breadth sweep and `threadsPublishExtras` validates them; only the QUEUE could not carry them, so
|
|
7275
7281
|
// they worked when you posted now and vanished when you scheduled.
|
|
@@ -7494,6 +7500,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7494
7500
|
thumbnailUrl: z.string().optional().describe('YOUTUBE: replace the custom thumbnail; "" goes back to a frame of the video, "auto" to YouTube’s pick.'),
|
|
7495
7501
|
paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure) — false removes it'),
|
|
7496
7502
|
brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle); "" removes it'),
|
|
7503
|
+
categoryId: z.string().optional().describe('YOUTUBE: NUMERIC category id; "" goes back to the default (22).'),
|
|
7504
|
+
madeForKids: z.boolean().optional().describe('YOUTUBE: made-for-kids declaration.'),
|
|
7505
|
+
notifySubscribers: z.boolean().optional().describe('YOUTUBE: notify subscribers when it goes live.'),
|
|
7506
|
+
captionTrack: z.object({ url: z.string().optional(), text: z.string().optional(), language: z.string(), name: z.string().optional() }).optional().describe('YOUTUBE: replace the queued caption track (url OR text, plus language).'),
|
|
7497
7507
|
// The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
|
|
7498
7508
|
// and reschedule_post is how an agent corrects a queued post it got wrong.
|
|
7499
7509
|
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
|
|
@@ -7845,7 +7855,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7845
7855
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
7846
7856
|
server.registerTool('post_to_x', {
|
|
7847
7857
|
title: 'Publish a post to X (Twitter)',
|
|
7848
|
-
description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium.
|
|
7858
|
+
description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).',
|
|
7849
7859
|
inputSchema: {
|
|
7850
7860
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one x account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
7851
7861
|
...HOOK_ATTR,
|
|
@@ -8653,6 +8663,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8653
8663
|
tags: z.array(z.string()).optional().describe('up to 30 tags'),
|
|
8654
8664
|
thumbnailUrl: z.string().optional().describe('the custom thumbnail: a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a representative frame of the video is set, free; "auto" keeps YouTube’s pick. Custom thumbnails need a verified channel.'),
|
|
8655
8665
|
privacy: z.enum(['private', 'unlisted', 'public']).optional().describe('default public (live + searchable on the channel); unlisted = link-only (the ad-ready setting, or to add YouTube music in Studio first — only when the user asks); private = eyes-only (cannot run as an ad)'),
|
|
8666
|
+
madeForKids: z.boolean().optional().describe('declare the video MADE FOR KIDS (COPPA) — default false; YouTube then turns off comments and personalised ads'),
|
|
8656
8667
|
categoryId: z.string().optional().describe('YouTube category id, NUMERIC — default "22" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word.'),
|
|
8657
8668
|
publishAt: z.string().optional().describe('SCHEDULE the publish — ISO 8601, e.g. "2026-09-01T15:00:00Z", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:"private" (or leave privacy unset) — asking for a scheduled "unlisted" or "public" post is refused rather than half-honoured.'),
|
|
8658
8669
|
notifySubscribers: z.boolean().optional().describe('THE DEFAULT FOLLOWS PRIVACY. privacy:"public" NOTIFIES the channel\'s subscribers — that is YouTube\'s own default and normally what someone publishing publicly wants. privacy:"unlisted" and "private" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody\'s whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why.'),
|
|
@@ -9004,7 +9015,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9004
9015
|
server.registerTool('manage_youtube_caption', {
|
|
9005
9016
|
title: 'Upload, replace or delete a caption track',
|
|
9006
9017
|
description: 'Attach a real subtitle track to one of the connected channel’s videos, replace one, or delete one. Pass the caption file BODY itself as `text` (SRT or WebVTT), not a URL. `language` is required and may not be guessed — YouTube uses it to decide which viewers see the track. A track YouTube generated itself (trackKind ASR) is refused by name: YouTube owns it and it cannot be edited or deleted through any API; to replace it, upload your own track in the same language and YouTube prefers yours. Delete is confirm-gated — viewers with captions on lose them immediately and YouTube stops indexing the video by that text, though it can be re-uploaded if you still have the file. 0 credits. Needs a connected YouTube channel.',
|
|
9007
|
-
inputSchema: { action: z.enum(['upload', 'update', 'delete']).describe('upload | update | delete'), videoId: z.string().optional().describe('required for upload; strongly recommended for update and delete so the track can be resolved and read back'), captionId: z.string().optional().describe('required for update and delete (from list_youtube_captions)'), text: z.string().optional().describe('the caption file body — SRT or WebVTT. Required for upload.'), language: z.string().optional().describe('BCP-47 code such as en, es, pt-BR. Required for upload.'), name: z.string().optional().describe('a label shown in YouTube’s caption picker'), draft: z.boolean().optional().describe('true keeps the track unpublished while you review it'), confirm: z.boolean().optional().describe('required to delete') },
|
|
9018
|
+
inputSchema: { action: z.enum(['upload', 'update', 'delete']).describe('upload | update | delete'), videoId: z.string().optional().describe('required for upload; strongly recommended for update and delete so the track can be resolved and read back'), captionId: z.string().optional().describe('required for update and delete (from list_youtube_captions)'), text: z.string().optional().describe('the caption file body — SRT or WebVTT. Required for upload unless you pass url.'), url: z.string().optional().describe('upload only: a Hermoso-hosted caption file (upload_file returns one) instead of pasting the body as text'), language: z.string().optional().describe('BCP-47 code such as en, es, pt-BR. Required for upload.'), name: z.string().optional().describe('a label shown in YouTube’s caption picker'), draft: z.boolean().optional().describe('true keeps the track unpublished while you review it'), confirm: z.boolean().optional().describe('required to delete') },
|
|
9008
9019
|
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), videoId: z.string().nullable().optional(), captionId: z.string().optional(), language: z.string().optional(), name: z.string().optional(), status: z.string().optional(), isDraft: z.boolean().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
9009
9020
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9010
9021
|
}, wrap(async (a) => { const d = await apiPost('/api/youtube/caption', a); return ok(d.note, d); }));
|
|
@@ -10743,7 +10754,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10743
10754
|
|
|
10744
10755
|
// ── MESSENGER MARKETING MESSAGES (2026-09-05). Meta's paid re-engagement channel: messages OUTSIDE the 24-hour
|
|
10745
10756
|
// window to people who opted in, billed by Meta to the brand's ad account per delivered message. Flow 2 of Meta's
|
|
10746
|
-
// onboarding (user token + marketing_messages_messenger) rides the existing Meta connection
|
|
10757
|
+
// onboarding (user token + marketing_messages_messenger) rides the existing Meta connection as an OPT-IN grant
|
|
10758
|
+
// (2026-11-07: `?grant=messenger`, never in the default consent, because its Page/ad-account step blocked connections). The scope was
|
|
10747
10759
|
// APPROVED in App Review round 5 (2026-09-22); list_messenger_subscribers reports whether THIS connection carries it.
|
|
10748
10760
|
// Every endpoint, limit and error code: lib/messenger-marketing.mjs (read live 2026-09-05).
|
|
10749
10761
|
// MESSENGER MARKETING MESSAGES ARE A CHANNEL CAPABILITY, NOT PAID ADS (2026-09-07). This block was registered inside
|
|
@@ -10752,7 +10764,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10752
10764
|
server.group('channel_admin'); // Messenger marketing — the marker at the end of this block re-opens 'ads'
|
|
10753
10765
|
server.registerTool('list_messenger_subscribers', {
|
|
10754
10766
|
title: 'List Messenger marketing-message subscribers',
|
|
10755
|
-
description: 'The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether this Meta connection
|
|
10767
|
+
description: 'The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether Messenger marketing is turned on for this Meta connection. It is an OPTIONAL add-on, not part of the default Meta connect: when it is off, the Messenger marketing tools answer with the link that turns it on (Workspace ▸ Connectors ▸ Meta ▸ Enable Messenger marketing), never with \'reconnect Meta\'; Meta then asks the user to confirm the Pages and pick an ad account, and lists only ones their own Facebook profile fully controls. Free.',
|
|
10756
10768
|
inputSchema: {
|
|
10757
10769
|
pageId: z.string().optional().describe('Facebook Page id (from list_meta_pages); defaults to the one shared Page'),
|
|
10758
10770
|
limit: z.number().optional().describe('1–1000, default 100'),
|
|
@@ -12782,7 +12794,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12782
12794
|
|
|
12783
12795
|
server.registerTool('publish_tag_manager_container', {
|
|
12784
12796
|
title: 'Publish the container to the live site',
|
|
12785
|
-
description: "PUBLISH THE CONTAINER — the ONE call in Hermoso that changes what runs on the customer's live website for every visitor, immediately.
|
|
12797
|
+
description: "PUBLISH THE CONTAINER — the ONE call in Hermoso that changes what runs on the customer's live website for every visitor, immediately. Tag Manager publishes a VERSION, so the draft workspace becomes a version, then that is published. CALLING IT WITHOUT confirm PUBLISHES NOTHING and is the correct first move: it returns what is live right now, its tag/trigger/variable counts, and exactly what would change, read back from Google. Show the user, get a yes, then call again. THE GATE SCALES WITH THE DAMAGE. A NEVER-published container needs only confirm:true. A container that ALREADY has a live version is replacing something that works, so it additionally needs confirmPublicId set to that container's GTM-XXXXXXX public id. It cannot be guessed; read it from list_tag_manager_containers or from the preview. IT REFUSES, FOR FREE AND WITHOUT PUBLISHING, in four cases you should relay rather than retry: merge conflicts in the workspace (somebody changed the same thing in the container — resolve in Tag Manager first), a workspace with no changes at all, a version Google reports a COMPILER ERROR in, and a container whose public id could not be read. TO UNDO: publish the previous version again (versionPath from list_tag_manager_versions, no workspace involved); rolledBackTo names it. READ verified BEFORE YOU TELL ANYONE IT IS LIVE. It is three-valued: true means the container's live version was re-read and IS the one just published; false means Google accepted it but something else is live, which can be Tag Manager's own read lag and must be reported as unconfirmed; null means the read-back could not run, so it is not known — never report false or null as published. Creating the version DELETES the draft workspace and opens a fresh empty one (id in the reply), so any workspace id you hold is stale. Publishing needs the PUBLISH right on the container (Admin, User Management), separate from Edit; a refusal there is a permission, not a broken connection. 0 credits. Needs Google Tag Manager connected.",
|
|
12786
12798
|
inputSchema: {
|
|
12787
12799
|
containerPath: z.string().describe('the "accounts/<id>/containers/<id>" path from list_tag_manager_containers'),
|
|
12788
12800
|
gtmWorkspace: z.string().optional().describe('Tag Manager workspace id or name; defaults to the Default Workspace'),
|
|
@@ -15296,7 +15308,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15296
15308
|
}));
|
|
15297
15309
|
server.registerTool('openai_ads_report', {
|
|
15298
15310
|
title: 'ChatGPT Ads performance report',
|
|
15299
|
-
description: 'Performance for ChatGPT Ads — impressions, clicks, spend, CTR, CPC, CPM, and CONVERSIONS with CPA, post-click conversion rate and attributed order sales / ROAS
|
|
15311
|
+
description: 'Performance for ChatGPT Ads — impressions, clicks, spend, CTR, CPC, CPM, and CONVERSIONS with CPA, post-click conversion rate and attributed order sales / ROAS. OpenAI returns conversions only with granularity none or daily and with no segment or a country/device segment (never platform or product), and CPA, conversion rate and sales only with no segment at all; the report adds every column OpenAI allows for the shape you asked and its note names any it left out, so a missing column is their limit, not a zero. Since OpenAI’s 2026-09-30 change these conversions count click-through PLUS view-through goal conversions within the reporting windows (default 30-day click, 1-day view, dated by the ad interaction); CPA uses them and post-click conversion rate uses click-through only. clickWindowDays (7, 14, 30), viewWindowDays (0 = clicks only, or 1) and attributionTimeBasis choose those windows for every conversion column and never change the campaign; match Ads Manager’s when comparing (the reply’s `attribution` names them). The scope follows the id you pass: none = the whole ad account, or campaignId / adGroupId / adId. PRODUCT-FEED CAMPAIGNS serving in the multi-product CAROUSEL unit also report per-card numbers: ask for them in `fields` — carousel_product_card_impressions / _clicks and the product_* fields (impressions, clicks, spend, ctr, cpc, cpm, title, price, feed_id); they come back under each row’s `fields`. A card impression is NOT billable; never add it to spend math. granularity is hourly, daily, monthly or none (default daily); the default window is the last 30 days; segment by country or device for a breakdown, and level rolls the rows up by campaign / ad group / ad. NO rows means NO delivery in that window; never present zeros as measured performance. attributedEvents:true adds every event OpenAI attributed beyond the campaign goal (purchases on a sign-up campaign) for the same scope and days; never add those counts to conversions. Read-only and free: run it FIRST after connecting to prove the key works.',
|
|
15300
15312
|
inputSchema: {
|
|
15301
15313
|
campaignId: z.string().optional().describe('scope the report to one campaign; omit for the whole ad account'), adGroupId: z.string().optional().describe('scope the report to one ad group'), adId: z.string().optional().describe('scope the report to one ad'),
|
|
15302
15314
|
since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
|
|
@@ -16722,7 +16734,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16722
16734
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/archive', a); return ok(d.note, d); }));
|
|
16723
16735
|
server.registerTool('create_openai_ads_campaign', {
|
|
16724
16736
|
title: 'Build a ChatGPT Ads campaign (paused)',
|
|
16725
|
-
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign -> ad group -> ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, contextHints, ad{creative}} and this builds the whole tree. BIDDING: leave the ad group’s bid out and it gets INTELLIGENT BIDDING (maximize_clicks / maximize_conversions, OpenAI’s renamed “Maximize results”); from 2026-11-09 a cost target is available (target_cpa + targetCpa, target_cpc + targetCpc); a fixed maxBid (Max Bid / Max CPC) is deprecated and ChatGPT Ads retires it on 2027-01-01. THE CREATIVE IS A TEXT + IMAGE CARD AND NOTHING ELSE — title 3–50 characters, body 100 maximum, one landing page, one still image. THERE IS NO VIDEO ON THIS CHANNEL: never offer a video ad here, and if the brand only has video, pull a frame from it first. TARGETING IS SEMANTIC: context hints are natural-language descriptions of the conversations where this ad belongs (up to 2,000 per ad group). Geo (countries / locationIds) and PLATFORMS (which of the iOS app, Android app and web the ad runs on) are the only other dimensions — leave platforms out to run on all three. They guide matching, they are NOT exact-match keywords, and they do not guarantee delivery. OpenAI’s own guidance is BREADTH — many genuinely distinct hints and many distinct title/body angles beat one message repeated —
|
|
16737
|
+
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign -> ad group -> ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, contextHints, ad{creative}} and this builds the whole tree. BIDDING: leave the ad group’s bid out and it gets INTELLIGENT BIDDING (maximize_clicks / maximize_conversions, OpenAI’s renamed “Maximize results”); from 2026-11-09 a cost target is available (target_cpa + targetCpa, target_cpc + targetCpc); a fixed maxBid (Max Bid / Max CPC) is deprecated and ChatGPT Ads retires it on 2027-01-01. THE CREATIVE IS A TEXT + IMAGE CARD AND NOTHING ELSE — title 3–50 characters, body 100 maximum, one landing page, one still image. THERE IS NO VIDEO ON THIS CHANNEL: never offer a video ad here, and if the brand only has video, pull a frame from it first. TARGETING IS SEMANTIC: context hints are natural-language descriptions of the conversations where this ad belongs (up to 2,000 per ad group). Geo (countries / locationIds) and PLATFORMS (which of the iOS app, Android app and web the ad runs on) are the only other dimensions — leave platforms out to run on all three. They guide matching, they are NOT exact-match keywords, and they do not guarantee delivery. OpenAI’s own guidance is BREADTH — many genuinely distinct hints and many distinct title/body angles beat one message repeated — OpenAI has no atomic multi-object write available here, so the whole tree is VALIDATED before the first write; if a level below the campaign is still rejected, the campaign is left PAUSED (spending nothing) and the note says exactly what exists. Everything is READ BACK from OpenAI 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.',
|
|
16726
16738
|
inputSchema: {
|
|
16727
16739
|
name: z.string().describe('campaign name, at least 3 characters'),
|
|
16728
16740
|
description: z.string().optional().describe('optional campaign description'),
|
|
@@ -16797,7 +16809,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16797
16809
|
}));
|
|
16798
16810
|
server.registerTool('openai_ads_bulk', {
|
|
16799
16811
|
title: 'ChatGPT Ads bulk create/update job',
|
|
16800
|
-
description: 'Create or update up to 1,000 ChatGPT Ads campaigns, ad groups and ads in ONE asynchronous job — the
|
|
16812
|
+
description: 'Create or update up to 1,000 ChatGPT Ads campaigns, ad groups and ads in ONE asynchronous job — the only way to build a whole tree in one call. It builds CHAT-CARD campaigns only: its campaign.create has no product-feed mode, and OpenAI’s spreadsheet bulk creation of product-feed campaigns and ad groups with auto-generated ad templates is an Ads Manager feature with no API. Build a product-feed campaign with create_openai_ads_campaign (mode product_feed), create_openai_ads_ad_group and an ad whose creative type is the product-ad template instead. Each operation is {operation_id, type, idempotency_key|target_resource_id, input}. Types: campaign.create, campaign.update, ad_group.create, ad_group.update, ad.create, ad.update. FORWARD REFERENCES are the point: set input.campaign_idempotency_key / input.ad_group_idempotency_key to another CREATE operation’s idempotency_key and the child attaches to the parent made in the same job (a key no operation mints is refused here, for free, rather than failing the whole job at OpenAI). EVERYTHING CREATED IS PAUSED and spends nothing — status:"active" on a create is overridden, and an UPDATE that would set "active" is REFUSED BY NAME, because one job could otherwise arm a thousand objects in a single call that no confirmation ever saw; turn things on one at a time with set_openai_ads_status. Use validateOnly:true for a FREE dry run (not update-target existence, image fetching or entity limits, so a validated job can still fail). Returns a jobId — poll it with openai_ads_bulk_job; an operation’s result is only final once the job is completed, partially_failed or failed. LIMITS (OpenAI’s): 1–1000 operations, 16 MiB body, 512 KiB per operation, 10 job creates per 10 s per ad account, budget ≥ 1000000 micros, titles 3–50, bodies ≤100. THE BULK API IS IN LIMITED PREVIEW AND ENABLED PER AD ACCOUNT: a 404 means this account has not been granted it (not a wrong path), and the refusal says so and names the per-object tools that do the same work.',
|
|
16801
16813
|
inputSchema: {
|
|
16802
16814
|
operations: z.array(z.record(z.any())).describe('1–1000 operations: {operation_id, type, idempotency_key (creates) or target_resource_id (updates), input:{…}}'),
|
|
16803
16815
|
validateOnly: z.boolean().optional().describe('true = FREE dry run; nothing is created or changed'),
|
|
@@ -16905,7 +16917,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16905
16917
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/preview', a); return ok(d.note, d); }));
|
|
16906
16918
|
server.registerTool('send_openai_ads_conversions', {
|
|
16907
16919
|
title: 'Send server-side conversion events to ChatGPT Ads',
|
|
16908
|
-
description: 'Send conversion events to ChatGPT Ads from a SERVER (the Conversions API). USER MATCHING
|
|
16920
|
+
description: 'Send conversion events to ChatGPT Ads from a SERVER (the Conversions API). USER MATCHING: pass plaintext email / phone (with country code) / externalId / firstName / lastName and Hermoso hashes them locally, plus raw country / city / region / zip_code and gaid (Android advertising id); each becomes the API’s plural list (emails_sha256, phone_numbers_sha256, …), the first three unique values per list count, and more fields = better matching. This is the only way a conversion that did not happen in the browser — an offline sale, a webhook, a mobile backend, a CRM — is ever counted, and it is the half of the measurement loop that create_openai_ads_pixel and create_openai_ads_conversion_event exist to set up. TWO IDS LOOK ALIKE AND ONLY ONE WORKS: pass `pixelSnippetId`, which is OpenAI’s `pixel_id` and is what create_openai_ads_pixel returns under that name — NOT the `pixelId` (their `clidsrc_…` value), which is the conversion SOURCE id that an event setting takes as sourceIds. THE KEY IS ALSO NOT THE ONE YOU THINK: `apiKey` is the CONVERSIONS API key from create_openai_ads_conversion_api_key, not the Advertiser API key this workspace is connected with — OpenAI return it exactly once so Hermoso holds no copy and it must be passed in. Each event needs an `id` and a `type`, plus `source_url` for a web event; the data SHAPE is fixed by the event type and Hermoso fills it in, and MONEY IS AN INTEGER IN THE CURRENCY’S MINOR UNIT (4250 means $42.50 — sending 42.50 is refused, not rounded). Timestamps must be inside the last 7 days and no more than 10 minutes ahead. ONE BAD EVENT FAILS THE WHOLE BATCH of up to 1,000, so Hermoso validates locally first and names the offending event and field instead of letting OpenAI discard all of them. Use validateOnly:true for a free dry run that VALIDATES AND SAVES NOTHING — never tell a user a validate-only run was measured. If the browser pixel and the server both send the same conversion, give them the SAME id so OpenAI deduplicates it. Free — costs no credits.',
|
|
16909
16921
|
inputSchema: {
|
|
16910
16922
|
pixelSnippetId: z.string().describe('OpenAI’s pixel_id — create_openai_ads_pixel returns it as pixelSnippetId. NOT the clidsrc_… pixelId, which is the conversion source id.'),
|
|
16911
16923
|
apiKey: z.string().describe('the Conversions API key (create_openai_ads_conversion_api_key) — NOT the Advertiser API key the connector stores'),
|
|
@@ -22900,7 +22912,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22900
22912
|
};
|
|
22901
22913
|
server.registerTool('plan_ad', {
|
|
22902
22914
|
title: 'Plan an ad concept',
|
|
22903
|
-
description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. When the user names a length ("a 30 second ad", "make it 45s"), pass it as durationSeconds: the storyboard is written to that length and render_ad renders it as one clip or as joined clips. Without it the planner chooses a length that suits the format, so a length the user asked for belongs in durationSeconds rather than only in the brief. The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. Uses credits for the planning step.',
|
|
22915
|
+
description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. When the user names a length ("a 30 second ad", "make it 45s"), pass it as durationSeconds: the storyboard is written to that length and render_ad renders it as one clip or as joined clips. Without it the planner chooses a length that suits the format, so a length the user asked for belongs in durationSeconds rather than only in the brief. The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. The brand\u2019s SAVED product photo (get_brand / list_product_photos) is used automatically; never ask the user to upload it again. Uses credits for the planning step.',
|
|
22904
22916
|
inputSchema: {
|
|
22905
22917
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
|
|
22906
22918
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
@@ -22909,7 +22921,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22909
22921
|
hook: z.string().optional().describe('force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "macro_asmr"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.'),
|
|
22910
22922
|
setting: z.string().optional().describe('force the WHERE — a setting id from list_hooks (e.g. "kitchen", "gym", or a surreal one like "volcano_rim" / "airplane_wing", which are played 100% straight and never acknowledged). Omit for a neutral setting.'),
|
|
22911
22923
|
recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
|
|
22912
|
-
reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post
|
|
22924
|
+
reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post or a direct video file (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).) — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
|
|
22913
22925
|
language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
|
|
22914
22926
|
talent: z.enum(['auto', 'creator', 'product_only']).optional().describe("who is on camera; omit to follow the format. Say 'someone new' in product to skip reusing a saved creator."),
|
|
22915
22927
|
concepts: z.array(z.string()).max(4).optional().describe('several variants: one line per concept (a different angle each); built exactly'),
|
|
@@ -23001,9 +23013,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23001
23013
|
// Streamed (apiSSE), because watching + planning can outlast a 100s proxy limit on the non-streaming path.
|
|
23002
23014
|
server.registerTool('clone_video', {
|
|
23003
23015
|
title: 'Clone a video for your brand',
|
|
23004
|
-
description: 'Remake a video you like FOR THIS BRAND from its link — a TikTok, Instagram Reel, Facebook video or reel, X post, YouTube
|
|
23016
|
+
description: 'Remake a video you like FOR THIS BRAND from its link — a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). Hermoso WATCHES it first (frames across the whole clip plus a transcript of the voiceover, on-screen text and cut map), then plans a storyboard that keeps its hook device, structure, jump cuts and pacing while swapping in THIS brand\'s product, cast, setting and words — never the original\'s words, face or brand. The new ad MATCHES THE ORIGINAL\'S LENGTH (capped at 60s) unless durationSeconds is given. Renders nothing: pass the returned creative to render_ad to make the video. Costs the plan plus about 2 credits to read the link. The reply says exactly what was watched, and when a platform will not hand over the footage (YouTube sometimes refuses servers) it says the plan rests on the captions and thumbnail only. For a local file, upload_file it first and pass the URL.',
|
|
23005
23017
|
inputSchema: {
|
|
23006
|
-
url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X
|
|
23018
|
+
url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X link, or a direct https video file URL (not YouTube, which cannot be downloaded); ' + MEDIA_PARAM_NOTE),
|
|
23007
23019
|
product: z.string().optional().describe('what the new ad sells, plus any angle or offer; omit to use the saved brand\'s product'),
|
|
23008
23020
|
changes: z.string().optional().describe('what to change or keep from the original, in the user\'s words (e.g. "same hook but in a gym", "keep the jump cut, older creator")'),
|
|
23009
23021
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace\'s saved brand (see get_brand)'),
|
|
@@ -23023,7 +23035,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23023
23035
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
23024
23036
|
}, wrap(async ({ url, product, changes, brand, durationSeconds, language }) => {
|
|
23025
23037
|
const link = String(url || '').trim();
|
|
23026
|
-
if (!/^https?:\/\//i.test(link)) return ok('clone_video needs an https:// link to the video (a TikTok, Reel, Facebook
|
|
23038
|
+
if (!/^https?:\/\//i.test(link)) return ok('clone_video needs an https:// link to the video (a TikTok, Reel, Facebook or X post, or a direct file; YouTube links cannot be downloaded, so for a YouTube video the user downloads it and you upload_file it). For a file on this machine, call upload_file first and pass the URL it returns. Nothing was charged.');
|
|
23027
23039
|
const _askedLen = +durationSeconds > 0 ? Math.round(+durationSeconds) : 0;
|
|
23028
23040
|
const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
|
|
23029
23041
|
let brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
|
|
@@ -23084,7 +23096,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23084
23096
|
server.group('create');
|
|
23085
23097
|
server.registerTool('generate_image', {
|
|
23086
23098
|
title: 'Generate ad image',
|
|
23087
|
-
description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched.
|
|
23099
|
+
description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product, or an ANIMATED GIF (format gif) or SVG file (format svg). It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. The saved brand\u2019s product photo is used automatically (see get_brand / list_product_photos), so never ask the user to upload it again. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. GIF / SVG run on gpt-image-2.6 / 2.6-flare from the prompt alone (no refImages); frames + frameMs shape a GIF. RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
|
|
23088
23100
|
inputSchema: {
|
|
23089
23101
|
prompt: z.string().optional().describe('REQUIRED on every model EXCEPT the pose rows below. the full image prompt — subject, composition, lighting, and any on-image ad text. ON A POSE MODEL (product-in-hand / try-on) THIS IS EXTRA DIRECTION AND IT IS OPTIONAL — leave it out and the pose is built for you. If you do write one, DESCRIBE THE POSE ("she holds the bottle upright in her right hand at chest height, label to camera"); do NOT phrase it as a swap ("replace the mug with the bottle"), which is REFUSED for free, because the product then comes out the size of whatever it replaced — a 30ml bottle rendered mug-sized in testing.'),
|
|
23090
23102
|
refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in. ON THE POSE MODELS — any row hermoso_capabilities marks `needsRefs` with a `refsMax`, such as putting your product in someone’s hands or a virtual try-on — THE ORDER IS THE CONTRACT AND IT IS NOT A COMPOSITE: refImages[0] is the PERSON photo, and the rest (up to `refsMax` minus one) are the product or garment photos. Reversed, you get the product wearing the person. A 4th product is dropped and the reply says so. ' + LIKENESS_TERMS),
|
|
@@ -23324,7 +23336,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23324
23336
|
voice: z.string().optional().describe("ONE VOICE: a preset from the chosen engine (e.g. 'Aria'/'George' on eleven-v4, 'stokie_en' on seed-audio); omit for the engine default"),
|
|
23325
23337
|
lines: z.array(z.object({
|
|
23326
23338
|
speaker: z.string().optional().describe('who speaks; the same name keeps the same voice'),
|
|
23327
|
-
voice: z.string().optional().describe("that speaker's voice from the engine's list (or
|
|
23339
|
+
voice: z.string().optional().describe("that speaker's voice from the engine's list (or a voice id); omit to be given one"),
|
|
23328
23340
|
text: z.string().describe('what they say'),
|
|
23329
23341
|
})).optional().describe('DIALOGUE: the turns in speaking order'),
|
|
23330
23342
|
dryRun: z.boolean().optional().describe('true = the exact credits only, nothing made or charged'),
|
|
@@ -23368,7 +23380,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23368
23380
|
describe: z.string().describe("the sound in words: what makes it, where, how it moves, e.g. 'a fast whoosh past the ear' or 'a busy cafe, cups and low murmur'"),
|
|
23369
23381
|
seconds: z.number().optional().describe('length in seconds, 0.5-30 (default 5)'),
|
|
23370
23382
|
loop: z.boolean().optional().describe('true for a loopable bed'),
|
|
23371
|
-
engine: z.string().optional().describe("omit for 'seed-audio'
|
|
23383
|
+
engine: z.string().optional().describe("omit for 'seed-audio', or 'eleven-sfx'"),
|
|
23372
23384
|
dryRun: z.boolean().optional().describe('true = the exact credits only, nothing made or charged'),
|
|
23373
23385
|
},
|
|
23374
23386
|
outputSchema: {
|
|
@@ -23432,7 +23444,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23432
23444
|
server.group('create');
|
|
23433
23445
|
server.registerTool('render_ad', {
|
|
23434
23446
|
title: 'Render ad video',
|
|
23435
|
-
description: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product
|
|
23447
|
+
description: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). UGC and cinematic plans render from a painted STORYBOARD (a cinematic spot paints a location anchor first, and a hero when someone is on camera); a product-only spot renders from the real product photo, and board:true or foundations:\'choose\' adds an identity sheet, a moodboard and a board. A plan that fits one clip of the render model renders as one continuous pass; a longer one as STITCHED ACTS (the fewest balanced clips), never time-compressed. Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. The brand\u2019s saved product photo (get_brand / list_product_photos) is the real product in the shots, with no upload needed. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
|
|
23436
23448
|
inputSchema: {
|
|
23437
23449
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
23438
23450
|
creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand (casting skips the portrait we would otherwise generate). Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
|
|
@@ -23583,7 +23595,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23583
23595
|
};
|
|
23584
23596
|
server.registerTool('make_motion_film', {
|
|
23585
23597
|
title: 'Make a motion graphics video',
|
|
23586
|
-
description: "MOTION GRAPHICS and ANIMATED VIDEO in one call: kinetic typography (animated text), a logo animation or intro, a product launch film, an animated social cut. MOVING PICTURES are the default for launch, product and social: in each scene with a picture, one thing physically happens (a seed splits open, an eye opens, the view travels through a forest), filmed by an image-to-video model, with the type, transitions and the music's beat put on top; a few minutes, priced per shot, and with no pictures given each scene paints its own. moving:false, and always for type and logo, makes it in code with no video model: type, pictures and drawn graphics filmed by a moving virtual camera, a 1080p MP4 in about a minute, billed for its compute only. Pick it for 'animated video', 'motion graphics', 'kinetic text', 'text animation', 'logo animation', 'logo intro', 'animated explainer' asks. recipe: 'type' (every line its own animated card, no picture needed), 'logo' (the logo assembles out of dots and settles, the name and tagline land under it), 'launch' (30s 16:9), 'product' (15s), 'social' (15s 9:16). Or `spec`, the whole film as JSON (help:true returns the language). STEPS: without confirm it is a FREE quote that gives both prices (length, the music's tempo; the spec in structuredContent): say both; preview:true is a 1-credit contact sheet of the still version; confirm:true renders. You may write `moves` yourself, or the film's own planner writes them
|
|
23598
|
+
description: "MOTION GRAPHICS and ANIMATED VIDEO in one call: kinetic typography (animated text), a logo animation or intro, a product launch film, an animated social cut. MOVING PICTURES are the default for launch, product and social: in each scene with a picture, one thing physically happens (a seed splits open, an eye opens, the view travels through a forest), filmed by an image-to-video model, with the type, transitions and the music's beat put on top; a few minutes, priced per shot, and with no pictures given each scene paints its own. moving:false, and always for type and logo, makes it in code with no video model: type, pictures and drawn graphics filmed by a moving virtual camera, a 1080p MP4 in about a minute, billed for its compute only. Pick it for 'animated video', 'motion graphics', 'kinetic text', 'text animation', 'logo animation', 'logo intro', 'animated explainer' asks. recipe: 'type' (every line its own animated card, no picture needed), 'logo' (the logo assembles out of dots and settles, the name and tagline land under it), 'launch' (30s 16:9), 'product' (15s), 'social' (15s 9:16). Or `spec`, the whole film as JSON (help:true returns the language). STEPS: without confirm it is a FREE quote that gives both prices (length, the music's tempo; the spec in structuredContent): say both; preview:true is a 1-credit contact sheet of the still version; confirm:true renders. You may write `moves` yourself, or the film's own planner writes them (that planner call is priced in the quote). Only the user's or the brand's words appear: never invent a claim, number or review. projectUrl = a past film's url returns its saved spec to edit and render again.",
|
|
23587
23599
|
inputSchema: {
|
|
23588
23600
|
recipe: z.enum(['type', 'logo', 'launch', 'product', 'social']).optional().describe('type, logo, launch, product or social; help:true lists the recipes'),
|
|
23589
23601
|
hook: z.string().optional().describe("opening line; '*word*' = italic accent"),
|
|
@@ -23687,7 +23699,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23687
23699
|
|
|
23688
23700
|
server.registerTool('post_edit', {
|
|
23689
23701
|
title: 'Post-production edit',
|
|
23690
|
-
description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg
|
|
23702
|
+
description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card, trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, music (a bed under the clip, ducked: omit track = a free library track picked by mood, no attribution; or track = an audio link: an upload_file URL, a find_sound or generate_sfx link, a video post's sound; track 'generate' composes it, paid, ONLY when the user asks; start/end, db, replace), voiceover (text spoken OVER it, its sound ducked; ~2.5 words/s; voice female|male; +~1 credit), watermark (brand logo), grain (anti-AI), text (timed words in a native look: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle; start/end, or at:{word} and until:{word} to time them to a spoken phrase), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video). Presets are shortcuts: x/y, grain amount, join transition and bridge sound are free parameters. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound FROM THE HOOK ITSELF: its own audio carries across the cut, else one generated from its frames (up to 8 credits), else a neutral impact. {kind:'text', text, then?} only when the user asks for words over the cut. ANY OTHER EDIT, op or bridge (whip, zoom, freeze, wipe, split screen, generated shot) is edit_timeline: free keyframes, any size. NEVER generate_video/render_ad for these. Feedback maps to one named move, never a re-render: quieter/louder (music db, audio_gain), faster/slower (speed; slower = zooms and pans at 0.7x), tighten (trim), retime (to a word anchor), recut (order, in-out points), regrade (timeline exposure/contrast/saturation keys).",
|
|
23691
23703
|
inputSchema: {
|
|
23692
23704
|
videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link; ' + MEDIA_PARAM_NOTE),
|
|
23693
23705
|
ops: z.array(z.object({
|
|
@@ -23759,7 +23771,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23759
23771
|
title: 'Compose an edit (timeline)',
|
|
23760
23772
|
description: "Compose ANY edit or transition yourself; there is no preset list. Segments on an output timeline (later ones drawn on top; overlapping ones ARE the transition), each animated by keyframes, plus your own HTML overlays and masks, compiled server-side into one render. Whip pan, zoom through, push, spin, speed ramp, slow motion, freeze frame, reverse, flash, J-cut / L-cut, circle or shape wipe, split screen, picture in picture: all composed from the fields below. Local render, the flat post_edit price (~2 credits, overlays included); a `generate` segment (a paid generated transition shot) only when the user explicitly asks for one. "
|
|
23761
23773
|
+ "LOOK FIRST AND AFTER: video_frames(url, start, end, fps) shows exact frames free, so find the moment (the second the egg tips) before you cut, and check the seam of what you rendered before you call it done. "
|
|
23762
|
-
+ "SOURCES: src is 'this' (videoUrl) or any Library / render URL, direct file or PUBLIC TikTok / Reel / Facebook / X
|
|
23774
|
+
+ "SOURCES: src is 'this' (videoUrl) or any Library / render URL, direct file or PUBLIC TikTok / Reel / Facebook / X post link (a viral hook's link works as is; a YouTube link is refused, YouTube does not let us download videos, so upload the file instead). in/out are SOURCE seconds; out:'payoff' cuts just before the hook's payoff, found from its motion. "
|
|
23763
23775
|
+ "AUDIO: audio.tail:'payoff' carries the hook's OWN payoff sound across the cut (the egg's real splat over our clip); tail seconds = an L-cut; lead = a J-cut; gain dB or keys; mute. "
|
|
23764
23776
|
+ "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
|
|
23765
23777
|
+ "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
|
|
@@ -23852,7 +23864,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23852
23864
|
|
|
23853
23865
|
server.registerTool('fix_beat', {
|
|
23854
23866
|
title: 'Fix a video beat',
|
|
23855
|
-
description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this. A FINISHED VIDEO-BLOCKS FILM (make_explainer's explainer or faceless channel; get_job says it is editable) is edited ONE BLOCK at a time instead: pass `job` (its job id) and `block` (1-based) or `atSeconds`, with `prompt` for a different shot and/or `line` for new narration words. Only that block is re-rendered and/or re-read; every other block, take, the music and the captions are the film's own and stay aligned. It is priced as that block alone and charged exactly what it quotes (dryRun). The original film is kept; a new film comes back, and it can be edited again. Feedback maps to one named move,
|
|
23867
|
+
description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this. A FINISHED VIDEO-BLOCKS FILM (make_explainer's explainer or faceless channel; get_job says it is editable) is edited ONE BLOCK at a time instead: pass `job` (its job id) and `block` (1-based) or `atSeconds`, with `prompt` for a different shot and/or `line` for new narration words. Only that block is re-rendered and/or re-read; every other block, take, the music and the captions are the film's own and stay aligned. It is priced as that block alone and charged exactly what it quotes (dryRun). The original film is kept; a new film comes back, and it can be edited again. Feedback maps to one named move, never a re-render: quieter/louder (music db, audio_gain), faster/slower (speed; slower = zooms and pans at 0.7x), tighten (trim), retime (to a word anchor), recut (order, in-out points), regrade (timeline exposure/contrast/saturation keys).",
|
|
23856
23868
|
inputSchema: {
|
|
23857
23869
|
videoUrl: z.string().optional().describe('the served URL of the master video to fix (not needed with `job`)'),
|
|
23858
23870
|
startSeconds: z.number().optional().describe('window start in seconds (not needed with `job`)'),
|
|
@@ -23894,9 +23906,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23894
23906
|
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
23895
23907
|
server.registerTool('clip_video', {
|
|
23896
23908
|
title: 'Clip a long video',
|
|
23897
|
-
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a
|
|
23909
|
+
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED watch link (NOT YouTube: YouTube does not let us download videos, so for a YouTube video the user downloads it and uploads the file), a direct https .mp4/.mov/.webm, or 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 ~15s to ~600MB; only the first ~40 minutes is analysed (truncated:true says so). Cost: a ~7-credit hold settled to the exact transcription + encode cost, plus the clip-selection model's tokens as their own small event. RETURNS clips[] — each its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode. SUBTITLES ARE BURNED IN BY DEFAULT (slim white CAPS, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE (per-sentence, not word-level); never promise frame-accurate sync. captionsBurned counts the clips that really carry a burned track and captionNote says why any are bare.",
|
|
23898
23910
|
inputSchema: {
|
|
23899
|
-
video: z.string().describe('the long video to clip — a
|
|
23911
|
+
video: z.string().describe('the long video to clip — a Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL; ' + MEDIA_PARAM_NOTE),
|
|
23900
23912
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
23901
23913
|
aspectRatio: z.string().optional().describe("clip shape: '9:16' (default), '1:1', '16:9', any 'W:H', or 'keep' for the source framing"),
|
|
23902
23914
|
captions: z.boolean().optional().describe('burn subtitles into every clip. DEFAULT TRUE — a clip cut from a podcast or a talk is watched on mute, and the words are the product. Set false for clean footage. A clip whose window carries no readable speech is delivered bare rather than captioned with a guess, and the result says which.'),
|
|
@@ -23926,7 +23938,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23926
23938
|
// upload or someone else's video had no way to get them. The look is the same textStyle vocabulary render_ad speaks.
|
|
23927
23939
|
server.registerTool('add_subtitles', {
|
|
23928
23940
|
title: 'Add subtitles to a video',
|
|
23929
|
-
description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Only when asked, textStyle 'highlight' (or a highlightColor) lights the word being said, timed word by word. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a
|
|
23941
|
+
description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Only when asked, textStyle 'highlight' (or a highlightColor) lights the word being said, timed word by word. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a Vimeo-style page, or a TikTok / Reel / X post link (not YouTube: YouTube does not let us download videos, so upload the file). No speech is refused and refunded. Runs in the background and lands in the Library.",
|
|
23930
23942
|
inputSchema: {
|
|
23931
23943
|
video: z.string().describe('the video to subtitle; ' + MEDIA_PARAM_NOTE),
|
|
23932
23944
|
textStyle: TEXT_STYLE_Z.optional().describe('the look: words, a preset or fields; omit for the default'),
|
|
@@ -23954,7 +23966,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23954
23966
|
|
|
23955
23967
|
server.registerTool('make_explainer', {
|
|
23956
23968
|
title: 'Make an explainer video',
|
|
23957
|
-
description: "AI HOST EPISODE: format 'host_episode' = one presenter talking to camera, the camera changing every piece (frontal, three-quarter, close), the same host and set throughout, native voice, 16:9. Host = `creator` (saved or preset) or `hostImage`; words = `topic` (written for you) or `script` (verbatim). It returns a 480p DRAFT; HD (720p) is a SEPARATE call with `fromDraft` (quote it with dryRun, run it only when the user asks). Otherwise: turn a TOPIC into a finished narrated explainer video, in one of TWO LANES (`lane`). 'blocks' (the default) = 10-second VIDEO blocks, one narrated line per block, hard cuts, a music bed under the voice: an EXPLAINER renders on Gemini Omni at 720p (9:16 by default, or 16:9), a FACELESS CHANNEL video (`format:'faceless_channel'`, or channel history / kids / fairytale) on MiniMax H3 at 2K (16:9 by default) with five cuts per block. 'stills' = a picture film: writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s
|
|
23969
|
+
description: "AI HOST EPISODE: format 'host_episode' = one presenter talking to camera, the camera changing every piece (frontal, three-quarter, close), the same host and set throughout, native voice, 16:9. Host = `creator` (saved or preset) or `hostImage`; words = `topic` (written for you) or `script` (verbatim). It returns a 480p DRAFT; HD (720p) is a SEPARATE call with `fromDraft` (quote it with dryRun, run it only when the user asks). Otherwise: turn a TOPIC into a finished narrated explainer video, in one of TWO LANES (`lane`). 'blocks' (the default) = 10-second VIDEO blocks, one narrated line per block, hard cuts, a music bed under the voice: an EXPLAINER renders on Gemini Omni at 720p (9:16 by default, or 16:9), a FACELESS CHANNEL video (`format:'faceless_channel'`, or channel history / kids / fairytale) on MiniMax H3 at 2K (16:9 by default) with five cuts per block. 'stills' = a picture film: writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s, mostly one-detail edits of the frame before), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (motion is the CUT RATE), then composites the end card (and any text you asked for). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT; the film ships clean unless the user asks; `captions:true` adds held key points and `subtitles:true` adds subtitles timed to the narration. `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 renders ONE style-key image and locks every scene to it. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact spend. Takes SEVERAL minutes — one image render per frame; NOT for a short product ad: use render_ad, generate_video or make_template_ad.",
|
|
23958
23970
|
inputSchema: {
|
|
23959
23971
|
topic: z.string().optional().describe('what the explainer should teach or explain — a topic or a short brief (host_episode: or pass `script`)'),
|
|
23960
23972
|
lane: z.enum(['blocks', 'stills']).optional().describe("'blocks' (default) = 10-second video blocks, real motion, one narrated line per block; 'stills' = the picture film (a still about every 1.5s, narrated, no video model; cheaper). Quote either with dryRun."),
|
|
@@ -23973,7 +23985,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23973
23985
|
channel: z.enum(['explainer', 'history', 'kids', 'fairytale']).optional().describe("the CHANNEL TYPE — it sets the pacing, the narration register and the default look, and is orthogonal to `style` (a named style always wins): explainer (casual second-person, fast cuts), history (witty chronological retelling / documentary), kids (fastest, question-first, warm teacher), fairytale (slow, atmospheric myth or folklore). Default 'explainer'."),
|
|
23974
23986
|
voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
|
|
23975
23987
|
captions: z.boolean().optional().describe('turn ON-SCREEN TEXT on. DEFAULT FALSE, and leave it false unless the user asks — the narration already says the point and the pictures carry it, so the clean film is the better default. `captions:true` on its own burns SUBTITLES (see below), because that is what a caption is for: showing what is being said when the phone is on mute. Slim white text (CAPS in the stills lane), thin black outline, bottom safe band, no plate, no box.'),
|
|
23976
|
-
subtitles: z.boolean().optional().describe('which on-screen text, once `captions` is on. LEAVE IT UNSET (or true) for SUBTITLES — every spoken word, in order, timed to the narration; no extra render, and there is NO cue limit, so the whole film is subtitled however long it runs (at most 4 words and one line a cue; 5 words / 32 characters in stills). Set it FALSE only if the user explicitly wants section HEADINGS instead: one short summary label held over each ~7-15s section. That is NOT what is being said — it is a label about it — so it is the wrong answer to "add captions" and to anyone watching on mute. `subtitles:true` also implies `captions:true`. TIMING: in the default blocks lane each cue is timed to its own spoken words (one listen to the narration,
|
|
23988
|
+
subtitles: z.boolean().optional().describe('which on-screen text, once `captions` is on. LEAVE IT UNSET (or true) for SUBTITLES — every spoken word, in order, timed to the narration; no extra render, and there is NO cue limit, so the whole film is subtitled however long it runs (at most 4 words and one line a cue; 5 words / 32 characters in stills). Set it FALSE only if the user explicitly wants section HEADINGS instead: one short summary label held over each ~7-15s section. That is NOT what is being said — it is a label about it — so it is the wrong answer to "add captions" and to anyone watching on mute. `subtitles:true` also implies `captions:true`. TIMING: in the default blocks lane each cue is timed to its own spoken words (one listen to the narration, inside the quote; per-block timing, said, if it fails). In the stills lane cues are spread over each section’s measured narration by character count, exact at section edges, a few tenths off within one: it is not a word-level speech clock, so never promise frame-accurate sync there.'),
|
|
23977
23989
|
music: z.string().optional().describe("music bed under the narration, measured to sit about 14 dB under the voice and sidechain-ducked beneath it. Omit and the KIDS and FAIRYTALE channels get their recommended bed COMPOSED for this film — those two are the only channels a bed is due on unasked, and it costs a small flat fee; every other channel ships dry. 'off' forces silence. 'library' takes a free curated track only, and ships dry when none is on file. NAME A MOOD (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic) or DESCRIBE it in words to compose one on ANY channel, at the same fee. hermoso_capabilities reports the exact figure as explainerMusicCredits; quote it before you turn a bed on or pick a mood."),
|
|
23978
23990
|
upscale: z.number().optional().describe("optional FINAL upscale — 2 doubles each side, 4 quadruples. Captions and the end card are burned BEFORE it so they upscale with the frame. It is priced BY LENGTH and it is the expensive part — several times the cost of rendering the film itself. hermoso_capabilities reports the exact figures per length as explainerUpscaleCredits. Never turn it on unasked: quote the number and let the user choose."),
|
|
23979
23991
|
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
|
|
@@ -23986,7 +23998,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23986
23998
|
look: z.enum(['corner', 'badge', 'split']).optional().describe("'corner' (default): cut out of a green screen, standing in a bottom corner; 'badge': a round badge in a corner; 'split': the block on one half, the presenter on the other"),
|
|
23987
23999
|
position: z.string().optional().describe("'br' (default), 'bl', 'tr' or 'tl' (a corner cutout stays at the bottom); for split, 'bl' puts the presenter first"),
|
|
23988
24000
|
scale: z.number().optional().describe('the share of the frame the presenter takes, 0.2-0.8 (default: the look\'s own size)'),
|
|
23989
|
-
voice: z.string().optional().describe("the presenter
|
|
24001
|
+
voice: z.string().optional().describe("a voice to re-voice the presenter to, only when the user names one (default: the presenter's own voice from the video model)"),
|
|
23990
24002
|
}).optional().describe('with fromFilm: who presents and how. A face the video model flags stops it, nothing charged, with the choices to offer.'),
|
|
23991
24003
|
},
|
|
23992
24004
|
outputSchema: { ...JOB_OUT },
|
|
@@ -24077,7 +24089,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24077
24089
|
};
|
|
24078
24090
|
server.registerTool('make_series', {
|
|
24079
24091
|
title: 'Make a series of faceless short videos (Content Factory)',
|
|
24080
|
-
description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel /
|
|
24092
|
+
description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel / Facebook / X link or a video file URL whose look, pacing and opening hook every episode copies, with plain subtitles only if the reference burns its own; never its words or characters). Find a trending reference with search_tiktok / search_instagram / search_youtube when the user wants one. THREE STEPS, ALWAYS IN THIS ORDER: (1) call with no `confirm` for a FREE quote of the whole series (per episode and total); (2) optionally `planOnly:true` to see the episode titles and the style card first (a small planning charge); (3) `confirm:true` (pass the `plan` from step 2 to reuse it) queues every episode and returns their job ids; poll get_job. To make MORE of a series later, pass its returned plan as `moreOf`: same style, no episode repeated, the reference not watched again. Each finished episode can be scheduled with this tool again: pass `jobIds` plus `schedule` {channels, at, everyHours, timezone} and it books one post per finished episode, spaced `everyHours` apart (default 24). Captions follow the reference (on if it burns words on screen) unless `captions` says otherwise.',
|
|
24081
24093
|
inputSchema: {
|
|
24082
24094
|
topic: z.string().optional().describe('what the series is about, in the user\'s words'),
|
|
24083
24095
|
episodes: z.number().optional().describe('how many episodes, 1-10 (default 1)'),
|
|
@@ -24230,7 +24242,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24230
24242
|
|
|
24231
24243
|
server.registerTool('generate_video', {
|
|
24232
24244
|
title: 'Generate video',
|
|
24233
|
-
description: 'Use this when the user asks for a video clip, b-roll or a shot from a description, or names a video model. For a finished video ad, Reel, TikTok ad or UGC video for their business, use render_ad. It renders one continuous clip on a real video model and returns its mp4 URL. A clip runs up to the model’s longest listed duration (the longest takes a multi-beat 30s spot in ONE take with native audio), so never assume an 8–10s cap or stitch what fits one clip; durationSeconds must be one of the model’s durations in hermoso_capabilities. NAME A MODEL in `model` to get it: an unnamed render uses a narrower auto-pool. 1–3 min. refImage anchors the first frame; ttsScript adds a voiceover. AUDIO IS NOT FREE BY DEFAULT: a clip with no audio of its own gets a music bed composed and CHARGED on top (see musicMood and audio), which can cost as much as a cheap draft clip. refVideo (a clip URL) EDITS that clip instead, keeping its canvas and length. RAW MODEL ACCESS: by default small guards are added (label safety with no reference image, a negative prompt where the model takes one, reference lines) and hex colours become names; ' + RAW_TOOL_NOTE + ' Spends credits (blocked on the Starter plan).',
|
|
24245
|
+
description: 'Use this when the user asks for a video clip, b-roll or a shot from a description, or names a video model. For a finished video ad, Reel, TikTok ad or UGC video for their business, use render_ad. A GIF is generate_image format gif. It renders one continuous clip on a real video model and returns its mp4 URL. A clip runs up to the model’s longest listed duration (the longest takes a multi-beat 30s spot in ONE take with native audio), so never assume an 8–10s cap or stitch what fits one clip; durationSeconds must be one of the model’s durations in hermoso_capabilities. NAME A MODEL in `model` to get it: an unnamed render uses a narrower auto-pool. 1–3 min. refImage anchors the first frame; ttsScript adds a voiceover. AUDIO IS NOT FREE BY DEFAULT: a clip with no audio of its own gets a music bed composed and CHARGED on top (see musicMood and audio), which can cost as much as a cheap draft clip. refVideo (a clip URL) EDITS that clip instead, keeping its canvas and length. RAW MODEL ACCESS: by default small guards are added (label safety with no reference image, a negative prompt where the model takes one, reference lines) and hex colours become names; ' + RAW_TOOL_NOTE + ' Spends credits (blocked on the Starter plan).',
|
|
24234
24246
|
inputSchema: {
|
|
24235
24247
|
dryRun: z.boolean().optional().describe('true = render NOTHING and return the credits this exact clip would hold (an estimate; the final charge is its actual cost). Free. Call this to price a clip before running it: without it the call renders and bills.'),
|
|
24236
24248
|
prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
|
|
@@ -26491,8 +26503,29 @@ function memoryNoteVerdict(text) {
|
|
|
26491
26503
|
// so the text says so before it says what the create tools do with the profile — the same note the web Studio reads.
|
|
26492
26504
|
const who = d?.personaNote ? `\n${d.personaNote}` : '';
|
|
26493
26505
|
const _pt = d?.profileType ? ` · ${d.profileType} profile` : '';
|
|
26506
|
+
// WHAT THE BRAND HOLDS, IN WORDS (2026-11-06): the product photos, logo, palette and product names were in
|
|
26507
|
+
// structuredContent only, and an agent reading the TEXT told a user the brand had no product photo while one was saved.
|
|
26508
|
+
const _brandFacts = (b) => {
|
|
26509
|
+
if (!b || typeof b !== 'object') return '';
|
|
26510
|
+
const _s = (x) => (typeof x === 'string' ? x.trim() : '');
|
|
26511
|
+
const imgs = [...new Set((Array.isArray(b.productImages) ? b.productImages : []).map((x) => _s(typeof x === 'string' ? x : x?.url)).filter(Boolean))];
|
|
26512
|
+
const hero = _s(b.product) || imgs[0] || '';
|
|
26513
|
+
const titles = new Map((Array.isArray(b.productTitles) ? b.productTitles : []).filter((x) => x && x.image).map((x) => [x.image, _s(x.title)]));
|
|
26514
|
+
const all = hero && !imgs.includes(hero) ? [hero, ...imgs] : imgs;
|
|
26515
|
+
const lines = [];
|
|
26516
|
+
if (all.length) {
|
|
26517
|
+
const ordered = [hero, ...all.filter((u) => u !== hero)].filter(Boolean);
|
|
26518
|
+
lines.push(`Product photos: ${ordered.length} saved (never ask the user to upload them again; plan_ad / render_ad / generate_image ground on the primary one automatically). Primary (hero): ${abs(ordered[0])}${titles.get(ordered[0]) ? ' (' + titles.get(ordered[0]) + ')' : ''}.` + (ordered.length > 1 ? ' Others: ' + ordered.slice(1, 8).map((u) => abs(u) + (titles.get(u) ? ' (' + titles.get(u) + ')' : '')).join('; ') + (ordered.length > 8 ? ` (+${ordered.length - 8} more, list_product_photos)` : '') + '.' : ''));
|
|
26519
|
+
} else lines.push('Product photos: none saved (set_product_image or draft_brand with confirmed:true adds them).');
|
|
26520
|
+
if (_s(b.logo)) lines.push(`Logo: ${abs(_s(b.logo))}`);
|
|
26521
|
+
const pal = (Array.isArray(b.palette) ? b.palette : []).map((c) => _s(typeof c === 'string' ? c : c?.hex || c?.color)).filter(Boolean);
|
|
26522
|
+
if (pal.length) lines.push(`Palette: ${pal.slice(0, 8).join(', ')}`);
|
|
26523
|
+
const prods = (Array.isArray(b.products) ? b.products : []).map((x) => _s(typeof x === 'string' ? x : x?.title || x?.name)).filter(Boolean);
|
|
26524
|
+
if (prods.length) lines.push(`Products: ${prods.slice(0, 12).join(', ')}`);
|
|
26525
|
+
return lines.length ? '\n' + lines.join('\n') : '';
|
|
26526
|
+
};
|
|
26494
26527
|
const text = d?.hasBrand
|
|
26495
|
-
? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''}${_pt} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${who}`
|
|
26528
|
+
? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''}${_pt} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${_brandFacts(d.brand)}${who}`
|
|
26496
26529
|
: (d?.brand && d?.profileType === 'explore')
|
|
26497
26530
|
? `The profile “${d.brand.name || d.brand.domain}” is saved but set to just exploring, so nothing you make uses it. Make what they ask with no brand; switch it back with update_brand profileType (website, social or describe) only if THEY ask for on-brand work.${who}`
|
|
26498
26531
|
: (d?.persona === 'explorer'
|
|
@@ -26618,13 +26651,14 @@ function memoryNoteVerdict(text) {
|
|
|
26618
26651
|
|
|
26619
26652
|
server.registerTool('list_library', {
|
|
26620
26653
|
title: 'List library',
|
|
26621
|
-
description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
|
|
26654
|
+
description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), a human TITLE (the ad's headline or concept, the prompt's first words, or the upload's name), kind, model, and age. When the user names an ad by its headline (\"the Afternoon fix ad\"), pass `query` to find it by title. Free, read-only.",
|
|
26622
26655
|
inputSchema: {
|
|
26623
26656
|
kind: z.enum(['image', 'video', 'document', 'all']).optional().describe("filter by asset kind (default 'all')"),
|
|
26624
26657
|
uploads: z.boolean().optional(),
|
|
26658
|
+
query: z.string().optional().describe('only assets whose title contains this text (case-insensitive), e.g. an ad headline the user quoted'),
|
|
26625
26659
|
limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
|
|
26626
26660
|
},
|
|
26627
|
-
outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
|
|
26661
|
+
outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), title: z.string().optional(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
|
|
26628
26662
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
26629
26663
|
}, wrap(async (a) => {
|
|
26630
26664
|
let list = await readStore('heist.assets.v1'); // via /api/store/bootstrap — there is no GET /api/store/:key route
|
|
@@ -26633,10 +26667,21 @@ function memoryNoteVerdict(text) {
|
|
|
26633
26667
|
const lim = Math.min(60, Math.max(1, +a.limit || 20));
|
|
26634
26668
|
// id: L0, L1… over the whole Library newest first (a kind filter does not renumber), the id plan_ad / render_ad `inserts` take
|
|
26635
26669
|
const up = a.uploads === true;
|
|
26636
|
-
|
|
26637
|
-
|
|
26670
|
+
// A HUMAN TITLE ON EVERY ROW (2026-11-06): a user quotes an ad by its headline and the Library had only ids and urls.
|
|
26671
|
+
// Newer rows carry `title`; older ones are named from their linked creation (headline, concept, title) or their concept.
|
|
26672
|
+
let _cr = []; try { const c = await readStore('heist.creations.v1'); if (Array.isArray(c)) _cr = c; } catch {}
|
|
26673
|
+
const _crById = new Map(_cr.filter((c) => c && c.id).map((c) => [c.id, c]));
|
|
26674
|
+
const _clean = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
26675
|
+
const titleOf = (x) => {
|
|
26676
|
+
const c = x.creationId ? _crById.get(x.creationId) : null;
|
|
26677
|
+
return _clean(x.title) || (x.source === 'upload' ? _clean(x.name) : '') || _clean(c?.title) || _clean(c?.creative?.headline) || _clean(c?.creative?.concept) || _clean(x.concept) || _clean(c?.product) || '';
|
|
26678
|
+
};
|
|
26679
|
+
const q = String(a.query || '').trim().toLowerCase();
|
|
26680
|
+
const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i, title: titleOf(x) })).filter(({ x, title }) => (!kind || x.kind === kind) && (!up || x.source === 'upload') && (!q || (title + ' ' + _clean(x.concept) + ' ' + _clean(x.name)).toLowerCase().includes(q))).slice(0, lim)
|
|
26681
|
+
.map(({ x, id, title }) => ({ id, url: abs(x.url), ...(title ? { title } : {}), kind: x.kind || '', model: x.model || '', ...(x.source === 'upload' ? { source: 'upload', ...(x.name ? { name: String(x.name).slice(0, 120) } : {}) } : {}), ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
|
|
26682
|
+
if (!assets.length && q) return ok(`No Library asset has a title containing “${a.query}”. Call list_library without query to see every title.`, { assets: [] });
|
|
26638
26683
|
if (!assets.length) return ok(up ? 'Nothing uploaded to this workspace\'s Library yet — upload_file keeps an image, video or PDF there.' : 'The Library is empty for this workspace — render something or upload_file a file first.', { assets: [] });
|
|
26639
|
-
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
26684
|
+
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. ${x.title ? '“' + x.title + '” ' : ''}[${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
26640
26685
|
}));
|
|
26641
26686
|
|
|
26642
26687
|
// DELETE FROM THE LIBRARY (2026-10-23). A customer was told it could not be done and sent to support. The server
|
|
@@ -27082,9 +27127,9 @@ function memoryNoteVerdict(text) {
|
|
|
27082
27127
|
// motion and the ORIGINAL AUDIO untouched. The plan is one server call; every variant is a `multiply` job (opening-frame keyframe edit → motion transfer driven by the source, so length, cut, motion and audio are the original's).
|
|
27083
27128
|
server.registerTool('multiply_ad', {
|
|
27084
27129
|
title: 'Multiply an ad',
|
|
27085
|
-
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were
|
|
27130
|
+
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and any burned-in captions are removed. Pass the source video URL (a render, job result, list_library, or the top performer from post_performance / meta_insights). HOW: the source video itself DRIVES each variant (motion transfer from one image-edited opening frame), so every variant keeps the source's length, cut and audio; only the person, outfit, set and props change. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). ONE JOB PER VARIANT: get_job each until done; never describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. images takes up to 9 of the user's product, people, clothes or places, and change names them @Image1, @Image2 in order ('she holds the bottle in @Image1'); the saved brand's product photo is read as a reference to its real look (useBrand:false leaves it out). A TikTok, Reel, Facebook or X post link works as the source (not YouTube: upload the file): one paid lookup reads it into a file. quality: 1080p (default) or 720p; quoteOnly prices both. Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is separate: dub_video on a finished variant. `change` is what should change, in the user's own words ('older women', 'winter streets', 'swap the mug for our bottle'): every variant applies it and the variants still differ around it; a part of it about the voice, words, language, music, length or captions cannot change here, and the reply says so and names the tool that can.",
|
|
27086
27131
|
inputSchema: {
|
|
27087
|
-
video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook
|
|
27132
|
+
video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook or X post link (not YouTube)'),
|
|
27088
27133
|
images: z.array(z.union([z.string(), z.object({ url: z.string(), role: z.string().optional().describe('what it is, in your words, e.g. "our founder"') })])).optional().describe('up to 9 pictures to put in the variants, in the order @Image1, @Image2 name them: URLs or local paths'),
|
|
27089
27134
|
quality: z.enum(['1080p', '720p']).optional().describe('1080p is the default; 720p costs less, and hands holding a product are less reliable at 720p'),
|
|
27090
27135
|
useBrand: z.boolean().optional().describe('false = send no brand product photo (for a video that is not this workspace brand)'),
|
|
@@ -27246,7 +27291,7 @@ function memoryNoteVerdict(text) {
|
|
|
27246
27291
|
// the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
|
|
27247
27292
|
server.registerTool('recast_hook', {
|
|
27248
27293
|
title: 'Recast a viral hook with your subject',
|
|
27249
|
-
description: "RECAST A VIRAL HOOK with the brand's own subject (the egg rolling off the table becomes your product; your host does the viral move), then join it into your clip. voice/language: a new voice, or language lip-synced, for one person swapped in. hook: a public post link (TikTok, Reel, Facebook, X
|
|
27294
|
+
description: "RECAST A VIRAL HOOK with the brand's own subject (the egg rolling off the table becomes your product; your host does the viral move), then join it into your clip. voice/language: a new voice, or language lip-synced, for one person swapped in. hook: a public post link (TikTok, Reel, Facebook, X; one lookup; not YouTube, which cannot be downloaded) or a video file. Subject: creator (saved name or id), image (a product or person; a real person's photo records consent), or neither for the brand's first product photo. A DUO OR GROUP (2-4 people): subjects [{creator or image, position: 'left'|'right'|'center' or your own words, refs?, outfit?}], each bound to its place in the frame, keeping that performer's movement and timing on the original sound. LOCATION AND OUTFITS, same pass: location (a place photo or words) moves the scene; outfit (a photo or words) re-dresses the subject, subjects[].outfit each person; with no subject named nobody is swapped and only those change (useBrand:true also swaps in the brand product). Pictures are references (4 max; more in words). Person for person: Seedance 2.5 (720p) if the hook is our render or faceRoute is set, else Wan 3.0 (≤15s; Kling 4.0 Edit past). FULL LENGTH: past its one pass (30 s) the window is recast in passes, each continuing from the previous one's frame and crossfaded across the cut, under the original sound: one video up to 60 s, quoted as passes plus join. mode 'auto' is always 'swap' (a region edit: camera, set, light, timing, text and sound kept; a multi-shot hook goes shot by shot); 'motion' only if asked (your person moves in their photo's setting). swap: your own words for what changes. start/end pick the window (3 s minimum; default: a group or a location/outfit change takes the whole clip, one subject the first 10 s). PAID, QUOTED FIRST: no confirm returns the plan and credits; confirm:true once the user okays. Then get_job until done, and post_edit with videoUrl = the result and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
|
|
27250
27295
|
inputSchema: {
|
|
27251
27296
|
hook: z.string().describe('the viral hook: a post link or a video URL'),
|
|
27252
27297
|
creator: z.string().optional().describe("a saved creator's name or id (list_creators). NONE SAVED: cast a preset by exact name (list_creators), or pass image instead: a portrait of an AI person that generate_image painted from words (no reference photo) swaps in on every plan, nothing to save. A described lookalike of the user is fine on every plan; a photo of anyone is a real face (paid plan)"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.527",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1178 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 10 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|