hermoso 0.1.526 → 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 +34 -33
- 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
|
|
@@ -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'],
|
|
@@ -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'),
|
|
@@ -7855,7 +7855,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7855
7855
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
7856
7856
|
server.registerTool('post_to_x', {
|
|
7857
7857
|
title: 'Publish a post to X (Twitter)',
|
|
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.
|
|
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).',
|
|
7859
7859
|
inputSchema: {
|
|
7860
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."),
|
|
7861
7861
|
...HOOK_ATTR,
|
|
@@ -10754,7 +10754,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10754
10754
|
|
|
10755
10755
|
// ── MESSENGER MARKETING MESSAGES (2026-09-05). Meta's paid re-engagement channel: messages OUTSIDE the 24-hour
|
|
10756
10756
|
// window to people who opted in, billed by Meta to the brand's ad account per delivered message. Flow 2 of Meta's
|
|
10757
|
-
// 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
|
|
10758
10759
|
// APPROVED in App Review round 5 (2026-09-22); list_messenger_subscribers reports whether THIS connection carries it.
|
|
10759
10760
|
// Every endpoint, limit and error code: lib/messenger-marketing.mjs (read live 2026-09-05).
|
|
10760
10761
|
// MESSENGER MARKETING MESSAGES ARE A CHANNEL CAPABILITY, NOT PAID ADS (2026-09-07). This block was registered inside
|
|
@@ -10763,7 +10764,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10763
10764
|
server.group('channel_admin'); // Messenger marketing — the marker at the end of this block re-opens 'ads'
|
|
10764
10765
|
server.registerTool('list_messenger_subscribers', {
|
|
10765
10766
|
title: 'List Messenger marketing-message subscribers',
|
|
10766
|
-
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.',
|
|
10767
10768
|
inputSchema: {
|
|
10768
10769
|
pageId: z.string().optional().describe('Facebook Page id (from list_meta_pages); defaults to the one shared Page'),
|
|
10769
10770
|
limit: z.number().optional().describe('1–1000, default 100'),
|
|
@@ -12793,7 +12794,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12793
12794
|
|
|
12794
12795
|
server.registerTool('publish_tag_manager_container', {
|
|
12795
12796
|
title: 'Publish the container to the live site',
|
|
12796
|
-
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.",
|
|
12797
12798
|
inputSchema: {
|
|
12798
12799
|
containerPath: z.string().describe('the "accounts/<id>/containers/<id>" path from list_tag_manager_containers'),
|
|
12799
12800
|
gtmWorkspace: z.string().optional().describe('Tag Manager workspace id or name; defaults to the Default Workspace'),
|
|
@@ -15307,7 +15308,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15307
15308
|
}));
|
|
15308
15309
|
server.registerTool('openai_ads_report', {
|
|
15309
15310
|
title: 'ChatGPT Ads performance report',
|
|
15310
|
-
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.',
|
|
15311
15312
|
inputSchema: {
|
|
15312
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'),
|
|
15313
15314
|
since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
|
|
@@ -16733,7 +16734,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16733
16734
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/archive', a); return ok(d.note, d); }));
|
|
16734
16735
|
server.registerTool('create_openai_ads_campaign', {
|
|
16735
16736
|
title: 'Build a ChatGPT Ads campaign (paused)',
|
|
16736
|
-
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.',
|
|
16737
16738
|
inputSchema: {
|
|
16738
16739
|
name: z.string().describe('campaign name, at least 3 characters'),
|
|
16739
16740
|
description: z.string().optional().describe('optional campaign description'),
|
|
@@ -16808,7 +16809,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16808
16809
|
}));
|
|
16809
16810
|
server.registerTool('openai_ads_bulk', {
|
|
16810
16811
|
title: 'ChatGPT Ads bulk create/update job',
|
|
16811
|
-
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.',
|
|
16812
16813
|
inputSchema: {
|
|
16813
16814
|
operations: z.array(z.record(z.any())).describe('1–1000 operations: {operation_id, type, idempotency_key (creates) or target_resource_id (updates), input:{…}}'),
|
|
16814
16815
|
validateOnly: z.boolean().optional().describe('true = FREE dry run; nothing is created or changed'),
|
|
@@ -16916,7 +16917,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16916
16917
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/preview', a); return ok(d.note, d); }));
|
|
16917
16918
|
server.registerTool('send_openai_ads_conversions', {
|
|
16918
16919
|
title: 'Send server-side conversion events to ChatGPT Ads',
|
|
16919
|
-
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.',
|
|
16920
16921
|
inputSchema: {
|
|
16921
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.'),
|
|
16922
16923
|
apiKey: z.string().describe('the Conversions API key (create_openai_ads_conversion_api_key) — NOT the Advertiser API key the connector stores'),
|
|
@@ -23095,7 +23096,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23095
23096
|
server.group('create');
|
|
23096
23097
|
server.registerTool('generate_image', {
|
|
23097
23098
|
title: 'Generate ad image',
|
|
23098
|
-
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. 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.
|
|
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.',
|
|
23099
23100
|
inputSchema: {
|
|
23100
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.'),
|
|
23101
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),
|
|
@@ -23335,7 +23336,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23335
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"),
|
|
23336
23337
|
lines: z.array(z.object({
|
|
23337
23338
|
speaker: z.string().optional().describe('who speaks; the same name keeps the same voice'),
|
|
23338
|
-
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"),
|
|
23339
23340
|
text: z.string().describe('what they say'),
|
|
23340
23341
|
})).optional().describe('DIALOGUE: the turns in speaking order'),
|
|
23341
23342
|
dryRun: z.boolean().optional().describe('true = the exact credits only, nothing made or charged'),
|
|
@@ -23379,7 +23380,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23379
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'"),
|
|
23380
23381
|
seconds: z.number().optional().describe('length in seconds, 0.5-30 (default 5)'),
|
|
23381
23382
|
loop: z.boolean().optional().describe('true for a loopable bed'),
|
|
23382
|
-
engine: z.string().optional().describe("omit for 'seed-audio'
|
|
23383
|
+
engine: z.string().optional().describe("omit for 'seed-audio', or 'eleven-sfx'"),
|
|
23383
23384
|
dryRun: z.boolean().optional().describe('true = the exact credits only, nothing made or charged'),
|
|
23384
23385
|
},
|
|
23385
23386
|
outputSchema: {
|
|
@@ -23443,7 +23444,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23443
23444
|
server.group('create');
|
|
23444
23445
|
server.registerTool('render_ad', {
|
|
23445
23446
|
title: 'Render ad video',
|
|
23446
|
-
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.',
|
|
23447
23448
|
inputSchema: {
|
|
23448
23449
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
23449
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),
|
|
@@ -23594,7 +23595,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23594
23595
|
};
|
|
23595
23596
|
server.registerTool('make_motion_film', {
|
|
23596
23597
|
title: 'Make a motion graphics video',
|
|
23597
|
-
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.",
|
|
23598
23599
|
inputSchema: {
|
|
23599
23600
|
recipe: z.enum(['type', 'logo', 'launch', 'product', 'social']).optional().describe('type, logo, launch, product or social; help:true lists the recipes'),
|
|
23600
23601
|
hook: z.string().optional().describe("opening line; '*word*' = italic accent"),
|
|
@@ -23698,7 +23699,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23698
23699
|
|
|
23699
23700
|
server.registerTool('post_edit', {
|
|
23700
23701
|
title: 'Post-production edit',
|
|
23701
|
-
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).",
|
|
23702
23703
|
inputSchema: {
|
|
23703
23704
|
videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link; ' + MEDIA_PARAM_NOTE),
|
|
23704
23705
|
ops: z.array(z.object({
|
|
@@ -23863,7 +23864,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23863
23864
|
|
|
23864
23865
|
server.registerTool('fix_beat', {
|
|
23865
23866
|
title: 'Fix a video beat',
|
|
23866
|
-
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).",
|
|
23867
23868
|
inputSchema: {
|
|
23868
23869
|
videoUrl: z.string().optional().describe('the served URL of the master video to fix (not needed with `job`)'),
|
|
23869
23870
|
startSeconds: z.number().optional().describe('window start in seconds (not needed with `job`)'),
|
|
@@ -23905,7 +23906,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23905
23906
|
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
23906
23907
|
server.registerTool('clip_video', {
|
|
23907
23908
|
title: 'Clip a long video',
|
|
23908
|
-
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
|
|
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.",
|
|
23909
23910
|
inputSchema: {
|
|
23910
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),
|
|
23911
23912
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
@@ -23965,7 +23966,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23965
23966
|
|
|
23966
23967
|
server.registerTool('make_explainer', {
|
|
23967
23968
|
title: 'Make an explainer video',
|
|
23968
|
-
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.",
|
|
23969
23970
|
inputSchema: {
|
|
23970
23971
|
topic: z.string().optional().describe('what the explainer should teach or explain — a topic or a short brief (host_episode: or pass `script`)'),
|
|
23971
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."),
|
|
@@ -23984,7 +23985,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23984
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'."),
|
|
23985
23986
|
voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
|
|
23986
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.'),
|
|
23987
|
-
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.'),
|
|
23988
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."),
|
|
23989
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."),
|
|
23990
23991
|
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
|
|
@@ -23997,7 +23998,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23997
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"),
|
|
23998
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"),
|
|
23999
24000
|
scale: z.number().optional().describe('the share of the frame the presenter takes, 0.2-0.8 (default: the look\'s own size)'),
|
|
24000
|
-
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)"),
|
|
24001
24002
|
}).optional().describe('with fromFilm: who presents and how. A face the video model flags stops it, nothing charged, with the choices to offer.'),
|
|
24002
24003
|
},
|
|
24003
24004
|
outputSchema: { ...JOB_OUT },
|
|
@@ -24241,7 +24242,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24241
24242
|
|
|
24242
24243
|
server.registerTool('generate_video', {
|
|
24243
24244
|
title: 'Generate video',
|
|
24244
|
-
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).',
|
|
24245
24246
|
inputSchema: {
|
|
24246
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.'),
|
|
24247
24248
|
prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
|
|
@@ -27126,7 +27127,7 @@ function memoryNoteVerdict(text) {
|
|
|
27126
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).
|
|
27127
27128
|
server.registerTool('multiply_ad', {
|
|
27128
27129
|
title: 'Multiply an ad',
|
|
27129
|
-
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.",
|
|
27130
27131
|
inputSchema: {
|
|
27131
27132
|
video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook or X post link (not YouTube)'),
|
|
27132
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'),
|
|
@@ -27290,7 +27291,7 @@ function memoryNoteVerdict(text) {
|
|
|
27290
27291
|
// the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
|
|
27291
27292
|
server.registerTool('recast_hook', {
|
|
27292
27293
|
title: 'Recast a viral hook with your subject',
|
|
27293
|
-
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
|
|
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'}}].",
|
|
27294
27295
|
inputSchema: {
|
|
27295
27296
|
hook: z.string().describe('the viral hook: a post link or a video URL'),
|
|
27296
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",
|