hermoso 0.1.498 → 0.1.502
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/README.md +9 -7
- package/bin/hermoso.mjs +1 -1
- package/mcp/chatgpt-roster.mjs +51 -0
- package/mcp/client.mjs +5 -4
- package/mcp/http.mjs +25 -24
- package/mcp/openai-extensions.mjs +9 -6
- package/mcp/queued-image-note.mjs +14 -0
- package/mcp/roster-scope.mjs +33 -9
- package/mcp/static-batch.mjs +5 -2
- package/mcp/tool-hints.mjs +13 -0
- package/mcp/tools.mjs +1568 -247
- package/mcp/well-formed.mjs +1 -1
- package/package.json +2 -2
- package/skills/hermoso-ad-from-brand/SKILL.md +3 -0
- package/skills/hermoso-generate/SKILL.md +8 -0
- package/skills/hermoso-marketing/SKILL.md +1 -0
- package/skills/hermoso-product-photoshoot/SKILL.md +27 -12
- package/skills/hermoso-research/SKILL.md +1 -0
package/mcp/tools.mjs
CHANGED
|
@@ -32,13 +32,15 @@ function metaAlternativeNote(name) { return metaAlternativeNoteOf(canonicalToolN
|
|
|
32
32
|
import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
|
|
33
33
|
import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
|
|
34
34
|
// THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
|
|
35
|
-
import { withHints, hintsOf, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint, finishedRenderKind } from './tool-hints.mjs';
|
|
35
|
+
import { withHints, hintsOf, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint, finishedRenderKind, withWidgetResultNote } from './tool-hints.mjs';
|
|
36
36
|
// THE CHATGPT EXTENSIONS APP (home, Library panel, concept picker, brief, setup, settings, @-mentions), registered only
|
|
37
37
|
// behind HERMOSO_CHATGPT_EXT=1 + `?ext=1` on a widget host — see registerChatGPTExtensions() below and
|
|
38
38
|
// ./openai-extensions.mjs. Same twin-safe specifier rule.
|
|
39
39
|
import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY } from './openai-extensions.mjs';
|
|
40
40
|
// STATIC ADS AT SCALE (2026-10-06): the pure catalog/plan/prompt helpers behind make_static_ads. Same twin-safe specifier rule.
|
|
41
41
|
import { STATIC_BATCH, normalizeRatios, renderList } from './static-batch.mjs';
|
|
42
|
+
import { joinQueuedImageNote } from './queued-image-note.mjs'; // the queued generate_image note says the logo's corner once (journey QA 2026-11-03)
|
|
43
|
+
import { CHATGPT_ROSTER_FAMILIES } from './chatgpt-roster.mjs'; // the ranked ChatGPT families, GENERATED by tools/chatgpt-roster-rank.mjs
|
|
42
44
|
|
|
43
45
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
44
46
|
// /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
|
|
@@ -201,6 +203,8 @@ export const KEY_CONNECTORS = {
|
|
|
201
203
|
applovin_ads: { label: 'AppLovin Ads', route: 'applovin-ads', fields: { reportKey: 's', eventKey: '', apiKey: 's', capiKey: 's', accountId: '' }, how: 'the Reporting Key and the AppLovin Event Key from the AppLovin dashboard ▸ your account, top right ▸ Keys; plus apiKey (the Campaign Management key) and capiKey (the Conversion API key) when AppLovin has enabled those for the account (accountId only for an app account)' },
|
|
202
204
|
stripe: { label: 'Stripe', route: 'stripe', fields: { apiKey: 'rs' }, how: 'a secret (sk_) or restricted (rk_) key from Stripe ▸ Developers ▸ API keys' },
|
|
203
205
|
intercom: { label: 'Intercom', route: 'intercom', fields: { accessToken: 'rs', region: '' }, how: 'an access token from Intercom ▸ Settings ▸ Integrations ▸ Developer Hub ▸ your app ▸ Configure ▸ Authentication; region us, eu or au (found automatically when omitted)' },
|
|
206
|
+
mailchimp: { label: 'Mailchimp', route: 'mailchimp', fields: { apiKey: 'rs', server: '' }, how: 'an API key: Mailchimp ▸ Profile ▸ Extras ▸ API keys ▸ Create A Key. Leave server out; pass it only when Hermoso answers that the key has no -us8 style ending' },
|
|
207
|
+
klaviyo: { label: 'Klaviyo', route: 'klaviyo', fields: { apiKey: 'rs' }, how: 'a private key (pk_…): Klaviyo ▸ Settings ▸ API keys ▸ Create Private API Key ▸ Custom Key' },
|
|
204
208
|
vercel: { label: 'Vercel', route: 'vercel', fields: { token: 'rs', team: '' }, how: 'an access token from vercel.com/account/tokens (Settings ▸ Tokens ▸ Create); team is the team id (team_…) or slug when the token was made for a team' },
|
|
205
209
|
openai_ads: { label: 'ChatGPT Ads', route: 'openai-ads', fields: { apiKey: 'rs' }, how: 'an Advertiser API key from ChatGPT Ads Manager ▸ Settings ▸ General ▸ API Keys ▸ Create New Key' },
|
|
206
210
|
apple_ads: { label: 'Apple Ads', route: 'apple-ads', fields: { clientId: '', teamId: '', keyId: '', privateKey: 's', setupToken: 's', orgId: '' }, how: 'clientId, teamId and keyId (Apple Ads ▸ Account Settings ▸ API shows all three once a public key is saved there), plus privateKey for a key already registered with Apple, or setupToken from a first call with no fields, which generates the key pair' },
|
|
@@ -210,8 +214,9 @@ export const KEY_CONNECTORS = {
|
|
|
210
214
|
posthog: { label: 'PostHog', route: 'posthog', fields: { apiKey: 'rs', region: '', host: '', projectId: '' }, how: 'a PERSONAL API key (starts phx_) from PostHog ▸ Settings ▸ Personal API keys; region us or eu; projectId when the key can see several projects' },
|
|
211
215
|
mixpanel: { label: 'Mixpanel', route: 'mixpanel', fields: { username: 'r', secret: 'rs', projectId: 'r', region: '', workspaceId: '' }, how: 'a SERVICE ACCOUNT username and secret from Mixpanel ▸ Organization Settings ▸ Service Accounts, plus the numeric projectId from Project Settings' },
|
|
212
216
|
amplitude: { label: 'Amplitude', route: 'amplitude', fields: { apiKey: 'rs', secretKey: 'rs', region: '', host: '' }, how: 'the project API key and secret key from Amplitude ▸ Settings ▸ Projects ▸ your project ▸ General' },
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
wordpress: { label: 'WordPress', route: 'wordpress', fields: { siteUrl: 'r', username: 'r', appPassword: 'rs' }, how: 'the site address (https://…, a self-hosted site — WordPress.com cannot be connected this way), the WordPress username, and an APPLICATION password from WordPress ▸ Users ▸ Profile ▸ Application Passwords (not the login password)' },
|
|
218
|
+
slack: { label: 'Slack', route: 'slack', fields: { webhookUrl: 's', announceWebhookUrl: 's' }, how: 'NO fields = the one-click "Add to Slack" link (the Hermoso app); or an incoming-webhook URL (https://hooks.slack.com/…) per channel: webhookUrl for ALERTS (renders, posts, approvals, leads), announceWebhookUrl for the channel post_to_slack and scheduled Slack posts go to — pass one per call' },
|
|
219
|
+
discord: { label: 'Discord', route: 'discord', fields: { webhookUrl: 's', announceWebhookUrl: 's' }, how: 'NO fields = the one-click link that adds the Hermoso bot; or a channel webhook URL (Server Settings ▸ Integrations ▸ Webhooks ▸ New Webhook) per channel: webhookUrl for ALERTS, announceWebhookUrl for the channel post_to_discord and scheduled Discord posts go to — pass one per call' },
|
|
215
220
|
webhook: { label: 'Webhook', route: 'webhook', fields: { webhookUrl: 'rs' }, how: 'a public https:// URL that should receive a JSON POST for each render (a Zapier, Make or n8n catch hook)' },
|
|
216
221
|
};
|
|
217
222
|
|
|
@@ -259,7 +264,7 @@ export const CAPABILITY_MAP = [
|
|
|
259
264
|
'B) CREATE: on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here; each has its own brand details, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · make_motion_film (motion graphics: kinetic typography, logo intros, launch films; no video model) · 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 (fan out + rank).',
|
|
260
265
|
'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
|
|
261
266
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_alert_settings / set_alert_events / send_test_alert (where ALERTS go: Slack, Discord or a signed webhook, for renders, posts, approvals, competitor findings, leads and low credits) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
|
|
262
|
-
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_meta_stories (the Stories live right now on Instagram and Facebook Pages) / get_meta_story (one story id: live details, or that it expired) / instagram_story_insights (a Story’s reach, views, replies, link clicks and navigation, kept readable after its 24 hours end) / list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK POST INSIGHTS, also on the TikTok account authorization: tiktok_video_insights (per-POST analytics for the brand’s own posts — views, reach, average watch time, the per-second RETENTION curve, traffic sources, followers gained) · tiktok_spark_recommendations (TikTok rates the account’s organic posts HIGH / MEDIUM / LOW as Spark Ads candidates, optionally for an objective) · tiktok_hashtag_suggestions (10 hashtag ideas for a caption keyword, with each tag’s post count). TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · update_tiktok_ads_ad (EDIT a live ad in place — ad text, call-to-action, landing URL, UTMs, name, video and cover, carousel sound or Instant Form — instead of deleting and rebuilding it, which throws away its review history and delivery learning) · preview_tiktok_ads_ad (see an ad as it renders on TikTok, or preview a video BEFORE any ad is built, with TikTok’s own fix-it tips per placement) · tiktok_ads_review_info (TikTok’s review verdict per ad — approved, approved for only part of its targeting, or rejected — with its REASONS and suggested fix) · list_tiktok_ads_music (the ad-cleared sound a CAROUSEL ad needs) · tiktok_ads_creative_fatigue (TikTok’s own verdict on whether a creative is WEARING OUT, per day, with spend and cost per conversion — the signal to swap in a fresh variation) · tiktok_ads_video_retention (per-SECOND ad video performance: where viewers swipe away, the hook read for the next variation) · tiktok_ads_trending_hashtags + tiktok_trends (TikTok’s OWN first-party trend data: top hashtags by country, industry and period; trending pre-cleared Commercial Music Library sounds, the top videos on a sound, top search terms) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · create_tiktok_ads_account (CREATE A NEW TIKTOK AD ACCOUNT for a business that has none, in a Business Center the user’s own login administers and in their company name; two steps, call without confirm first, and it can never be deleted) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · set_microsoft_ads_bidding (change a campaign’s BID STRATEGY — EnhancedCpc, MaxClicks, MaxConversions with an optional target CPA, MaxConversionValue with an optional target ROAS, TargetImpressionShare — read back) · list_microsoft_ads_negative_keywords + add_microsoft_ads_negative_keywords + remove_microsoft_ads_negative_keywords + manage_microsoft_ads_negative_keyword_list (NEGATIVE keywords: the searches a campaign or ad group must NOT pay for, Exact or Phrase only, and SHARED negative lists reused across campaigns; removing one on a LIVE campaign lets those searches trigger paid ads again, so it is confirm-gated) · list_microsoft_ads_uet_tags + create_microsoft_ads_uet_tag (the UET site tag and its install snippet — conversion goals, remarketing and conversion bidding all need one) · create_microsoft_ads_conversion_goal (Url / Event / OfflineConversion / Duration / PagesViewedPerVisit goals, read back) · list_microsoft_ads_audience_targeting + set_microsoft_ads_audience_targeting (put an audience — a customer list, remarketing, in-market — to work on a campaign or ad group, or EXCLUDE it, with a bid adjustment) · create_microsoft_ads_image_ad (turn a render into an IMAGE ad on the Microsoft Audience Network or a multimedia ad on Search; the picture is cropped to the 1.91:1 and 1:1 sizes Microsoft requires) · upload_microsoft_ads_video (put a render on the account as a video asset, or check one still processing; Microsoft refuses clips under 6 seconds) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder · share_onedrive_file (a view/edit link for a client) · onedrive_file_permissions (who can open it; revoke). INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · vercel_event_analytics (custom-event conversions by campaign) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
267
|
+
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_meta_stories (the Stories live right now on Instagram and Facebook Pages) / get_meta_story (one story id: live details, or that it expired) / instagram_story_insights (a Story’s reach, views, replies, link clicks and navigation, kept readable after its 24 hours end) / list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK POST INSIGHTS, also on the TikTok account authorization: tiktok_video_insights (per-POST analytics for the brand’s own posts — views, reach, average watch time, the per-second RETENTION curve, traffic sources, followers gained) · tiktok_spark_recommendations (TikTok rates the account’s organic posts HIGH / MEDIUM / LOW as Spark Ads candidates, optionally for an objective) · tiktok_hashtag_suggestions (10 hashtag ideas for a caption keyword, with each tag’s post count). TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · update_tiktok_ads_ad (EDIT a live ad in place — ad text, call-to-action, landing URL, UTMs, name, video and cover, carousel sound or Instant Form — instead of deleting and rebuilding it, which throws away its review history and delivery learning) · preview_tiktok_ads_ad (see an ad as it renders on TikTok, or preview a video BEFORE any ad is built, with TikTok’s own fix-it tips per placement) · tiktok_ads_review_info (TikTok’s review verdict per ad — approved, approved for only part of its targeting, or rejected — with its REASONS and suggested fix) · list_tiktok_ads_music (the ad-cleared sound a CAROUSEL ad needs) · tiktok_ads_creative_fatigue (TikTok’s own verdict on whether a creative is WEARING OUT, per day, with spend and cost per conversion — the signal to swap in a fresh variation) · tiktok_ads_video_retention (per-SECOND ad video performance: where viewers swipe away, the hook read for the next variation) · tiktok_ads_trending_hashtags + tiktok_trends (TikTok’s OWN first-party trend data: top hashtags by country, industry and period; trending pre-cleared Commercial Music Library sounds, the top videos on a sound, top search terms) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · create_tiktok_ads_account (CREATE A NEW TIKTOK AD ACCOUNT for a business that has none, in a Business Center the user’s own login administers and in their company name; two steps, call without confirm first, and it can never be deleted) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). WORDPRESS: post_to_wordpress (a blog post on the brand’s own self-hosted site — publish, draft, pending review, private, or scheduled natively with a future date; the first image is featured, the rest inline, a video embedded; a title is required) · update_wordpress_post · delete_wordpress_post (trash, or permanent; confirm-gated) · list_wordpress_posts · get_wordpress_post · list_wordpress_terms / create_wordpress_term. WordPress has no per-post views. DISCORD + SLACK: post_to_discord / post_to_slack (an ANNOUNCEMENT through the brand’s announcement webhook, never its alert one — Discord 2000 characters with files up to 20 MB attached, larger as a link, mentions off unless asked; Slack 40,000 characters with images inline and a video as a link) WHOP: post_to_whop (a FORUM post — title, Markdown, images and video, pin, members-only or public, paywall, poll — or a CHAT message, into the brand’s whop as the Hermoso app it installed) · list_whop_destinations · list_whop_products · edit_whop_post · get_whop_post · delete_whop_post (chat messages; Whop’s API cannot delete a forum post). A whop links from Whop itself: install Hermoso from the Whop App Store and press Open Hermoso; nothing is billed through Whop. · edit_discord_message / delete_discord_message / get_discord_message (its reactions). A Slack webhook post cannot be edited or deleted. BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · set_microsoft_ads_bidding (change a campaign’s BID STRATEGY — EnhancedCpc, MaxClicks, MaxConversions with an optional target CPA, MaxConversionValue with an optional target ROAS, TargetImpressionShare — read back) · list_microsoft_ads_negative_keywords + add_microsoft_ads_negative_keywords + remove_microsoft_ads_negative_keywords + manage_microsoft_ads_negative_keyword_list (NEGATIVE keywords: the searches a campaign or ad group must NOT pay for, Exact or Phrase only, and SHARED negative lists reused across campaigns; removing one on a LIVE campaign lets those searches trigger paid ads again, so it is confirm-gated) · list_microsoft_ads_uet_tags + create_microsoft_ads_uet_tag (the UET site tag and its install snippet — conversion goals, remarketing and conversion bidding all need one) · create_microsoft_ads_conversion_goal (Url / Event / OfflineConversion / Duration / PagesViewedPerVisit goals, read back) · list_microsoft_ads_audience_targeting + set_microsoft_ads_audience_targeting (put an audience — a customer list, remarketing, in-market — to work on a campaign or ad group, or EXCLUDE it, with a bid adjustment) · create_microsoft_ads_image_ad (turn a render into an IMAGE ad on the Microsoft Audience Network or a multimedia ad on Search; the picture is cropped to the 1.91:1 and 1:1 sizes Microsoft requires) · upload_microsoft_ads_video (put a render on the account as a video asset, or check one still processing; Microsoft refuses clips under 6 seconds) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder · share_onedrive_file (a view/edit link for a client) · onedrive_file_permissions (who can open it; revoke). INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). MAILCHIMP (same shape): create_mailchimp_template · create_mailchimp_campaign · send_mailchimp_campaign (confirm:true + the count) · mailchimp_report and more. KLAVIYO (the brand’s own email, nothing billed): create_klaviyo_template (an ad + on-brand copy → an email) · create_klaviyo_campaign (a draft) · send_klaviyo_campaign (needs confirm:true + the recipient count) · klaviyo_report and more. VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · vercel_event_analytics (custom-event conversions by campaign) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
263
268
|
'F) YOUR TOOL LIST IS A STARTING POINT, NOT THE PRODUCT \u2014 and the whole catalogue is two calls away. The listed roster is deliberately small (the core tools, plus whatever this connection asked for or you have switched on); every other tool in this map is held out of the LIST on SIZE alone and is fully built, fully live and fully callable. THE ROUTE THAT WORKS ON EVERY HOST, including the ones that cannot reload their tool list at all (claude.ai, ChatGPT): find_tools({query}) searches every tool by task or name and returns its parameters, its credit cost and its recent health in one line, then call_tool({name, args}) RUNS it \u2014 same account, same permissions, same result as if it had been listed. A direct tools/call to a name you already know works too. enable_tools({groups:[\u2026]}) additionally LISTS a whole group for hosts that re-list: `ads` (paid-campaign management on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads, Apple Search Ads \u2014 by far the heaviest group), `analytics` (GA4, Search Console), `channel_admin` (reading a channel back: insights, comments, DMs, catalogs, templates, webhooks, editing or deleting a published post), plus research, create, channels, files, workspace, or \'all\'. NEVER tell a user Hermoso cannot build a campaign, read a comment, answer a DM or pull a channel\u2019s numbers because you cannot see the tool \u2014 look it up and call it.',
|
|
264
269
|
].join('\n');
|
|
265
270
|
|
|
@@ -295,7 +300,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
295
300
|
'• RESEARCH the ads already winning: find_competitors, competitor_teardown, pull_competitor_ads, research_ads, search_meta_ads, search_google_ads, search_linkedin_ads, search_tiktok, search_instagram, search_youtube, search_reddit, search_threads, mine_angles, analyze_video, check_ad_policy.',
|
|
296
301
|
'• CREATE on-brand ads: render_ad, generate_image, generate_video, generate_avatar, make_template_ad, make_motion_film, make_thumbnail, make_explainer, plan_ad, plan_variations; get_brand / draft_brand / update_brand; list_creators / save_creator; edit_video, dub_video, clip_video, reframe_video, upscale_video, stitch_video.',
|
|
297
302
|
'• RAW MODELS, prompt only: generate_image / generate_video with useBrand:false, generate_voice, generate_text, upload_file (any file becomes a URL every tool accepts).',
|
|
298
|
-
'• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business; schedule_post (+ list/reschedule/cancel); list_connectors. Not connected = say to connect it under Settings ▸ Connectors, never that Hermoso lacks the channel.',
|
|
303
|
+
'• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business, post_to_wordpress, post_to_discord, post_to_slack; schedule_post (+ list/reschedule/cancel); list_connectors. Not connected = say to connect it under Settings ▸ Connectors, never that Hermoso lacks the channel.',
|
|
299
304
|
// INSTAGRAM_PATHS_NOTE — an inline copy of lib/instagram-paths.mjs (the twins ship without lib/); tools/instagram-paths-check.mjs asserts they are byte-equal.
|
|
300
305
|
// THE LAST LINE OF THE HEAD, AND THE ONE THAT MAKES THE SHORT ROSTER SAFE (2026-09-17). Your tool LIST is now
|
|
301
306
|
// core-first; every name in the bullets above is held out of it on SIZE and answers a call anyway. Say that
|
|
@@ -328,8 +333,8 @@ export const MCP_INSTRUCTIONS = [
|
|
|
328
333
|
// instead of a render, once after being told in terms to pass an image and run generate_video. A "do X first" that
|
|
329
334
|
// is not actually required is a trap in an instruction an agent reads before acting, and this one was not
|
|
330
335
|
// required: `model` is OPTIONAL on generate_image, generate_video and render_ad, and an unnamed render resolves to
|
|
331
|
-
// GEN_MODELS.<kind>[0] in workImage/workVideo, which is the catalog's `best: true` row (
|
|
332
|
-
// and, for video, model-belt.mjs's AUTO_VIDEO_DEFAULT (seedance-2.5, also `video[0]`). The prose below says "the
|
|
336
|
+
// GEN_MODELS.<kind>[0] in workImage/workVideo, which is the catalog's `best: true` row (for image an unnamed generate_image
|
|
337
|
+
// here goes further and takes the static_ad job's engine, GPT Image 2.5 since 2026-11-03) and, for video, model-belt.mjs's AUTO_VIDEO_DEFAULT (seedance-2.5, also `video[0]`). The prose below says "the
|
|
333
338
|
// server's own default" rather than "the best row" on purpose: hermoso_capabilities' own _pickLine states as a RULE
|
|
334
339
|
// that neither `best` nor the longest-clip row decides where an unnamed ask lands, and two shipped sentences that
|
|
335
340
|
// disagree are worse than either one. What the user-facing line has to carry is only that omitting `model` works.
|
|
@@ -360,7 +365,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
360
365
|
export const MCP_INSTRUCTIONS_DIRECTORY = [
|
|
361
366
|
'Hermoso runs a brand\'s marketing from Claude: research what is working in its market, publish and schedule to its own social accounts, answer its comments and messages, run its paid campaigns, and read the results. Each area works on its own; none needs another first.',
|
|
362
367
|
'• RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads, search_meta_ads, search_google_ads, search_linkedin_ads, search_tiktok, search_instagram, search_youtube, search_reddit, search_threads, check_ad_policy.',
|
|
363
|
-
'• PUBLISH & SCHEDULE the user\'s own media to their own accounts: upload_file (any file becomes a URL), post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business; schedule_post, list_scheduled, reschedule_post, cancel_scheduled.',
|
|
368
|
+
'• PUBLISH & SCHEDULE the user\'s own media to their own accounts: upload_file (any file becomes a URL), post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business, post_to_wordpress, post_to_discord, post_to_slack; schedule_post, list_scheduled, reschedule_post, cancel_scheduled.',
|
|
364
369
|
'• INBOX: list_inbox and reply_to_inbox_item cover comments, reviews and direct messages across connected channels.',
|
|
365
370
|
'• PAID CAMPAIGNS on Meta, Google, Microsoft, LinkedIn, TikTok, Pinterest, Reddit, Snapchat, X, Apple and ChatGPT: create_* builds campaigns, ad groups and ads from the user\'s own creative, always created PAUSED and read back; set_*_status arms spend only after the user confirms.',
|
|
366
371
|
'• ANALYTICS: meta_insights, google_ads_report and the other per-platform reports, analytics_report, search_console_performance.',
|
|
@@ -381,16 +386,17 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
|
|
|
381
386
|
// • under ~2 KB, because ChatGPT's connector truncates server instructions near there.
|
|
382
387
|
// Every other host keeps MCP_INSTRUCTIONS unchanged.
|
|
383
388
|
export const MCP_INSTRUCTIONS_WIDGET = [
|
|
384
|
-
"Hermoso runs a brand's marketing: research
|
|
385
|
-
'Act on the request: asked to make something, make it.
|
|
389
|
+
"Hermoso runs a brand's marketing: research competitors' ads, create on-brand image and video ads, post and schedule to the user's own accounts, run their paid campaigns, and read how it all performs. Each area works on its own.",
|
|
390
|
+
'Act on the request: asked to make something, make it. Render tools pick a sound default model; hermoso_capabilities gives model ids and credit costs.',
|
|
386
391
|
'• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
|
|
387
|
-
'• CREATE: get_brand / draft_brand
|
|
388
|
-
"• FILES &
|
|
389
|
-
'•
|
|
390
|
-
'•
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
'
|
|
392
|
+
'• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in the background: poll get_job until done.',
|
|
393
|
+
"• FILES & SCHEDULING: upload_file turns a file into a URL; schedule_post queues a post; list_scheduled shows the queue; list_connectors shows what is connected (connected in the Hermoso app, Settings > Connectors); list_library lists past creations.",
|
|
394
|
+
'• PUBLISH: post_to_* (post_to_meta, post_to_tiktok...) post now; list_inbox and reply_to_inbox_item answer comments and messages.',
|
|
395
|
+
'• PAID ADS (Meta, Google, LinkedIn, TikTok, Reddit, Snapchat, ChatGPT): create_* builds campaigns paused; set_*_status and set_*_budget control spend.',
|
|
396
|
+
'• RESULTS: post_performance, analyze_campaigns. WORKSPACE: list_brands, use_brand, create_brand, hermoso_credits, report_bug, request_feature.',
|
|
397
|
+
"CREDITS: AI and research tools use credits from the user's Hermoso account; the reply says how many. If too low, say so; the account is managed at hermoso.ai. Never start a purchase.",
|
|
398
|
+
'CONFIRM with the user before posting or scheduling, messaging customers, turning on a campaign or changing a budget, deleting anything, or replacing a saved profile (draft_brand with save:true).',
|
|
399
|
+
'SHOWING RESULTS: the widget already shows each finished image and video. Never repeat one as a markdown image or image link (it shows a broken tile); describe it and link only if asked.',
|
|
394
400
|
].join('\n');
|
|
395
401
|
// `opts.widgetHost` is the transport's own host decision (mcp/http.mjs isWidgetHost), the same one registerTools gets.
|
|
396
402
|
export const instructionsFor = (scope, opts = {}) => (scope && scope.directory === 'scoped' ? MCP_INSTRUCTIONS_DIRECTORY : (opts && opts.widgetHost ? MCP_INSTRUCTIONS_WIDGET : MCP_INSTRUCTIONS));
|
|
@@ -474,7 +480,7 @@ const NOT_CONNECTED_LEGACY_RE = /isn['’`]?t connected\b|\bConnect [A-Z][\w .+&
|
|
|
474
480
|
const CONNECT_LEGACY_PROVIDERS = [
|
|
475
481
|
[/onedrive/i, 'microsoft_onedrive'], [/google ads/i, 'google_ads'],
|
|
476
482
|
[/google sheet|google doc|google drive|\bdrive\b/i, 'google_drive'] /* Drive, Sheets and Docs are ONE connector since 2026-08-01 — one grant, one consent screen, one connect link */,
|
|
477
|
-
// NO ROW FOR A GATED PROVIDER (google_business
|
|
483
|
+
// NO ROW FOR A GATED PROVIDER (google_business — see CONNECTOR_GATES in server.js). Their
|
|
478
484
|
// /connect routes apply the same gate and answer 501, so a one-click link is a dead end; they fall through to
|
|
479
485
|
// CONNECT_ASK_HINT, which is true whatever the gate is doing. Derived assertion in tools/operator-scope-check.mjs.
|
|
480
486
|
[/youtube/i, 'youtube'], [/threads/i, 'threads'],
|
|
@@ -508,7 +514,7 @@ function notConnectedHint(e, msg) {
|
|
|
508
514
|
// ── EVERY TOOL OF A MULTI-ACCOUNT CHANNEL TAKES `account` (2026-10-29, customer bug fb_f6f7b0371d42) ────────────────
|
|
509
515
|
// A brand can connect several accounts of these channels (lib/multi-account.mjs), and the server REFUSES a write that
|
|
510
516
|
// names none. Only the post_to_* tools declared `account`, so a user with two YouTube channels was told "name one with
|
|
511
|
-
// account: …" by update_youtube_video, whose schema had no such argument: their daily private-to-public flip
|
|
517
|
+
// account: …" by update_youtube_video, whose schema had no such argument: their daily private-to-public flip never ran.
|
|
512
518
|
// Fixed at the registration seam, not per tool: a tool whose provider (roster-scope toolProvider) is in this set gets
|
|
513
519
|
// ACCOUNT_ARG when it does not declare `account` itself, and wrap() scopes every request the call makes to that account
|
|
514
520
|
// (client.mjs withAccountScope -> `?account=`, read by the server's connCtxFor). Tool 1089 inherits it. WHICH tools
|
|
@@ -528,7 +534,22 @@ export const withAccountArg = (name, def) => {
|
|
|
528
534
|
// of this next step says nothing and costs the caller nothing. The REST passthrough (/v1/tools, the one store with no
|
|
529
535
|
// `client`) is an integration parsing a response, not a person in a chat, so it is left out.
|
|
530
536
|
const CONNECT_PROMPT_WAIT_MS = 2000;
|
|
531
|
-
|
|
537
|
+
// WHICH BRAND A TOOL ACTED ON, for the two notes asked after its reply (2026-11-03). Each tool whose `brand` / `brandId`
|
|
538
|
+
// names a Hermoso brand (its description points at list_brands, the rule tools/brand-write-honoured-check.mjs applies)
|
|
539
|
+
// is recorded at registration; a `brand` that is a vendor's attribute (a merchant product's brand) never is. Without it
|
|
540
|
+
// the notes were judged against the connection's PINNED brand, so an agent working on another brand could be offered
|
|
541
|
+
// a week of posts, or a connect link, for the wrong one.
|
|
542
|
+
const HERMOSO_BRAND_KEYS = new Map();
|
|
543
|
+
export function noteBrandKeys(name, def) {
|
|
544
|
+
const shape = def?.inputSchema?.shape || def?.inputSchema || {};
|
|
545
|
+
const keys = ['brand', 'brandId'].filter((k) => shape[k] && /list_brands/.test(String(shape[k].description || '')));
|
|
546
|
+
if (keys.length) HERMOSO_BRAND_KEYS.set(name, keys); else HERMOSO_BRAND_KEYS.delete(name);
|
|
547
|
+
}
|
|
548
|
+
export function noteBrandOf(tool, args) {
|
|
549
|
+
for (const k of HERMOSO_BRAND_KEYS.get(tool) || []) { const v = args?.[k]; if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 120); }
|
|
550
|
+
return '';
|
|
551
|
+
}
|
|
552
|
+
export async function withConnectPrompt(tool, r, post = apiPost, brand = '') {
|
|
532
553
|
try {
|
|
533
554
|
const kind = finishedRenderKind(tool, r);
|
|
534
555
|
if (!kind) return r;
|
|
@@ -537,7 +558,7 @@ export async function withConnectPrompt(tool, r, post = apiPost) {
|
|
|
537
558
|
const sc = r.structuredContent || {};
|
|
538
559
|
const job = String(sc.jobId || sc.id || '').slice(0, 80);
|
|
539
560
|
const client = store ? String(store.client || '') : 'hermoso';
|
|
540
|
-
const d = await Promise.race([Promise.resolve(post('/api/mcp/connect-prompt', { tool, kind, job, client })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), CONNECT_PROMPT_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
|
|
561
|
+
const d = await Promise.race([Promise.resolve(post('/api/mcp/connect-prompt', { tool, kind, job, client, ...(brand ? { brand } : {}) })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), CONNECT_PROMPT_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
|
|
541
562
|
if (!d || d.show !== true || typeof d.text !== 'string' || !d.text) return r;
|
|
542
563
|
const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...sc, connectPrompt: { channels: Array.isArray(d.channels) ? d.channels : [] } } };
|
|
543
564
|
return withHints(out, [...hintsOf(r), { do: 'offer to post this; hand the user one of the connect links in this reply', why: 'no publishing channel is connected, and connecting needs a browser sign-in on the platform' }]);
|
|
@@ -550,13 +571,13 @@ export async function withConnectPrompt(tool, r, post = apiPost) {
|
|
|
550
571
|
// effort: bounded, and a failed or slow read says nothing.
|
|
551
572
|
const WEEK_PLAN_NOTE_WAIT_MS = 1500;
|
|
552
573
|
const WEEK_PLAN_NOTE_SKIP = new Set(['plan_week', 'get_job', 'list_jobs']);
|
|
553
|
-
export async function withWeekPlanNote(tool, r, post = apiPost) {
|
|
574
|
+
export async function withWeekPlanNote(tool, r, post = apiPost, brand = '') {
|
|
554
575
|
try {
|
|
555
576
|
if (!r || r.isError || !Array.isArray(r.content) || WEEK_PLAN_NOTE_SKIP.has(tool)) return r;
|
|
556
577
|
const store = mcpCtx.getStore();
|
|
557
578
|
if (store && (store.connectPromptQuiet || (store.remote && store.client === undefined))) return r;
|
|
558
579
|
const client = store ? String(store.client || '') : 'hermoso';
|
|
559
|
-
const d = await Promise.race([Promise.resolve(post('/api/mcp/week-plan/note', { client })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), WEEK_PLAN_NOTE_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
|
|
580
|
+
const d = await Promise.race([Promise.resolve(post('/api/mcp/week-plan/note', { client, ...(brand ? { brand } : {}) })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), WEEK_PLAN_NOTE_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
|
|
560
581
|
if (!d || d.show !== true || typeof d.text !== 'string' || !d.text) return r;
|
|
561
582
|
const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...(r.structuredContent || {}), weekPlanNote: { kind: String(d.kind || ''), sentence: String(d.sentence || '') } } };
|
|
562
583
|
return withHints(out, [...hintsOf(r), { do: 'ask the user the question in this reply', why: d.kind === 'finished' ? 'their planned week has gone out' : 'they just connected their first channel' }]);
|
|
@@ -601,7 +622,8 @@ const wrap = (fn) => {
|
|
|
601
622
|
}
|
|
602
623
|
}
|
|
603
624
|
} catch {}
|
|
604
|
-
|
|
625
|
+
const _noteBrand = noteBrandOf(_tool, args); // the brand this call acted on, so both notes are about it
|
|
626
|
+
return await withWeekPlanNote(_tool, await withConnectPrompt(_tool, withWidgetResultNote(_tool, r, hostRendersWidgets()), apiPost, _noteBrand), apiPost, _noteBrand);
|
|
605
627
|
}
|
|
606
628
|
catch (e) {
|
|
607
629
|
try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
|
|
@@ -612,7 +634,7 @@ const wrap = (fn) => {
|
|
|
612
634
|
// client or an agent can branch on it instead of matching a sentence. This is the highest-value place in the
|
|
613
635
|
// product for it: it is the ONE catch every tool's failure returns through.
|
|
614
636
|
const _hints = [];
|
|
615
|
-
//
|
|
637
|
+
// running out of credits needs an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
|
|
616
638
|
// BOTH phrasings. The gates say "You're out of credits" while the reserve path says "Not enough credits";
|
|
617
639
|
// matching only the latter meant research, X posting and the competitor watch hit a 402 and told the agent
|
|
618
640
|
// nothing about how to fix it, so the top-up path this whole flow depends on was unreachable from those tools.
|
|
@@ -705,16 +727,20 @@ const publishWrap = (fn) => {
|
|
|
705
727
|
// keeps its design on its Library row; "change $10 to $15" makes that design again with the words swapped, about 2 credits,
|
|
706
728
|
// never a video-model edit of every frame. The server decides, free (POST /api/template-ad/remake-text); edit_video asks
|
|
707
729
|
// it first unless the caller chose the model path (a preview, or an approved still). null = not a design: edit as before.
|
|
708
|
-
|
|
730
|
+
// textChanges [{from, to}]: EVERY change at once (a translation of every line), made in ONE render (2026-11-03).
|
|
731
|
+
async function designRemakeFirst(video, prompt, textChanges) {
|
|
709
732
|
let t = null;
|
|
710
|
-
|
|
711
|
-
|
|
733
|
+
const many = Array.isArray(textChanges) && textChanges.length ? { changes: textChanges } : {};
|
|
734
|
+
try { t = await apiPost('/api/template-ad/remake-text', { video, instruction: prompt, ...many }); } catch (e) { if (Number(e?.status) !== 404) throw e; } // an older server without the route: the edit, as before
|
|
735
|
+
const ch = Array.isArray(t?.changes) && t.changes.length > 1 ? `${t.changes.length} changes (${t.changes.map((c) => `"${c.from}" to "${c.to}"`).join('; ')})` : t?.change ? `"${t.change.from}" to "${t.change.to}"` : 'those words';
|
|
736
|
+
const later = t?.derived && Array.isArray(t.laterEdits) && t.laterEdits.length ? ` It was made from the design this video came from; the edits added after it are not in it yet. Put them back with post_edit on the new video, in this order: ${JSON.stringify(t.laterEdits).slice(0, 1500)}.` : '';
|
|
712
737
|
if (t?.route === 'remake') {
|
|
713
738
|
const r = await awaitRenderJob({ id: t.jobId });
|
|
714
|
-
if (r?.stillRendering) return ok(`This video was laid out from a design, so the same ${t.templateLabel} is being made again with ${ch} (about ${t.credits} credits) instead of a video edit [job ${t.jobId}]
|
|
715
|
-
return okVideo(`Made the same ${t.templateLabel} again with ${ch} everywhere it appeared (${t.hits} place${t.hits === 1 ? '' : 's'}); the original video is kept and was not edited: ${r.url} [job ${t.jobId}]${switchNote(r)}`, { ...r, remade: true });
|
|
739
|
+
if (r?.stillRendering) return ok(`This video was laid out from a design, so the same ${t.templateLabel} is being made again with ${ch} (about ${t.credits} credits, one render) instead of a video edit [job ${t.jobId}].${later} Call get_job with that id until it is done.`, { ...r, remade: true });
|
|
740
|
+
return okVideo(`Made the same ${t.templateLabel} again with ${ch} everywhere it appeared (${t.hits} place${t.hits === 1 ? '' : 's'}); the original video is kept and was not edited: ${r.url} [job ${t.jobId}]${later}${switchNote(r)}`, { ...r, remade: true });
|
|
716
741
|
}
|
|
717
|
-
if (t?.route === '
|
|
742
|
+
if (t?.route === 'busy') return ok(`Nothing changed or charged: the last change of words (job ${t.jobId}) is still rendering. Wait for it, then call once with its video and every change still left in textChanges.`, t);
|
|
743
|
+
if (t?.route === 'not_found') return ok(`Nothing changed or charged: ${((t.missing && t.missing.length) ? t.missing : [t.change]).map((c) => `"${c.from}"`).join(', ')} not in this design's words. Its lines are exactly: ${(t.words || []).map((w) => `"${w}"`).join(', ')}. Call again once with every change in textChanges, each from copied from those lines.`, t);
|
|
718
744
|
if (t?.route === 'no_design') return ok(`Nothing changed or charged yet: this video was laid out from a design that was not kept with it, so it cannot be re-made word for word. Offer both ways: (1) make it again from the brand with make_template_ad (the same template, the words with ${ch}), about 2 credits; (2) edit the words inside this video with the video model: edit_video with previewFirstFrame:true shows the change on one still and quotes the clip before anything paid runs.`, t);
|
|
719
745
|
return null;
|
|
720
746
|
}
|
|
@@ -786,6 +812,15 @@ async function awaitRenderJob(job) {
|
|
|
786
812
|
// verdict from the text a human reads (it survived only in `structuredContent`, which nothing prints). One
|
|
787
813
|
// resolver, so a note added to either reader inherits both shapes.
|
|
788
814
|
const renderPayload = (r) => (r?.raw ?? jobResult(r) ?? null);
|
|
815
|
+
// A STOPPED VIDEO-BLOCKS FILM SAYS WHAT IT KEPT AND HOW TO FINISH IT (2026-11-03, lib/block-ledger.mjs); a finished one says
|
|
816
|
+
// it can be edited one block at a time. '' for every other job.
|
|
817
|
+
const resumeLine = (j) => {
|
|
818
|
+
const r = j?.resume; if (!r || (j.type !== 'explainer' && j.type !== 'fixbeat')) return ''; // a block redo is a film too
|
|
819
|
+
if (j.status === 'error' && r.resumable) return `\nResumable: ${r.kept?.length ? `block${r.kept.length > 1 ? 's' : ''} ${r.kept.join(', ')} of ${r.blocks} kept` : 'its script'}${r.narration ? ' with the narration' : ''}. resume_job {jobId:"${j.id}"} renders only what is missing (dryRun:true quotes it).`;
|
|
820
|
+
if (j.status === 'done' && r.delivered && r.presenter) return `\nThis film has a presenter. To redo a block, edit the film without them (fix_beat {job:"${r.cleanJobId}"}), then add the presenter to the new film.`;
|
|
821
|
+
if (j.status === 'done' && r.delivered) return `\nEditable one block at a time: fix_beat {job:"${j.id}", block:N, prompt or line} redoes only that block.`;
|
|
822
|
+
return '';
|
|
823
|
+
};
|
|
789
824
|
// A MODEL SUBSTITUTION IS NEVER SILENT (2026-08-01). The server sets `modelNote` on any render whose delivered model
|
|
790
825
|
// is not the model the caller named — an entry-belt coercion, or an in-flight content-filter/quota fallback — and the
|
|
791
826
|
// bill always follows the model that RAN. An agent that asked for one model and reads a one-line "ready" reply would
|
|
@@ -841,7 +876,7 @@ const geoLine = (r) => {
|
|
|
841
876
|
// AD_LENGTH_MAX — the longest STITCHED spot the planner can build: 12 acts (KEYFRAME_CAP) × 15s.
|
|
842
877
|
const VIDEO_SINGLE_CLIP_CEILING = 15;
|
|
843
878
|
// ── FRAME ASKS. The twin of the length rule above, and it exists for the same reason: prod job_mt6gnnuhmnawu was
|
|
844
|
-
// QUEUED for a 1:1 video, handed to the user as a job id, and
|
|
879
|
+
// QUEUED for a 1:1 video, handed to the user as a job id, and failed 8ms later inside the worker ("Gemini Omni renders
|
|
845
880
|
// 9:16, 16:9"). Nobody should wait on a job that could not have worked.
|
|
846
881
|
// AR_UNIVERSAL — a CONSERVATIVE trigger, exactly like VIDEO_SINGLE_CLIP_CEILING, and NOT a claim about any model.
|
|
847
882
|
// It is the set of frames every video row in the catalog declared when this was written (read live 2026-08-23:
|
|
@@ -1120,8 +1155,8 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
|
|
|
1120
1155
|
// timeout is HARDER evidence than a garbage answer — nothing came back at all — so two of them settle it, where a
|
|
1121
1156
|
// usable-but-empty answer still gets MAX_MISSES. Two timeouts is ~18s of spinner before the card says so.
|
|
1122
1157
|
var POLL_TIMEOUT_MS = 8000, MAX_TIMEOUTS = 2;
|
|
1123
|
-
// A THROTTLE IS NOT NEWS ABOUT THE RENDER (2026-08-23).
|
|
1124
|
-
//
|
|
1158
|
+
// A THROTTLE IS NOT NEWS ABOUT THE RENDER (2026-08-23). A JSON-less 429 is the hosting platform's own refusal
|
|
1159
|
+
// ("The request was aborted because there was no available instance"), not ours — it lands on
|
|
1125
1160
|
// /api/credits from a browser and on the uptime check just as readily as on a tool call, so it says NOTHING about
|
|
1126
1161
|
// the job and must not be counted as a missing answer. Three consequences, all here: it gets its own counter (so a
|
|
1127
1162
|
// busy minute cannot masquerade as a lost job), the card SAYS the server is busy instead of "checking every few
|
|
@@ -1990,7 +2025,7 @@ const looseOutput = (def) => (def && def.outputSchema && typeof def.outputSchema
|
|
|
1990
2025
|
// its own npm package root — a ../lib import would resolve in this repo and be absent from the published CLI.
|
|
1991
2026
|
//
|
|
1992
2027
|
// THE GAP. Hermoso could already read comments on Facebook and Instagram, replies and mentions on Threads,
|
|
1993
|
-
// comments on YouTube and Reddit, reviews on Google Business, and mentions on X — seven separate tools, each with
|
|
2028
|
+
// comments on YouTube (and, then, Reddit), reviews on Google Business, and mentions on X — seven separate tools, each with
|
|
1994
2029
|
// its own shape, its own id field and its own idea of what "who said it" is called. So "answer my comments" meant
|
|
1995
2030
|
// six calls the agent had to merge itself, and in practice meant the agent asked the user which platform first.
|
|
1996
2031
|
// Found 2026-08-16 by reading a competitor's OpenAPI: one `GET /inbox/conversations` where we had seven.
|
|
@@ -2053,7 +2088,6 @@ export const INBOX_SOURCES = [
|
|
|
2053
2088
|
{ source: 'meta_webhook', provider: 'meta', read: '/api/meta/webhooks/events', reply: null, kind: 'pushed', label: 'Pushed Meta event' },
|
|
2054
2089
|
{ source: 'threads_mention', provider: 'threads', read: '/api/threads/mentions', reply: '/api/threads/reply', kind: 'mention', label: 'Threads mention' },
|
|
2055
2090
|
{ source: 'youtube', provider: 'youtube', read: '/api/youtube/comments', reply: '/api/youtube/reply-comment', kind: 'comment', label: 'YouTube comment', needs: { arg: 'videoId', param: 'videoId', from: 'list_youtube_videos' } },
|
|
2056
|
-
{ source: 'reddit', provider: 'reddit', read: '/api/reddit/comments', reply: '/api/reddit/reply', kind: 'comment', label: 'Reddit comment' },
|
|
2057
2091
|
{ source: 'google_business', provider: 'google_business', read: '/api/google-business/reviews', reply: '/api/google-business/review-reply', kind: 'review', label: 'Google review' },
|
|
2058
2092
|
{ source: 'x', provider: 'x', read: '/api/x/mentions', reply: null, kind: 'mention', label: 'X mention' },
|
|
2059
2093
|
// BLUESKY, added 2026-08-17. Publishing (text, images, video, scheduling) shipped that morning with NO read path
|
|
@@ -2458,7 +2492,8 @@ export const WIDGET_HOST_EXTRA = Object.freeze([
|
|
|
2458
2492
|
'set_connector_accounts', 'youtube_channel', 'x_post_metrics', 'list_analytics_properties',
|
|
2459
2493
|
'moderate_meta_comment', 'list_brands',
|
|
2460
2494
|
]);
|
|
2461
|
-
//
|
|
2495
|
+
// SUPERSEDED 2026-11-03 BY THE FULL ROSTER (chatgptRosterMode below); this curated set is now only what CHATGPT_ROSTER=curated
|
|
2496
|
+
// restores. CHATGPT CARRIES A CURATED ROSTER, LISTED WHETHER OR NOT THE SCANNING ACCOUNT CONNECTED ANYTHING (2026-10-25, the owner:
|
|
2462
2497
|
// "I don't want to offer all the tools to ChatGPT if it's going to make everything not work well … Let's only offer the
|
|
2463
2498
|
// most important tools"). Two facts decide the shape. (1) ChatGPT serves every user the tool list OpenAI's scan read
|
|
2464
2499
|
// (it rescans daily and auto-publishes tool changes), never a live tools/list, so a per-account connector gate cannot
|
|
@@ -2489,10 +2524,62 @@ export const WIDGET_HOST_LOOKUPS = Object.freeze([
|
|
|
2489
2524
|
// fails when a skill names a tool this list leaves off, so the two cannot drift apart.
|
|
2490
2525
|
export const WIDGET_HOST_SKILL_TOOLS = Object.freeze(['save_to_drive', 'list_intercom_conversations', 'read_intercom_conversation', 'reply_to_intercom_conversation']);
|
|
2491
2526
|
export const WIDGET_HOST_CONNECTOR_TOOLS = Object.freeze(new Set([...WIDGET_HOST_EXTRA.filter((n) => !WIDGET_HOST_LONG_TAIL_BUILD.includes(n)), ...WIDGET_HOST_LOOKUPS, ...WIDGET_HOST_SKILL_TOOLS]));
|
|
2492
|
-
//
|
|
2493
|
-
|
|
2494
|
-
//
|
|
2495
|
-
|
|
2527
|
+
// ── CHATGPT GETS AS MUCH OF THE ROSTER AS IT ACCEPTS (2026-11-03, the owner: ChatGPT users get every Hermoso tool,
|
|
2528
|
+
// publishing and ads included, the same as claude.ai). The curated list above was OUR choice; OpenAI's plugin guidelines,
|
|
2529
|
+
// app-review page and Apps SDK reference set no cap, but the platform refuses more than 1,000 (measured, below). What
|
|
2530
|
+
// OpenAI does rule, and the ranked mode keeps: (1) no generic executor ("Do not use discovery, operation selection, or
|
|
2531
|
+
// schema fetching with a generic executor to enable operations not individually exposed for review"), so on ChatGPT a
|
|
2532
|
+
// tool must be LISTED by name to be reachable, which is why every group and the on-demand tools are listed; (2) explicit
|
|
2533
|
+
// readOnly / destructive / openWorld hints, reviewed per tool in tools/lib/chatgpt-tool-annotations.json; (3) the
|
|
2534
|
+
// commerce and money-transfer rules and the per-tool scan findings (WITHHELD_FROM_WIDGET_HOSTS / WIDGET_HOST_SPLITS /
|
|
2535
|
+
// WIDGET_HOST_RENAMES). The scanned snapshot is one list for every user (the scan account connects nothing), so a
|
|
2536
|
+
// carried connector tool is listed regardless of connections and an unconnected call answers its real 401.
|
|
2537
|
+
// ── CHATGPT ACCEPTS AT MOST 1,000 TOOLS (measured 2026-11-03), SO ITS LIST IS RANKED AND CAPPED ─────────────────────
|
|
2538
|
+
// With every tool listed (1,213), ChatGPT's own refresh answered 424 {"developer_message":"tools/list pagination exceeded
|
|
2539
|
+
// 1000 tools"}. OpenAI's docs do not state it; the platform enforces it. So 'ranked' (the default) lists the core (every
|
|
2540
|
+
// tool that needs no connection) plus whole provider FAMILIES in the order tools/chatgpt-roster-rank.mjs committed to
|
|
2541
|
+
// mcp/chatgpt-roster.mjs (real usage first, then value: publishing, the big ad platforms, analytics, the long tail), up
|
|
2542
|
+
// to CHATGPT_TOOL_CAP. A family is all or nothing, so a platform is never half-present. The list is DATA, never computed
|
|
2543
|
+
// per request, so OpenAI's scan sees a stable list; enforceWidgetCap() is only a guard for a registry that grew past the
|
|
2544
|
+
// cap since the last ranking (it drops the lowest-ranked families, the same for every session of a deploy).
|
|
2545
|
+
// CHATGPT_ROSTER=curated is the kill switch back to the 2026-10-25 curated list, with no code change.
|
|
2546
|
+
export const CHATGPT_TOOL_CAP = 950;
|
|
2547
|
+
export const CHATGPT_HARD_TOOL_LIMIT = 1000; // ChatGPT's own refusal point; the cap keeps headroom under it
|
|
2548
|
+
export const chatgptRosterMode = (env = process.env) => (String(env?.CHATGPT_ROSTER ?? '').trim().toLowerCase() === 'curated' ? 'curated' : 'ranked');
|
|
2549
|
+
// The groups the ANONYMOUS discovery roster lists (mcp/http.mjs serveAnonDiscovery) when no `?tools=` was stated. A
|
|
2550
|
+
// widget host in ranked mode gets every group (the families decide), so the scanner's anonymous read and the session agree.
|
|
2551
|
+
export const anonDiscoveryGroups = (widgetHost, env = process.env) => (widgetHost && chatgptRosterMode(env) === 'ranked' ? [...TOOL_GROUP_NAMES] : [...DEFAULT_TOOL_GROUPS]);
|
|
2552
|
+
const RANKED_FAMILIES = new Set(CHATGPT_ROSTER_FAMILIES);
|
|
2553
|
+
let _capDroppedFamilies = new Set(); // set by enforceWidgetCap only when the registry outgrew the committed ranking
|
|
2554
|
+
const rankedFamilyCarried = (fam) => RANKED_FAMILIES.has(fam) && !_capDroppedFamilies.has(fam);
|
|
2555
|
+
// Is this tool on the ChatGPT list? Ranked: a tool with no connector always is; a connector tool when its family is.
|
|
2556
|
+
// Curated: a connector tool only when it is in the curated set.
|
|
2557
|
+
export const widgetHostCarries = (name, env = process.env) => { const fam = toolProvider(name); return fam === null || (chatgptRosterMode(env) === 'curated' ? WIDGET_HOST_CONNECTOR_TOOLS.has(canonicalToolName(name)) : rankedFamilyCarried(fam)); };
|
|
2558
|
+
// The connector gate does not apply to a carried connector tool on a widget host: its listing is fixed for every user.
|
|
2559
|
+
const widgetHostUngated = (name, ctx) => !!ctx?.widgetHost && toolProvider(name) !== null && widgetHostCarries(name) && !toolWithheldByServer(name, ctx.conn);
|
|
2560
|
+
// How many tools a widget host lists for a set of registered names, per family. ONE function for the ranker, the guard
|
|
2561
|
+
// and the checks, so they cannot count differently. A name widgetHostHides drops (a split or renamed original, a
|
|
2562
|
+
// host-policy hold, the executor) is not listed, so it is not counted.
|
|
2563
|
+
export function widgetRosterCount(names) {
|
|
2564
|
+
const byFamily = Object.create(null); let core = 0;
|
|
2565
|
+
for (const n of names) {
|
|
2566
|
+
if (widgetHostHides(n, true)) continue;
|
|
2567
|
+
const fam = toolProvider(n);
|
|
2568
|
+
if (fam === null) core++; else byFamily[fam] = (byFamily[fam] || 0) + 1;
|
|
2569
|
+
}
|
|
2570
|
+
return { core, byFamily };
|
|
2571
|
+
}
|
|
2572
|
+
// Families in ranked order, kept while they fit under the cap. Everything after the first that does not fit is dropped,
|
|
2573
|
+
// so a cut always removes the lowest-ranked families and never a middle one.
|
|
2574
|
+
export function capFamilies(names, ordered = CHATGPT_ROSTER_FAMILIES, cap = CHATGPT_TOOL_CAP) {
|
|
2575
|
+
const { core, byFamily } = widgetRosterCount(names);
|
|
2576
|
+
let total = core; const kept = []; const dropped = []; let full = false;
|
|
2577
|
+
for (const fam of ordered) {
|
|
2578
|
+
const n = byFamily[fam] || 0;
|
|
2579
|
+
if (!full && total + n <= cap) { kept.push(fam); total += n; } else { full = true; dropped.push(fam); }
|
|
2580
|
+
}
|
|
2581
|
+
return { core, kept, dropped, total };
|
|
2582
|
+
}
|
|
2496
2583
|
// Hosts that have been SEEN to follow find_tools → call_tool. A name that does not match keeps the full roster.
|
|
2497
2584
|
// WIDENED 2026-09-25 ON EVIDENCE: Gemini CLI (0.60, clientInfo "gemini-cli-mcp-client"), Grok (clientInfo "grok", UA
|
|
2498
2585
|
// grok-connectors-manager) and Mistral Le Chat / Vibe (clientInfo "mcp", so it is recognised by its UA MistralAI-MCPClient)
|
|
@@ -2546,9 +2633,9 @@ export function parseToolScope(raw) {
|
|
|
2546
2633
|
// plan's price, the saved card and auto-reload, and its own description points at upgrade_plan / set_auto_reload.
|
|
2547
2634
|
// The plugin guidelines say a plugin "must not display subscription plans … or promote upgrades"; hermoso_credits
|
|
2548
2635
|
// still answers "what is my balance" on ChatGPT, and the plan is managed in the Hermoso app.
|
|
2549
|
-
// `delete_brand`
|
|
2550
|
-
//
|
|
2551
|
-
//
|
|
2636
|
+
// `delete_brand` was withheld 2026-09-30 by the owner's call for the 1.0.0 review (a choice, not an OpenAI rule) and
|
|
2637
|
+
// RETURNED 2026-11-03 with the full roster: it is destructiveHint:true and refuses without confirm plus the exact name
|
|
2638
|
+
// and connector count from its own inventory, so ChatGPT's approval prompt and our gate both stand in front of it.
|
|
2552
2639
|
// connect_connector takes a pasted key in the conversation, which ChatGPT's review refuses (connect in the app instead);
|
|
2553
2640
|
// x_post_insights / x_post_insights_historical need X analytics access our X app does not hold today, so ChatGPT's scan
|
|
2554
2641
|
// read their descriptions as claiming what the tool cannot do (2026-10-18). x_post_metrics answers there.
|
|
@@ -2581,7 +2668,7 @@ export const WIDGET_WITHHELD_WHY = Object.freeze({
|
|
|
2581
2668
|
set_openai_ads_negative_keywords: 'OpenAI no longer serves account-level negative keywords, so there is nothing it can set',
|
|
2582
2669
|
create_reddit_ads_lead_form: 'Reddit retired onsite lead forms, so there is nothing it can create',
|
|
2583
2670
|
});
|
|
2584
|
-
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', '
|
|
2671
|
+
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'connect_connector', 'x_post_insights', 'x_post_insights_historical', 'fetch_social_data', 'remix_static', ...WIDGET_MONEY_MOVEMENT_TOOLS, ...Object.keys(WIDGET_WITHHELD_WHY)]);
|
|
2585
2672
|
// remix_static is clone_static under its old name (kept for callers that cached it); on ChatGPT two tools for one
|
|
2586
2673
|
// action is a selection hazard OpenAI's metadata guide calls out, so only clone_static is offered there (2026-10-24).
|
|
2587
2674
|
// The withheld tools whose refusal is the generic-executor sentence rather than the commerce one.
|
|
@@ -2607,9 +2694,9 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
|
|
|
2607
2694
|
// caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
|
|
2608
2695
|
// bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
|
|
2609
2696
|
export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
|
|
2610
|
-
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand', click_to_ad: 'plan_video_ad_from_product_link' }); // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
|
|
2697
|
+
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand', click_to_ad: 'plan_video_ad_from_product_link', delete_openai_ads_object: 'archive_openai_ads_object' }); // delete_openai_ads_object (2026-11-03 scan, "name … inconsistent with its behavior"): OpenAI has no delete, the tool ARCHIVES // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
|
|
2611
2698
|
export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
|
|
2612
|
-
export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link' });
|
|
2699
|
+
export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link', archive_openai_ads_object: 'Archive a ChatGPT Ads campaign, ad group or ad' });
|
|
2613
2700
|
// ── ONE OPERATION, ONE TOOL, ON A WIDGET HOST (2026-10-18) ─────────────────────────────────────────────────────
|
|
2614
2701
|
// OpenAI's daily scan HELD 18 tools that take an `action` enum and do different things per value: "This tool appears
|
|
2615
2702
|
// to execute operations that are not individually exposed. Please expose each model-callable operation upfront as a
|
|
@@ -2727,10 +2814,10 @@ export const WIDGET_HOST_SPLITS = Object.freeze(withSplitAccounts({
|
|
|
2727
2814
|
splitOp('remove_x_chat_admins', { action: 'remove_admins' }, ['id!', 'userIds!'], splitW(true, true), 'Remove admins from an X Chat group', 'Use this when people should stop being admins of a group the brand admins. userIds are numeric X account ids.' + X_BILLED),
|
|
2728
2815
|
splitOp('remove_x_chat_members', { action: 'remove_members' }, ['id!', 'userIds!', 'confirm'], splitW(true, true), 'Remove members from an X Chat group', 'Use this when people should be removed from a group the brand admins. Needs confirm:true. userIds are numeric X account ids.' + X_BILLED, { confirm: 'must be true to remove them' }),
|
|
2729
2816
|
splitOp('leave_x_chat', { action: 'leave' }, ['id!', 'confirm'], splitW(true, true), 'Leave an X Chat group', 'Use this when the brand should leave a group conversation. Needs confirm:true.' + X_BILLED, { confirm: 'must be true to leave' }),
|
|
2730
|
-
splitOp('approve_x_chat_admin_request', { action: 'approve_admin_request' }, ['id!', 'userId!'], splitW(
|
|
2817
|
+
splitOp('approve_x_chat_admin_request', { action: 'approve_admin_request' }, ['id!', 'userId!'], splitW(true, true), 'Approve a request to be an X Chat group admin', 'Use this when someone who asked to be an admin of a group the brand admins should be approved. list_x_chats view admin_requests lists the requests.' + X_BILLED),
|
|
2731
2818
|
splitOp('reject_x_chat_admin_request', { action: 'reject_admin_request' }, ['id!', 'userId!'], splitW(true, true), 'Reject a request to be an X Chat group admin', 'Use this when a request to become admin of a group the brand admins should be turned down.' + X_BILLED),
|
|
2732
2819
|
splitOp('reject_x_chat_join_requests', { action: 'reject_join_requests' }, ['id!', 'userIds!'], splitW(true, true), 'Reject requests to join an X Chat group', 'Use this when people waiting to join a group the brand admins should be turned away. Approving a join is done in the X app.' + X_BILLED),
|
|
2733
|
-
splitOp('enable_x_chat_invite_link', { action: 'enable_invite' }, ['id!', 'title', 'avatarUrl', 'welcomeMessage', 'expiresAt'], splitW(
|
|
2820
|
+
splitOp('enable_x_chat_invite_link', { action: 'enable_invite' }, ['id!', 'title', 'avatarUrl', 'welcomeMessage', 'expiresAt'], splitW(true, false), 'Turn on an X Chat group invite link', 'Use this when a group the brand admins should get a shareable invite link. X stores the preview (title, avatar, welcome message) unencrypted and shows it to anyone with the link.' + X_BILLED),
|
|
2734
2821
|
splitOp('disable_x_chat_invite_link', { action: 'disable_invite' }, ['id!', 'confirm'], splitW(true, true), 'Turn off an X Chat group invite link', 'Use this when a group’s shared invite link should stop working for good. Needs confirm:true.' + X_BILLED, { confirm: 'must be true to turn the link off' }),
|
|
2735
2822
|
splitOp('accept_x_chat_request', { action: 'accept_request' }, ['id!'], splitW(false, true), 'Accept an X Chat message request', 'Use this when a message request should move into the brand’s X inbox. id is the sender’s numeric id or the request’s conversationId from list_x_chats view message_requests. Show the user who sent it first; list_x_dms with participantId reads the message only if it was not sent encrypted.' + X_BILLED),
|
|
2736
2823
|
splitOp('archive_x_chat', { action: 'archive' }, ['id!'], splitW(false, true), 'Archive an X Chat conversation', 'Use this when a conversation should move out of the brand’s main X inbox. Only the brand’s view changes.' + X_BILLED),
|
|
@@ -2861,12 +2948,13 @@ export const widgetHostHides = (name, widgetHost) => (widgetHost
|
|
|
2861
2948
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
2862
2949
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
2863
2950
|
// edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
|
|
2864
|
-
export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads', 'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product']); // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine)
|
|
2951
|
+
export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads', 'make_listing_set', 'reangle_video', 'repurpose_transcript', 'tiktok_ideas', 'generate_sfx', 'repurpose_image', 'add_presenter', 'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product']); // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine) // generate_sfx (2026-11-03): find_sound's and generate_voice's descriptions name it
|
|
2865
2952
|
|
|
2866
2953
|
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
2867
2954
|
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
2868
2955
|
// chatgpt-caches-the-connector). Their snapshot from August still names eight tools that no longer exist: seven
|
|
2869
|
-
// Reddit posting tools we withdrew (Reddit
|
|
2956
|
+
// Reddit posting tools we withdrew (organic Reddit posting is permanently not offered, owner 2026-11-03) and one
|
|
2957
|
+
// research tool, renamed. A call
|
|
2870
2958
|
// to any of them answered the SDK's bare "unknown tool", which the agent reads as "Hermoso is broken". So every name
|
|
2871
2959
|
// that was ever published is kept here for as long as a host could hold it: a rename FORWARDS to its successor with
|
|
2872
2960
|
// the same arguments; a withdrawn tool answers the same sentence the live roster would give for it. Neither is ever
|
|
@@ -2909,7 +2997,7 @@ export const withStaleNote = (out, ctx) => {
|
|
|
2909
2997
|
return out;
|
|
2910
2998
|
};
|
|
2911
2999
|
// ── A CONNECTED ACCOUNT THAT HAS MADE NOTHING IS OFFERED ONE CONCRETE FIRST ASK (2026-10-19) ─────────────────────────
|
|
2912
|
-
// Since the ChatGPT app was published (10-15),
|
|
3000
|
+
// Since the ChatGPT app was published (10-15), most accounts whose agent was ChatGPT had made nothing, and a live "make me
|
|
2913
3001
|
// a video ad for my coffee shop" on a connected account was answered by ChatGPT's own slideshow with no Hermoso call. So
|
|
2914
3002
|
// the first successful reply of a ChatGPT session on an account with no image or video render (the server decides:
|
|
2915
3003
|
// mcpFirstRunFor, read once per session in http.mjs) ends with one next step the user can say yes to: their brand's
|
|
@@ -2964,7 +3052,7 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
|
2964
3052
|
reportDeadEnd('stale_roster', name, `${name} no longer exists — the host's tool list is an older published snapshot`);
|
|
2965
3053
|
if (spec.gone) {
|
|
2966
3054
|
const text = spec.gone === 'reddit'
|
|
2967
|
-
? `${name} is no longer offered: Hermoso does not
|
|
3055
|
+
? `${name} is no longer offered: Hermoso does not post to Reddit; Reddit Ads is supported (the reddit_ads tools), organic Reddit posting is not. There is nothing the user can connect, so do not point them at Settings ▸ Connectors.`
|
|
2968
3056
|
: `${name} is no longer offered by Hermoso.`;
|
|
2969
3057
|
return withStaleNote({ content: [{ type: 'text', text }], isError: false }, ctx);
|
|
2970
3058
|
}
|
|
@@ -3004,7 +3092,7 @@ export const WITHHELD_FROM_DIRECTORY = new Set([
|
|
|
3004
3092
|
'generate_image', 'generate_video', 'generate_avatar', 'generate_voice', 'render_ad', 'plan_ad', 'plan_variations',
|
|
3005
3093
|
'make_thumbnail', 'make_explainer', 'product_sizzle', 'dub_video', 'change_voice', 'edit_video', 'recast_motion',
|
|
3006
3094
|
'upscale_video', 'reframe_video', 'insert_scene', 'multiply_ad', 'clone_static', 'remix_static', 'stitch_video', 'fix_beat',
|
|
3007
|
-
'hook_variants',
|
|
3095
|
+
'hook_variants', 'resume_job', // resume_job (2026-11-03): renders the missing blocks of a stopped film
|
|
3008
3096
|
// 2026-09-25, after the directory review named "30+ model ids" and the prompt-only mode: the model catalog, the
|
|
3009
3097
|
// saved cast (it exists to star a person in a render) and mine_angles (its `next` is a render call) point at the
|
|
3010
3098
|
// generation lanes, so they leave the cage too. The Stripe writes move money (refund, charge, subscribe), which
|
|
@@ -3109,9 +3197,27 @@ export function stripOutputSchema(def) {
|
|
|
3109
3197
|
return rest;
|
|
3110
3198
|
}
|
|
3111
3199
|
|
|
3200
|
+
// ── WHAT A WIDGET HOST READS WHEN THE SCAN FLAGGED THE WORDING (2026-11-03 scan, the full ranked roster) ──────────────
|
|
3201
|
+
// OpenAI's daily scan held these on wording alone: comparative or self-preferring phrasing ("the most useful research
|
|
3202
|
+
// tool here", "the one channel where"), a description that read as claiming more than the tool does, or one it found
|
|
3203
|
+
// unclear. Every other host keeps the longer text, which carries real operating detail; ChatGPT gets the same behaviour
|
|
3204
|
+
// described plainly. Each override is a complete description: what the tool does, its inputs, its cost.
|
|
3205
|
+
export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
|
|
3206
|
+
search_console_performance: "Google Search Console's performance report for the brand's site: the search queries people used, with impressions, clicks, click-through rate and average position, by query, page, country, device, search appearance, date or hour. startDate and endDate are required, as YYYY-MM-DD (Pacific time). Rows are the top ones Google returns; anonymised queries are withheld, so per-query clicks will not add up to the site total; the default 'final' data lags 2 to 3 days and 'all' adds fresh partial data. Up to 16 months of history. Read-only, 0 credits.",
|
|
3207
|
+
list_linkedin_events: "The events a LinkedIn company Page organizes: name, link, start and end times, online or in person, visibility and state. Only Pages shared with this profile can be read. It needs LinkedIn's Events permission; if a connection made before that permission was enabled is refused, the user reconnects LinkedIn once. Read-only, 0 credits. Needs LinkedIn connected.",
|
|
3208
|
+
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook, X or YouTube post link; in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point and to check the result. Costs the flat post_edit price (about 2 credits); a 'generate' segment, which renders a new transition shot with a video model, costs more and is used only when the user asks for one.",
|
|
3209
|
+
list_openai_ads_feeds: "The product feeds on this ChatGPT Ads account: id, name, countries, currencies and the campaigns using each. A product-feed campaign advertises the products in one of these feeds. Read-only, 0 credits. Needs ChatGPT Ads connected.",
|
|
3210
|
+
create_openai_ads_feed: "Create an empty product feed on ChatGPT Ads, with a name and the countries it serves (ISO codes). Products are added afterwards with update_openai_ads_feed_products. Needs ChatGPT Ads connected.",
|
|
3211
|
+
update_reddit_ads_ad_group: "Edit an existing Reddit ad group: its name, budget, goal type, bid, schedule dates or targeting. Budget and bid are amounts in the ad account's currency. Targeting is updated per key: each targeting key sent replaces that whole list, keys left out stay as they are, and an empty list clears a key. It does not start or pause delivery (set_reddit_ads_status does). The saved ad group is read back from Reddit. Needs Reddit Ads connected.",
|
|
3212
|
+
update_reddit_ads_saved_audience: "Edit a Reddit saved audience: its name or its targeting. Targeting is updated per key: each targeting key sent replaces that whole list, keys left out stay as they are, and an empty list clears a key. Changing the targeting of an audience that live ad groups use changes who those ad groups reach right away, so tell the user how many ad groups use it and get a yes first. The saved audience is read back from Reddit. Needs Reddit Ads connected.",
|
|
3213
|
+
});
|
|
3214
|
+
const _widgetDescribed = new WeakMap();
|
|
3112
3215
|
export function defForHost(name, def, widgetHost) {
|
|
3113
3216
|
explicitHints(def);
|
|
3114
|
-
|
|
3217
|
+
let out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def)) : stripOutputSchema(def);
|
|
3218
|
+
if (widgetHost && out && Object.prototype.hasOwnProperty.call(WIDGET_HOST_DESCRIPTIONS, name)) {
|
|
3219
|
+
let o = _widgetDescribed.get(out); if (!o) { o = { ...out, description: WIDGET_HOST_DESCRIPTIONS[name] }; _widgetDescribed.set(out, o); } out = o;
|
|
3220
|
+
}
|
|
3115
3221
|
if (!widgetHost || !out || !WIDGET_WITHHELD_TOOLS.has(name)) return out;
|
|
3116
3222
|
const meta = out._meta;
|
|
3117
3223
|
if (!meta || !('openai/outputTemplate' in meta)) return out;
|
|
@@ -3162,7 +3268,7 @@ function renamedForWidgetHost(def) {
|
|
|
3162
3268
|
}
|
|
3163
3269
|
|
|
3164
3270
|
// ── THE TOOL DEFINITIONS ARE BUILT ONCE PER PROCESS (2026-08-24) ───────────────────────────────────────────────
|
|
3165
|
-
// A HOSTED SESSION USED TO COST 114 MB,
|
|
3271
|
+
// A HOSTED SESSION USED TO COST 114 MB, MORE THAN A HOST CAN CARRY PER CONNECTION. Measured the way the 2026-08-01 figure
|
|
3166
3272
|
// was (node --expose-gc, 25 instances, retained heap): **114.36 MB per McpServer at the 681-tool roster** — the
|
|
3167
3273
|
// "~36 MB" recorded against 248 tools in mcp/http.mjs was accurate when written and is 3.2x stale. The breakdown
|
|
3168
3274
|
// says exactly where it goes: a bare McpServer is **0.03 MB**, and the 681 `def` objects with their zod schemas
|
|
@@ -3246,8 +3352,8 @@ export const holdReasonFor = (name, ctx) => {
|
|
|
3246
3352
|
if (EXECUTOR_TOOLS.has(name) && ctx.widgetHost) return 'host_policy'; // OpenAI's generic-executor rule — see EXECUTOR_TOOLS (its own sentence below)
|
|
3247
3353
|
if (toolHeldBackByConnectors(name, ctx.conn)) {
|
|
3248
3354
|
// NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Settings ▸ Connectors" is
|
|
3249
|
-
// right for a provider with a tile and a dead end for one without (
|
|
3250
|
-
// the API
|
|
3355
|
+
// right for a provider with a tile and a dead end for one without (google_business: no tile until Google grants
|
|
3356
|
+
// the API). `offered` is null on a server that predates the field.
|
|
3251
3357
|
const prov = toolProvider(name);
|
|
3252
3358
|
if (prov && ctx.conn?.offered instanceof Set && !ctx.conn.offered.has(prov)) return 'not_offered';
|
|
3253
3359
|
return 'not_connected';
|
|
@@ -3272,8 +3378,7 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
3272
3378
|
if (why === 'not_offered') {
|
|
3273
3379
|
const prov = toolProvider(name) || 'that';
|
|
3274
3380
|
const because = ctx?.conn?.gated?.[prov] ? ` (${ctx.conn.gated[prov]})` : '';
|
|
3275
|
-
|
|
3276
|
-
return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Settings ▸ Connectors and do not offer this capability.${reddit}`;
|
|
3381
|
+
return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Settings ▸ Connectors and do not offer this capability.`;
|
|
3277
3382
|
}
|
|
3278
3383
|
if (why === 'shopify_store') return `${name} ${SHOPIFY_STORE_NOTE} Do not send the user to Settings \u25b8 Connectors for it; there is no Shopify tile to connect.`;
|
|
3279
3384
|
if (why === 'unavailable') return `${name} is not available in Hermoso yet. Do not offer it or point the user at a setting for it; nothing they can connect turns it on.`;
|
|
@@ -3440,7 +3545,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
3440
3545
|
// says how many and why, because a group that turns on "8 tools" when the agent expected 240 with no
|
|
3441
3546
|
// explanation is the [[prompt-rosters-go-stale]] failure — the agent concludes the capability is missing.
|
|
3442
3547
|
if (toolWithheldByServer(name, ctx.conn)) continue; // not offered right now: neither enabled nor counted as waiting on a connection
|
|
3443
|
-
if (ctx.widgetHost && !widgetHostCarries(name)) continue; // the ChatGPT list is fixed (
|
|
3548
|
+
if (ctx.widgetHost && !widgetHostCarries(name)) continue; // the ChatGPT list is fixed (ranked families, or the curated set): a group flip cannot widen it
|
|
3444
3549
|
if (toolHeldBackByConnectors(name, ctx.conn) && !widgetHostUngated(name, ctx)) { heldBack++; continue; }
|
|
3445
3550
|
if (toolHeldBackByDirectory(name, grp, ctx)) continue; // the cage holds across a group flip too
|
|
3446
3551
|
try { h.enable(); n++; enabledNames.push(name); } catch {}
|
|
@@ -3529,7 +3634,10 @@ function newToolScope(opts) {
|
|
|
3529
3634
|
// HOSTED transport they keep the full default roster and nothing can look smaller than it is. The stdio CLI has no
|
|
3530
3635
|
// host to ask and stays a plain env opt-in. Widen CORE_FIRST_VERIFIED_HOST_RE on evidence, never on a guess.
|
|
3531
3636
|
const coreFirst = !opts.only && coreFirstRoster() && (!opts.hosted || hostTakesCoreFirst(opts.client, opts.ua) || hostHasToolCap(opts.client, opts.ua));
|
|
3532
|
-
|
|
3637
|
+
// A widget host in full mode with no stated scope lists EVERY group, the on-demand tools included: it may not use
|
|
3638
|
+
// the generic executor, so a group it does not list is a feature it does not have (see chatgptRosterMode).
|
|
3639
|
+
const widgetFull = !!opts.widgetHost && !opts.only && chatgptRosterMode() === 'ranked';
|
|
3640
|
+
const asked = opts.only ? new Set(opts.only) : new Set(widgetFull ? [...TOOL_GROUP_NAMES] : (coreFirst ? ['core'] : [...DEFAULT_TOOL_GROUPS]));
|
|
3533
3641
|
asked.add('core'); // discovery/credits/billing/jobs must exist in EVERY roster or the connection is unusable
|
|
3534
3642
|
// `connectors` is `{connected:Set<provider>, readOk:boolean}` from the transport's own free read, or absent.
|
|
3535
3643
|
// ABSENT AND `readOk:false` BEHAVE IDENTICALLY, and that is the fail-open law rather than a convenience:
|
|
@@ -3590,6 +3698,7 @@ function replayTools(rawServer, opts, canon) {
|
|
|
3590
3698
|
}
|
|
3591
3699
|
registerWidgetHostAliases(rawServer, ctx, opts);
|
|
3592
3700
|
registerWidgetHostSplits(rawServer, ctx, opts);
|
|
3701
|
+
enforceWidgetCap(ctx, opts);
|
|
3593
3702
|
registerChatGPTExtensions(rawServer, ctx, opts);
|
|
3594
3703
|
installHeldToolCalls(rawServer, ctx);
|
|
3595
3704
|
return rawServer;
|
|
@@ -3677,6 +3786,20 @@ export function widgetSplitHandler(o, handler) {
|
|
|
3677
3786
|
m[o.name] = fn;
|
|
3678
3787
|
return fn;
|
|
3679
3788
|
}
|
|
3789
|
+
// THE CAP GUARD (2026-11-03). The committed ranking keeps a widget host under CHATGPT_TOOL_CAP; this only acts when the
|
|
3790
|
+
// registry has grown past it since the last `node tools/chatgpt-roster-rank.mjs`. It counts registered NAMES (never
|
|
3791
|
+
// handle state, so every session of a deploy reaches the same answer), drops the lowest-ranked families until the list
|
|
3792
|
+
// fits, and disables their tools. ChatGPT refuses a list over 1,000 outright, so a slightly short list beats a dead one.
|
|
3793
|
+
let _capWarned = false;
|
|
3794
|
+
function enforceWidgetCap(ctx, opts) {
|
|
3795
|
+
if (!opts.widgetHost || chatgptRosterMode() !== 'ranked') return;
|
|
3796
|
+
const names = Object.keys(ctx.handleOf);
|
|
3797
|
+
const { dropped } = capFamilies(names);
|
|
3798
|
+
_capDroppedFamilies = new Set(dropped);
|
|
3799
|
+
if (!dropped.length) return;
|
|
3800
|
+
if (!_capWarned) { _capWarned = true; console.error(`[chatgpt-roster] the registry outgrew CHATGPT_TOOL_CAP (${CHATGPT_TOOL_CAP}); dropped ${dropped.join(', ')}. Re-run node tools/chatgpt-roster-rank.mjs.`); }
|
|
3801
|
+
for (const n of names) { const fam = toolProvider(n); if (fam !== null && _capDroppedFamilies.has(fam)) { try { ctx.handleOf[n].disable(); } catch {} } }
|
|
3802
|
+
}
|
|
3680
3803
|
function registerWidgetHostSplits(rawServer, ctx, opts) {
|
|
3681
3804
|
if (!opts.widgetHost) return;
|
|
3682
3805
|
for (const [from, ops] of Object.entries(WIDGET_HOST_SPLITS)) {
|
|
@@ -3724,7 +3847,7 @@ function registerWidgetHostSplits(rawServer, ctx, opts) {
|
|
|
3724
3847
|
// error object is preserved by identity (`e.status` / `e.connector` are what notConnectedHint and both twins branch
|
|
3725
3848
|
// on); only the message text is repaired, and only when it needed repairing.
|
|
3726
3849
|
// ONE WRAPPER PER RAW HANDLER, NOT ONE PER SESSION. The tool canon exists because rebuilding 713 tool definitions
|
|
3727
|
-
// cost 36 MB of retained heap per session
|
|
3850
|
+
// cost 36 MB of retained heap per session, more than the hosted transport can carry per connection; its surviving
|
|
3728
3851
|
// guarantee is that every handler but the session-bound one is SHARED BY REFERENCE across sessions, and
|
|
3729
3852
|
// `mcp-tool-canon-check` asserts exactly that. Minting a fresh closure here at each registration would have quietly
|
|
3730
3853
|
// added 713 of them per connection and turned that assertion red — which is how this was caught. A WeakMap rather
|
|
@@ -3909,6 +4032,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3909
4032
|
if (toolUnoffered(name)) return { enabled: false, enable() {}, disable() {}, remove() {}, update() {} };
|
|
3910
4033
|
groupOf[name] = group;
|
|
3911
4034
|
try { if (handler) handler._hermosoTool = name; } catch {}
|
|
4035
|
+
try { noteBrandKeys(name, def); } catch {} // which arg names the Hermoso brand, for the after-reply notes
|
|
3912
4036
|
def = withAccountArg(name, def); // a multi-account channel's tool names WHICH account (see MULTI_ACCOUNT_CHANNELS)
|
|
3913
4037
|
// TITLE IS MIRRORED INTO `annotations`, and it is deliberately a DUPLICATE rather than a move.
|
|
3914
4038
|
// The MCP spec ranks the TOP-LEVEL `title` above `annotations.title`, and the SDK emits it top-level, so
|
|
@@ -4214,6 +4338,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4214
4338
|
return exact + pool.filter((r) => (r._cov || 0) >= best).length;
|
|
4215
4339
|
};
|
|
4216
4340
|
const GENERIC_VERB_RE = /^(read|get|list|show|fetch|view|see|check|find|give|load|open)$/;
|
|
4341
|
+
const MAKE_VERB_RE = /^(repurpose|make|generate|create|render|write|build|remix|clone)$/; // a tool named for what it MAKES out of its noun
|
|
4217
4342
|
const makeFindToolsHandler = (ctx, { quiet = false } = {}) => async ({ query = '', group = '', limit = 12, onlyHealthy = false } = {}) => {
|
|
4218
4343
|
const q = String(query || '').toLowerCase().trim();
|
|
4219
4344
|
const g = String(group || '').toLowerCase().trim();
|
|
@@ -4253,7 +4378,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4253
4378
|
// A GENERIC VERB SAYS HOW, THE NOUN SAYS WHAT (2026-10-25). `ReadTranscript` pointed call_tool at read_meta_conversation
|
|
4254
4379
|
// (its NAME has `read`, its description mentions transcripts) over analyze_video, the tool that returns a transcript.
|
|
4255
4380
|
// When the ask also carries a content word, a bare verb hit on a name counts half.
|
|
4256
|
-
|
|
4381
|
+
// …AND A READ IS NOT A MAKE (2026-11-03). `ReadTranscript` then pointed at repurpose_transcript (its noun IS
|
|
4382
|
+
// `transcript`) over analyze_video, which reads one back. When the ask is a reading verb and the tool's own leading
|
|
4383
|
+
// verb MAKES something the ask never named, the noun hit counts half: that tool turns the thing into something else.
|
|
4384
|
+
const _makesOther = nameTokens.length > 1 && exactTok !== nameTokens[0] && MAKE_VERB_RE.test(nameTokens[0]) && !words.some((o) => o.w === nameTokens[0]) && words.some((o) => !o.literal && GENERIC_VERB_RE.test(o.w));
|
|
4385
|
+
if (exactTok) { score += 4 * tokenWeight(exactTok) * (GENERIC_VERB_RE.test(w) && words.some((o) => !o.literal && !GENERIC_VERB_RE.test(o.w)) ? 0.5 : 1) * (_makesOther ? 0.5 : 1); nameHits++; directHits++; covered++; continue; } // the word IS a name token
|
|
4257
4386
|
// the best-weighted synonym/stem that is a name token — "tweet" must land on post_to_x's `x` (rare), not its `post` (everywhere)
|
|
4258
4387
|
// A synonym that names TWO tokens of one tool (transcript → analyze + video) is a phrase pointing at that tool, so
|
|
4259
4388
|
// each further token it lands on adds half its weight, and the word itself in the description confirms it (+1).
|
|
@@ -4619,10 +4748,371 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4619
4748
|
return ok(d.note, d);
|
|
4620
4749
|
}));
|
|
4621
4750
|
|
|
4751
|
+
// ── WORDPRESS (2026-11-03) — blog posts on the brand's OWN self-hosted site, through an application password ─────
|
|
4752
|
+
// Every rule and error sentence is the server's (lib/wordpress.mjs); these are thin calls, so the web app, the
|
|
4753
|
+
// Studio agent and both MCP twins can never disagree about what a WordPress post is.
|
|
4754
|
+
server.registerTool('post_to_wordpress', {
|
|
4755
|
+
title: 'Publish a WordPress blog post',
|
|
4756
|
+
description: "Use this when the user wants to publish, draft or schedule a BLOG POST on their WordPress site. Publishing costs no credits. A title is REQUIRED and the text becomes the body: plain text or light markdown (## headings, - lists, **bold**, [links](url)) is turned into WordPress blocks, and HTML is sent as written. status: publish (default), draft, pending (submit for review), private (logged-in editors only) or future with a date — a future `date` makes WordPress schedule it natively. The FIRST image becomes the FEATURED image, every other image is uploaded and placed inline, and a video is uploaded and embedded (if the site's host refuses the file as too large, the post plays it from its own link and the reply says so). categories and tags take names or ids; a name the site lacks is CREATED and the reply says which. An over-long title is refused, never cut. WordPress has NO per-post views in its API: never quote a view count for a WordPress post. Self-hosted WordPress only — a WordPress.com site needs its own OAuth sign-in and cannot be connected. Not connected: the user connects it under Workspace > Connectors > WordPress, or you call connect_connector with provider wordpress (siteUrl, username, appPassword).",
|
|
4757
|
+
inputSchema: {
|
|
4758
|
+
brand: HOOK_ATTR.brand,
|
|
4759
|
+
...INTENT_SHORT,
|
|
4760
|
+
...PUBLISH_SAFETY,
|
|
4761
|
+
title: z.string().describe('REQUIRED — the post title'),
|
|
4762
|
+
text: z.string().optional().describe('the body: plain text, light markdown or HTML'),
|
|
4763
|
+
status: z.enum(['publish', 'draft', 'pending', 'private', 'future']).optional().describe('default publish'),
|
|
4764
|
+
date: z.string().optional().describe('ISO time to publish at; a future date schedules it on WordPress itself'),
|
|
4765
|
+
excerpt: z.string().optional().describe('the summary under the title in lists and search'),
|
|
4766
|
+
slug: z.string().optional().describe('the end of the post URL'),
|
|
4767
|
+
categories: z.array(z.string()).optional().describe('category names or ids (a missing name is created)'),
|
|
4768
|
+
tags: z.array(z.string()).optional().describe('tag names or ids (a missing name is created)'),
|
|
4769
|
+
imageUrl: z.string().optional().describe('one image — becomes the featured image'),
|
|
4770
|
+
imageUrls: z.array(z.string()).optional().describe('several images: the first is featured, the rest go inline'),
|
|
4771
|
+
videoUrl: z.string().optional().describe('one video, embedded in the post'),
|
|
4772
|
+
altText: z.string().optional().describe('alt text for the images'),
|
|
4773
|
+
},
|
|
4774
|
+
outputSchema: { id: z.number().nullable().optional(), status: z.string().optional(), url: z.string().optional(), title: z.string().optional(), public: z.boolean().optional(), featuredImage: z.number().nullable().optional(), note: z.string().optional() },
|
|
4775
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
4776
|
+
}, wrap(async (a) => {
|
|
4777
|
+
const r = await apiPost('/api/wordpress/post', a);
|
|
4778
|
+
return ok(r.note || `WordPress: ${r.status} — ${r.url || ''}`, r);
|
|
4779
|
+
}));
|
|
4780
|
+
|
|
4781
|
+
server.registerTool('update_wordpress_post', {
|
|
4782
|
+
title: 'Edit a WordPress post',
|
|
4783
|
+
description: "Change a WordPress post that already exists: title, body, excerpt, slug, status (publish a draft, unpublish to draft, make private, or schedule with a future date), categories, tags or the featured image. Only the fields passed change; the reply is WordPress's own copy of the post. Free.",
|
|
4784
|
+
inputSchema: {
|
|
4785
|
+
...MANAGE_BRAND,
|
|
4786
|
+
postId: z.number().describe('the post id post_to_wordpress or list_wordpress_posts returned'),
|
|
4787
|
+
title: z.string().optional(), text: z.string().optional().describe('the new body — replaces the old one'), excerpt: z.string().optional(), slug: z.string().optional(),
|
|
4788
|
+
status: z.enum(['publish', 'draft', 'pending', 'private', 'future']).optional(), date: z.string().optional(),
|
|
4789
|
+
categories: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
|
|
4790
|
+
featuredImageUrl: z.string().optional().describe('a new featured image'),
|
|
4791
|
+
},
|
|
4792
|
+
outputSchema: { id: z.number().nullable().optional(), status: z.string().optional(), url: z.string().optional(), title: z.string().optional(), changed: z.array(z.string()).optional(), note: z.string().optional() },
|
|
4793
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
4794
|
+
}, wrap(async (a) => {
|
|
4795
|
+
const { brand, ...rest } = a;
|
|
4796
|
+
const d = await apiPost('/api/wordpress/update', bodyBrand(a, rest));
|
|
4797
|
+
return ok(`Updated “${d.title}” (post ${d.id}, ${d.status})${d.url ? ` — ${d.url}` : ''}.${d.note ? ` ${d.note}` : ''}`, d);
|
|
4798
|
+
}));
|
|
4799
|
+
|
|
4800
|
+
server.registerTool('delete_wordpress_post', {
|
|
4801
|
+
title: 'Delete a WordPress post',
|
|
4802
|
+
description: "Delete a WordPress post. Call it WITHOUT confirm first: nothing is deleted and you get a sentence naming the post to show the user. By default it goes to WordPress's TRASH (restorable from Posts > Trash); permanent:true skips the trash and cannot be undone. Free.",
|
|
4803
|
+
inputSchema: {
|
|
4804
|
+
...MANAGE_BRAND,
|
|
4805
|
+
postId: z.number(),
|
|
4806
|
+
permanent: z.boolean().optional().describe('skip the trash — cannot be undone'),
|
|
4807
|
+
confirm: z.boolean().optional().describe('true only once the user agreed to the sentence the first call returned'),
|
|
4808
|
+
},
|
|
4809
|
+
outputSchema: { deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), permanent: z.boolean().optional(), postId: z.number().optional(), title: z.string().optional(), note: z.string().optional() },
|
|
4810
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
4811
|
+
}, wrap(async (a) => {
|
|
4812
|
+
const d = await apiPost('/api/wordpress/delete', bodyBrand(a, { postId: a.postId, permanent: a.permanent === true, confirm: a.confirm === true }));
|
|
4813
|
+
return ok(d.note, d);
|
|
4814
|
+
}));
|
|
4815
|
+
|
|
4816
|
+
server.registerTool('list_wordpress_posts', {
|
|
4817
|
+
title: 'List WordPress posts',
|
|
4818
|
+
description: "List the posts on the brand's WordPress site, newest first — published, scheduled, drafts, pending and private — each with its id, status, title and live URL. Free. WordPress publishes no per-post view counts, so this carries none.",
|
|
4819
|
+
inputSchema: {
|
|
4820
|
+
...MANAGE_BRAND,
|
|
4821
|
+
status: z.string().optional().describe('one status or a comma list: publish, future, draft, pending, private (default all)'),
|
|
4822
|
+
search: z.string().optional(), page: z.number().optional(), perPage: z.number().optional().describe('1–100, default 20'),
|
|
4823
|
+
},
|
|
4824
|
+
outputSchema: { posts: z.array(z.any()).optional(), total: z.number().optional(), pages: z.number().optional(), page: z.number().optional(), site: z.string().optional(), note: z.string().optional() },
|
|
4825
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4826
|
+
}, wrap(async (a = {}) => {
|
|
4827
|
+
const d = await apiGet('/api/wordpress/posts', queryBrand(a, { status: a.status, search: a.search, page: a.page, perPage: a.perPage }));
|
|
4828
|
+
const rows = (d.posts || []).map((p) => `• ${p.id} · ${p.status} · “${p.title}”${p.url ? ` — ${p.url}` : ''}`);
|
|
4829
|
+
return ok(`${d.total} post(s) on ${d.siteName || d.site} (page ${d.page} of ${d.pages}):\n${rows.join('\n') || '(none)'}`, d);
|
|
4830
|
+
}));
|
|
4831
|
+
|
|
4832
|
+
server.registerTool('get_wordpress_post', {
|
|
4833
|
+
title: 'Read a WordPress post',
|
|
4834
|
+
description: "Read one WordPress post back — WordPress's own title, status, live URL, excerpt and body. Use it to confirm a post really published. Free.",
|
|
4835
|
+
inputSchema: { ...MANAGE_BRAND, postId: z.number() },
|
|
4836
|
+
outputSchema: { id: z.number().nullable().optional(), status: z.string().optional(), url: z.string().optional(), title: z.string().optional(), public: z.boolean().optional(), excerpt: z.string().optional(), content: z.string().optional() },
|
|
4837
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4838
|
+
}, wrap(async (a) => {
|
|
4839
|
+
const d = await apiGet(`/api/wordpress/posts/${encodeURIComponent(String(a.postId))}`, queryBrand(a, {}));
|
|
4840
|
+
return ok(`Post ${d.id} · ${d.status}${d.public ? ' (public)' : ' (not public)'} · “${d.title}”${d.url ? ` — ${d.url}` : ''}`, d);
|
|
4841
|
+
}));
|
|
4842
|
+
|
|
4843
|
+
server.registerTool('list_wordpress_terms', {
|
|
4844
|
+
title: 'List WordPress categories or tags',
|
|
4845
|
+
description: "List the WordPress site's categories or tags (kind), most-used first, each with its id. Free.",
|
|
4846
|
+
inputSchema: { ...MANAGE_BRAND, kind: z.enum(['categories', 'tags']), search: z.string().optional() },
|
|
4847
|
+
outputSchema: { kind: z.string().optional(), terms: z.array(z.any()).optional(), total: z.number().optional() },
|
|
4848
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4849
|
+
}, wrap(async (a) => {
|
|
4850
|
+
const d = await apiGet(`/api/wordpress/${a.kind === 'categories' ? 'categories' : 'tags'}`, queryBrand(a, { search: a.search }));
|
|
4851
|
+
return ok(`${d.total} ${d.kind}:\n${(d.terms || []).map((t) => `• ${t.name} (id ${t.id}, ${t.count} posts)`).join('\n') || '(none)'}`, d);
|
|
4852
|
+
}));
|
|
4853
|
+
|
|
4854
|
+
server.registerTool('create_wordpress_term', {
|
|
4855
|
+
title: 'Create a WordPress category or tag',
|
|
4856
|
+
description: "Create a category or tag on the WordPress site. (post_to_wordpress also creates any category or tag NAME it is given that the site lacks.) Free.",
|
|
4857
|
+
inputSchema: { ...MANAGE_BRAND, kind: z.enum(['categories', 'tags']), name: z.string(), parent: z.number().optional().describe('a parent category id (categories only)'), description: z.string().optional() },
|
|
4858
|
+
outputSchema: { kind: z.string().optional(), id: z.number().optional(), name: z.string().optional(), slug: z.string().optional() },
|
|
4859
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
4860
|
+
}, wrap(async (a) => {
|
|
4861
|
+
const d = await apiPost('/api/wordpress/terms', bodyBrand(a, { kind: a.kind, name: a.name, parent: a.parent, description: a.description }));
|
|
4862
|
+
return ok(`Created the ${d.kind === 'categories' ? 'category' : 'tag'} “${d.name}” (id ${d.id}).`, d);
|
|
4863
|
+
}));
|
|
4864
|
+
|
|
4865
|
+
// ── DISCORD + SLACK ANNOUNCEMENTS (2026-11-03) — through the ANNOUNCEMENT webhook, never the alert one ──────────
|
|
4866
|
+
server.registerTool('post_to_discord', {
|
|
4867
|
+
title: 'Post a Discord announcement',
|
|
4868
|
+
description: "Post to the user's Discord server. Free. With the Hermoso bot added it posts as the bot to channel (or the brand's default channel) with attachments up to 25 MB a message, replyTo, threadId (inside a thread), startThread (a thread under the post) and threadName (a forum channel); the messageId it returns (\"<channel>/<message>\") is what edit, delete and get_discord_message take. Without the bot it posts through the brand's announcement webhook (never the alert one): ≤2000 characters, files up to 20 MB attached (up to 10), larger ones as links. Mentions never ping unless mentions:true. Reactions are the only per-post number (get_discord_message). Not connected: connect_connector with provider discord and no fields returns the install link.",
|
|
4869
|
+
inputSchema: {
|
|
4870
|
+
brand: HOOK_ATTR.brand,
|
|
4871
|
+
...INTENT_SHORT,
|
|
4872
|
+
...PUBLISH_SAFETY,
|
|
4873
|
+
text: z.string().optional().describe('the announcement, ≤2000 characters'),
|
|
4874
|
+
imageUrl: z.string().optional(), imageUrls: z.array(z.string()).optional().describe('up to 10 files in one message'), videoUrl: z.string().optional(),
|
|
4875
|
+
mentions: z.boolean().optional().describe('let @everyone / @here / role mentions ping people (off by default)'),
|
|
4876
|
+
silent: z.boolean().optional().describe('post without a notification'),
|
|
4877
|
+
threadName: z.string().optional().describe('required when the channel is a FORUM channel: the new thread title'),
|
|
4878
|
+
username: z.string().optional().describe("webhook posts only: a different display name than the webhook's own"),
|
|
4879
|
+
discordServer: z.string().optional().describe('HERMOSO BOT: which Discord server (id or name) when the bot is in several'),
|
|
4880
|
+
channel: z.string().optional().describe('HERMOSO BOT: channel id or #name (list_discord_channels); unset = the default channel'),
|
|
4881
|
+
replyTo: z.string().optional().describe('HERMOSO BOT: reply to this message id'),
|
|
4882
|
+
threadId: z.string().optional().describe('HERMOSO BOT: post inside this existing thread'),
|
|
4883
|
+
startThread: z.string().optional().describe('HERMOSO BOT: start a thread under the new post with this title'),
|
|
4884
|
+
},
|
|
4885
|
+
outputSchema: { ok: z.boolean().optional(), via: z.string().optional(), messageId: z.string().optional(), channelId: z.string().optional(), channelName: z.string().nullable().optional(), server: z.string().optional(), threadId: z.string().optional(), url: z.string().nullable().optional(), attached: z.number().optional(), linked: z.number().optional(), note: z.string().optional() },
|
|
4886
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
4887
|
+
}, wrap(async (a) => {
|
|
4888
|
+
const r = await apiPost('/api/discord/post', a);
|
|
4889
|
+
return ok(r.note || `Posted to Discord${r.url ? ` — ${r.url}` : ''}`, r);
|
|
4890
|
+
}));
|
|
4891
|
+
|
|
4892
|
+
server.registerTool('edit_discord_message', {
|
|
4893
|
+
title: 'Edit a Discord announcement',
|
|
4894
|
+
description: "Change the text of a Discord message Hermoso posted (≤2000 characters) — the bot's or the announcement webhook's. Pass messageId exactly as post_to_discord returned it. Mentions never ping on an edit. Free.",
|
|
4895
|
+
inputSchema: { ...MANAGE_BRAND, messageId: z.string().describe('the message id post_to_discord returned (a bot post\'s is "<channel>/<message>")'), channel: z.string().optional().describe('the channel, when messageId is a bare id of a BOT post'), text: z.string() },
|
|
4896
|
+
outputSchema: { edited: z.boolean().optional(), messageId: z.string().optional(), url: z.string().nullable().optional() },
|
|
4897
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
4898
|
+
}, wrap(async (a) => {
|
|
4899
|
+
const d = await apiPost('/api/discord/edit', bodyBrand(a, { messageId: a.messageId, channel: a.channel, text: a.text }));
|
|
4900
|
+
return ok(`Edited the Discord announcement ${d.messageId}${d.url ? ` — ${d.url}` : ''}.`, d);
|
|
4901
|
+
}));
|
|
4902
|
+
|
|
4903
|
+
server.registerTool('delete_discord_message', {
|
|
4904
|
+
title: 'Delete a Discord announcement',
|
|
4905
|
+
description: "PERMANENTLY delete a Discord message Hermoso posted (the bot's or the announcement webhook's). Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Free.",
|
|
4906
|
+
inputSchema: { ...MANAGE_BRAND, messageId: z.string(), channel: z.string().optional(), confirm: z.boolean().optional().describe('true only once the user agreed') },
|
|
4907
|
+
outputSchema: { deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), messageId: z.string().optional(), note: z.string().optional() },
|
|
4908
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
4909
|
+
}, wrap(async (a) => {
|
|
4910
|
+
const d = await apiPost('/api/discord/delete-message', bodyBrand(a, { messageId: a.messageId, channel: a.channel, confirm: a.confirm === true }));
|
|
4911
|
+
return ok(d.note, d);
|
|
4912
|
+
}));
|
|
4913
|
+
|
|
4914
|
+
server.registerTool('get_discord_message', {
|
|
4915
|
+
title: 'Read a Discord announcement',
|
|
4916
|
+
description: "Read back a Discord message Hermoso posted: its text, link and REACTIONS — the only engagement Discord exposes (there is no view count). Free.",
|
|
4917
|
+
inputSchema: { ...MANAGE_BRAND, messageId: z.string(), channel: z.string().optional() },
|
|
4918
|
+
outputSchema: { messageId: z.string().optional(), content: z.string().optional(), url: z.string().nullable().optional(), reactions: z.array(z.any()).optional(), reactionCount: z.number().nullable().optional(), note: z.string().optional() },
|
|
4919
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4920
|
+
}, wrap(async (a) => {
|
|
4921
|
+
const d = await apiGet('/api/discord/message', queryBrand(a, { messageId: a.messageId, ...(a.channel ? { channel: a.channel } : {}) }));
|
|
4922
|
+
return ok(`Discord message ${d.messageId}: ${d.reactionCount} reaction(s)${(d.reactions || []).length ? ` (${d.reactions.map((r) => `${r.emoji} ${r.count}`).join(', ')})` : ''}.${d.url ? ` ${d.url}` : ''}`, d);
|
|
4923
|
+
}));
|
|
4924
|
+
|
|
4925
|
+
server.registerTool('post_to_slack', {
|
|
4926
|
+
title: 'Post a Slack announcement',
|
|
4927
|
+
description: "Post to the user's Slack. Free. With the Hermoso app installed it posts as the app to channel (or the brand's default channel), uploads images and video, replies in a thread (threadTs) or schedules in Slack (postAt, text only, ≤120 days); the messageId it returns (\"<channel>/<ts>\") is what edit_slack_message, delete_slack_message and get_slack_message take. Without the app it posts through the brand's announcement webhook (never the alert one): text ≤40,000 characters (refused, never cut), a title line, png/jpg/gif images by link, video as a link; a webhook post has no id, so it cannot be edited, deleted or measured. Not connected: connect_connector with provider slack and no fields returns the install link.",
|
|
4928
|
+
inputSchema: {
|
|
4929
|
+
brand: HOOK_ATTR.brand,
|
|
4930
|
+
...INTENT_SHORT,
|
|
4931
|
+
...PUBLISH_SAFETY,
|
|
4932
|
+
text: z.string().optional(), title: z.string().optional().describe('a header line above the text'),
|
|
4933
|
+
imageUrl: z.string().optional(), imageUrls: z.array(z.string()).optional(), videoUrl: z.string().optional(),
|
|
4934
|
+
altText: z.string().optional().describe('alt text for the images'),
|
|
4935
|
+
slackWorkspace: z.string().optional().describe('HERMOSO APP: which Slack workspace (id or name) when the app is in several'),
|
|
4936
|
+
channel: z.string().optional().describe('HERMOSO APP: channel id or #name (list_slack_channels); unset = the default channel'),
|
|
4937
|
+
threadTs: z.string().optional().describe('HERMOSO APP: reply in this thread (the parent message ts)'),
|
|
4938
|
+
replyBroadcast: z.boolean().optional().describe('HERMOSO APP: also show the thread reply in the channel'),
|
|
4939
|
+
postAt: z.string().optional().describe('HERMOSO APP: schedule in Slack itself for this ISO time (≤120 days, text only; use schedule_post for files)'),
|
|
4940
|
+
unfurlLinks: z.boolean().optional().describe('false = no link previews'),
|
|
4941
|
+
},
|
|
4942
|
+
outputSchema: { ok: z.boolean().optional(), via: z.string().optional(), messageId: z.string().optional(), channel: z.string().optional(), channelName: z.string().nullable().optional(), slackWorkspace: z.string().optional(), scheduledMessageId: z.string().optional(), postAt: z.string().optional(), url: z.string().nullable().optional(), images: z.number().optional(), linked: z.number().optional(), files: z.number().optional(), note: z.string().optional() },
|
|
4943
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
4944
|
+
}, wrap(async (a) => {
|
|
4945
|
+
const r = await apiPost('/api/slack/post', a);
|
|
4946
|
+
return ok(r.via === 'app' && r.messageId ? `${r.note} Message id ${r.messageId}.` : (r.note || 'Posted to Slack.'), r);
|
|
4947
|
+
}));
|
|
4948
|
+
|
|
4949
|
+
// ── THE HERMOSO SLACK + DISCORD APPS (2026-11-03) — channels, the default channel, history, edit/delete/read, Slack's scheduler
|
|
4950
|
+
const chatChannelsText = (p, d) => {
|
|
4951
|
+
const all = (p === 'slack' ? d.workspaces : d.servers) || []; const where = (p === 'slack' ? d.workspace : d.server) || {};
|
|
4952
|
+
return `${all.length} ${p === 'slack' ? 'workspace' : 'server'}(s) with the Hermoso app: ${all.map((w) => `${w.name} (${w.id})${w.isDefault ? ' [default]' : ''}`).join(', ')}.\nChannels in ${where.name}:\n${(d.channels || []).map((c) => `• #${c.name} (${c.id})${c.private ? ' · private' : ''}${c.kind && c.kind !== 'text' ? ` · ${c.kind}` : ''}${c.isDefault ? ' · DEFAULT' : ''}`).join('\n') || '(none)'}${d.defaultChannel ? '' : `\nNo default channel yet — ask the user which one, then set_${p}_channel.`}`;
|
|
4953
|
+
};
|
|
4954
|
+
const chatHistoryText = (d) => `${d.count} recent message(s) in #${d.channelName || d.channel}:\n${(d.messages || []).map((m) => `• ${m.at} ${m.fromHermoso ? 'Hermoso' : (m.author || m.user || '?')}: ${m.contentHidden ? '[text withheld by Discord]' : String(m.text).replace(/\s+/g, ' ').slice(0, 400)}${m.files ? ` [${m.files.length} file(s)]` : ''}`).join('\n') || '(none)'}${d.note ? `\n${d.note}` : ''}`;
|
|
4955
|
+
// ONE definition per platform, registered under LITERAL names (tools/mcp-parity.mjs scans the source for them).
|
|
4956
|
+
const chatList = (p) => {
|
|
4957
|
+
const W = p === 'slack' ? 'workspace' : 'server'; const A = p === 'slack' ? 'slackWorkspace' : 'discordServer'; const L = p === 'slack' ? 'Slack' : 'Discord';
|
|
4958
|
+
return [{
|
|
4959
|
+
title: `List ${L} channels`,
|
|
4960
|
+
description: `The ${L} ${W}s this brand added the Hermoso app to, and the channels in one of them${p === 'slack' ? ' (public, plus private ones the app was invited to with /invite @Hermoso)' : ' (text, announcement and forum channels)'}, with the default channel marked. Use it before posting when the user has not named a channel, and to show them the choice. Free.`,
|
|
4961
|
+
inputSchema: { ...MANAGE_BRAND, [A]: z.string().optional().describe(`which ${W} (id or name) when there are several`), query: z.string().optional().describe('filter channel names') },
|
|
4962
|
+
outputSchema: { channels: z.array(z.any()).optional(), [`${W}s`]: z.array(z.any()).optional(), [W]: z.any().optional(), defaultChannel: z.any().optional(), note: z.string().optional() },
|
|
4963
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4964
|
+
}, wrap(async (a) => { const d = await apiGet(`/api/${p}/channels`, queryBrand(a, { ...(a[A] ? { [A]: a[A] } : {}), ...(a.query ? { query: a.query } : {}) })); return ok(chatChannelsText(p, d), d); })];
|
|
4965
|
+
};
|
|
4966
|
+
const chatSet = (p) => {
|
|
4967
|
+
const W = p === 'slack' ? 'workspace' : 'server'; const A = p === 'slack' ? 'slackWorkspace' : 'discordServer'; const L = p === 'slack' ? 'Slack' : 'Discord';
|
|
4968
|
+
return [{
|
|
4969
|
+
// its title is declared at each registration below, as a literal: the Studio's confirm card reads titles from source
|
|
4970
|
+
description: `Choose the DEFAULT ${L} channel the Hermoso app posts to: post_to_${p}, scheduled ${L} posts and Autopilot use it whenever a post names no channel. Shared with everyone on the brand. Ask the user which channel; never pick one for them. Free.`,
|
|
4971
|
+
inputSchema: { ...MANAGE_BRAND, [A]: z.string().optional(), channel: z.string().describe(`channel id or #name from list_${p}_channels`) },
|
|
4972
|
+
outputSchema: { ok: z.boolean().optional(), channel: z.string().optional(), channelName: z.string().nullable().optional(), note: z.string().optional() },
|
|
4973
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4974
|
+
}, wrap(async (a) => { const d = await apiPost(`/api/${p}/target`, bodyBrand(a, { [A]: a[A], channel: a.channel })); return ok(d.note, d); })];
|
|
4975
|
+
};
|
|
4976
|
+
const chatRead = (p) => {
|
|
4977
|
+
const W = p === 'slack' ? 'workspace' : 'server'; const A = p === 'slack' ? 'slackWorkspace' : 'discordServer'; const L = p === 'slack' ? 'Slack' : 'Discord';
|
|
4978
|
+
return [{
|
|
4979
|
+
title: `Read a ${L} channel`,
|
|
4980
|
+
description: `Read a ${L} channel's recent messages, newest first, for context — what ${p === 'slack' ? 'the team is discussing' : 'members are saying'} before you write${p === 'slack' ? '. A public channel is joined first if needed; a private one needs /invite @Hermoso' : '. Other people’s text needs Discord’s Message Content intent; a message Discord withholds is marked contentHidden'}. Free.`,
|
|
4981
|
+
inputSchema: { ...MANAGE_BRAND, [A]: z.string().optional(), channel: z.string().optional().describe('channel id or #name; unset = the default channel'), limit: z.number().optional().describe(`how many, 1–${p === 'slack' ? 200 : 100} (default 20)`), ...(p === 'slack' ? { oldest: z.string().optional().describe('only messages after this ts') } : { before: z.string().optional().describe('page back from this message id') }) },
|
|
4982
|
+
outputSchema: { messages: z.array(z.any()).optional(), count: z.number().optional(), channel: z.string().optional(), channelName: z.string().nullable().optional(), note: z.string().optional() },
|
|
4983
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4984
|
+
}, wrap(async (a) => { const { brand: _b, ...q } = a; const d = await apiGet(`/api/${p}/history`, queryBrand(a, Object.fromEntries(Object.entries(q).filter(([, v]) => v != null && v !== '')))); return ok(chatHistoryText(d), d); })];
|
|
4985
|
+
};
|
|
4986
|
+
// Annotations are spelled LITERALLY on each registration: lib/studio-approvals.mjs parses them from this source.
|
|
4987
|
+
const CHAT_TOOLS = { slack: { list: chatList('slack'), set: chatSet('slack'), read: chatRead('slack') }, discord: { list: chatList('discord'), set: chatSet('discord'), read: chatRead('discord') } };
|
|
4988
|
+
server.registerTool('list_slack_channels', { ...CHAT_TOOLS.slack.list[0], annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, CHAT_TOOLS.slack.list[1]);
|
|
4989
|
+
server.registerTool('set_slack_channel', { title: 'Set the default Slack channel', ...CHAT_TOOLS.slack.set[0], annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, CHAT_TOOLS.slack.set[1]);
|
|
4990
|
+
server.registerTool('read_slack_channel', { ...CHAT_TOOLS.slack.read[0], annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, CHAT_TOOLS.slack.read[1]);
|
|
4991
|
+
server.registerTool('list_discord_channels', { ...CHAT_TOOLS.discord.list[0], annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, CHAT_TOOLS.discord.list[1]);
|
|
4992
|
+
server.registerTool('set_discord_channel', { title: 'Set the default Discord channel', ...CHAT_TOOLS.discord.set[0], annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, CHAT_TOOLS.discord.set[1]);
|
|
4993
|
+
server.registerTool('read_discord_channel', { ...CHAT_TOOLS.discord.read[0], annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, CHAT_TOOLS.discord.read[1]);
|
|
4994
|
+
server.registerTool('edit_slack_message', {
|
|
4995
|
+
title: 'Edit a Slack message',
|
|
4996
|
+
description: "Change the text of a Slack message the Hermoso app posted (≤4,000 characters, Slack's edit limit). messageId is what post_to_slack returned (\"<channel>/<ts>\"). A message posted through a pasted webhook has no id and cannot be edited. Free.",
|
|
4997
|
+
inputSchema: { ...MANAGE_BRAND, messageId: z.string(), text: z.string(), slackWorkspace: z.string().optional() },
|
|
4998
|
+
outputSchema: { edited: z.boolean().optional(), messageId: z.string().optional(), url: z.string().nullable().optional() },
|
|
4999
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5000
|
+
}, wrap(async (a) => { const d = await apiPost('/api/slack/edit', bodyBrand(a, { messageId: a.messageId, text: a.text, slackWorkspace: a.slackWorkspace })); return ok(`Edited the Slack message ${d.messageId}${d.url ? ` — ${d.url}` : ''}.`, d); }));
|
|
5001
|
+
server.registerTool('delete_slack_message', {
|
|
5002
|
+
title: 'Delete a Slack message',
|
|
5003
|
+
description: 'PERMANENTLY delete a Slack message the Hermoso app posted. Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Free.',
|
|
5004
|
+
inputSchema: { ...MANAGE_BRAND, messageId: z.string(), slackWorkspace: z.string().optional(), confirm: z.boolean().optional().describe('true only once the user agreed') },
|
|
5005
|
+
outputSchema: { deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), messageId: z.string().optional(), note: z.string().optional() },
|
|
5006
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5007
|
+
}, wrap(async (a) => { const d = await apiPost('/api/slack/delete-message', bodyBrand(a, { messageId: a.messageId, slackWorkspace: a.slackWorkspace, confirm: a.confirm === true })); return ok(d.note, d); }));
|
|
5008
|
+
server.registerTool('get_slack_message', {
|
|
5009
|
+
title: 'Read a Slack message',
|
|
5010
|
+
description: 'Read back a Slack message the Hermoso app posted: text, link, REACTIONS and thread REPLIES — the engagement Slack exposes (there is no view count). Free.',
|
|
5011
|
+
inputSchema: { ...MANAGE_BRAND, messageId: z.string(), slackWorkspace: z.string().optional() },
|
|
5012
|
+
outputSchema: { messageId: z.string().optional(), text: z.string().optional(), url: z.string().nullable().optional(), reactions: z.array(z.any()).optional(), reactionCount: z.number().nullable().optional(), replies: z.number().optional(), note: z.string().optional() },
|
|
5013
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5014
|
+
}, wrap(async (a) => { const d = await apiGet('/api/slack/message', queryBrand(a, { messageId: a.messageId, ...(a.slackWorkspace ? { slackWorkspace: a.slackWorkspace } : {}) })); return ok(`Slack message ${d.messageId}: ${d.reactionCount} reaction(s), ${d.replies} thread repl${d.replies === 1 ? 'y' : 'ies'}.${d.url ? ` ${d.url}` : ''}`, d); }));
|
|
5015
|
+
server.registerTool('list_slack_scheduled', {
|
|
5016
|
+
title: 'List messages scheduled in Slack',
|
|
5017
|
+
description: "Messages the Hermoso app scheduled IN SLACK (post_to_slack with postAt) that have not posted yet. Posts queued with schedule_post are in list_scheduled instead. Free.",
|
|
5018
|
+
inputSchema: { ...MANAGE_BRAND, slackWorkspace: z.string().optional(), channel: z.string().optional() },
|
|
5019
|
+
outputSchema: { scheduled: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
5020
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5021
|
+
}, wrap(async (a) => { const d = await apiGet('/api/slack/scheduled', queryBrand(a, { ...(a.slackWorkspace ? { slackWorkspace: a.slackWorkspace } : {}), ...(a.channel ? { channel: a.channel } : {}) })); return ok(`${d.count} message(s) scheduled in Slack:\n${(d.scheduled || []).map((x) => `• ${x.id} · ${x.postAt} · ${x.channel} · “${String(x.text).slice(0, 120)}”`).join('\n') || '(none)'}\n${d.note}`, d); }));
|
|
5022
|
+
server.registerTool('cancel_slack_scheduled', {
|
|
5023
|
+
title: 'Cancel a message scheduled in Slack',
|
|
5024
|
+
description: 'Cancel a message scheduled in Slack (from list_slack_scheduled). Slack refuses once it is within 60 seconds of posting. Free.',
|
|
5025
|
+
inputSchema: { ...MANAGE_BRAND, slackWorkspace: z.string().optional(), channel: z.string(), scheduledMessageId: z.string() },
|
|
5026
|
+
outputSchema: { cancelled: z.boolean().optional(), scheduledMessageId: z.string().optional(), note: z.string().optional() },
|
|
5027
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5028
|
+
}, wrap(async (a) => { const d = await apiPost('/api/slack/scheduled/cancel', bodyBrand(a, { slackWorkspace: a.slackWorkspace, channel: a.channel, scheduledMessageId: a.scheduledMessageId })); return ok(d.note, d); }));
|
|
5029
|
+
// ── WHOP (2026-11-03) — the brand's own whop, posted into as the Hermoso app it installed ──────────────────────
|
|
5030
|
+
server.registerTool('post_to_whop', {
|
|
5031
|
+
title: 'Post to Whop',
|
|
5032
|
+
description: "Use this when the user wants to post into their WHOP (the community they sell on Whop). Publishing costs no credits. A FORUM post takes a title, Markdown text, images and video, pinning, members-only or public visibility, a paywall and a poll; a CHAT message takes text and files. It posts as the Hermoso app the whop installed. experienceId picks the forum or chat (list_whop_destinations names them); with one forum or chat it is used, with several and none named the call is refused and lists them. Mentions are OFF unless mention:true (it notifies every member). Returns the post id for edit_whop_post / delete_whop_post / get_whop_post. Not connected: the seller installs Hermoso from the Whop App Store and presses Open Hermoso in the whop's dashboard; nothing is billed through Whop.",
|
|
5033
|
+
inputSchema: {
|
|
5034
|
+
brand: HOOK_ATTR.brand,
|
|
5035
|
+
...INTENT_SHORT,
|
|
5036
|
+
...PUBLISH_SAFETY,
|
|
5037
|
+
experienceId: z.string().optional().describe('the forum or chat (exp_…) from list_whop_destinations'),
|
|
5038
|
+
text: z.string().optional().describe('the post or message, Markdown'),
|
|
5039
|
+
title: z.string().optional().describe('forum posts: the headline (required with a paywall)'),
|
|
5040
|
+
imageUrl: z.string().optional(), imageUrls: z.array(z.string()).optional().describe('up to 10 files'), videoUrl: z.string().optional(),
|
|
5041
|
+
pinned: z.boolean().optional().describe('forum posts: pin to the top'),
|
|
5042
|
+
mention: z.boolean().optional().describe('forum posts: notify every member (off by default)'),
|
|
5043
|
+
visibility: z.enum(['members_only', 'globally_visible']).optional().describe('forum posts: who can see it'),
|
|
5044
|
+
paywallAmount: z.number().optional().describe('forum posts: price to unlock, e.g. 5 for $5 (needs a title)'),
|
|
5045
|
+
paywallCurrency: z.string().optional().describe('paywall currency, default usd'),
|
|
5046
|
+
poll: z.array(z.string()).optional().describe('2 to 10 poll options'),
|
|
5047
|
+
replyTo: z.string().optional().describe('reply to this post or message id'),
|
|
5048
|
+
},
|
|
5049
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), kind: z.string().optional(), experienceId: z.string().optional(), destination: z.string().optional(), files: z.number().optional(), url: z.string().nullable().optional(), note: z.string().optional() },
|
|
5050
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5051
|
+
}, wrap(async (a) => {
|
|
5052
|
+
const r = await apiPost('/api/whop/post', a);
|
|
5053
|
+
return ok(r.note || `Posted to Whop (id ${r.id}).`, r);
|
|
5054
|
+
}));
|
|
5055
|
+
|
|
5056
|
+
server.registerTool('list_whop_destinations', {
|
|
5057
|
+
title: 'List Whop forums and chats',
|
|
5058
|
+
description: "List the forums and chats in the brand's whop that Hermoso can post into, each with its experienceId and kind (forum or chat). Free.",
|
|
5059
|
+
inputSchema: { ...MANAGE_BRAND },
|
|
5060
|
+
outputSchema: { company: z.any().optional(), destinations: z.array(z.any()).optional(), note: z.string().optional() },
|
|
5061
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5062
|
+
}, wrap(async (a) => {
|
|
5063
|
+
const d = await apiGet('/api/whop/destinations', queryBrand(a, {}));
|
|
5064
|
+
return ok(`${(d.company && (d.company.title || d.company.id)) || 'Whop'}: ${(d.destinations || []).map((x) => `${x.name} (${x.kind}, ${x.id})`).join('; ') || 'no forum or chat'}. ${d.note || ''}`, d);
|
|
5065
|
+
}));
|
|
5066
|
+
|
|
5067
|
+
server.registerTool('list_whop_products', {
|
|
5068
|
+
title: 'Read Whop products',
|
|
5069
|
+
description: "Read the brand's Whop products as Hermoso sees them: names, prices and product photos, plus the brand description Hermoso drafts from them (draft_brand with that description sets the brand up). Free.",
|
|
5070
|
+
inputSchema: { ...MANAGE_BRAND },
|
|
5071
|
+
outputSchema: { name: z.string().optional(), description: z.string().optional(), products: z.array(z.any()).optional(), productImages: z.array(z.string()).optional(), website: z.string().optional() },
|
|
5072
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5073
|
+
}, wrap(async (a) => {
|
|
5074
|
+
const d = await apiGet('/api/whop/seed', queryBrand(a, {}));
|
|
5075
|
+
return ok(`${d.name || 'Whop'}: ${(d.products || []).map((p) => `${p.title}${p.price ? ` (${p.price})` : ''}`).join('; ') || 'no products'}. ${(d.productImages || []).length} product photo(s).`, d);
|
|
5076
|
+
}));
|
|
5077
|
+
|
|
5078
|
+
server.registerTool('edit_whop_post', {
|
|
5079
|
+
title: 'Edit a Whop post',
|
|
5080
|
+
description: "Change a Whop forum post (text, title, pinned, visibility) or chat message (text, pinned) that Hermoso posted. Free.",
|
|
5081
|
+
inputSchema: { ...MANAGE_BRAND, id: z.string().describe('the id post_to_whop returned'), kind: z.enum(['forum', 'chat']), text: z.string().optional(), title: z.string().optional(), pinned: z.boolean().optional(), visibility: z.enum(['members_only', 'globally_visible']).optional() },
|
|
5082
|
+
outputSchema: { edited: z.boolean().optional(), id: z.string().optional(), kind: z.string().optional(), changed: z.array(z.string()).optional(), note: z.string().optional() },
|
|
5083
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5084
|
+
}, wrap(async (a) => {
|
|
5085
|
+
const d = await apiPost('/api/whop/edit', bodyBrand(a, { id: a.id, kind: a.kind, text: a.text, title: a.title, pinned: a.pinned, visibility: a.visibility }));
|
|
5086
|
+
return ok(d.note, d);
|
|
5087
|
+
}));
|
|
5088
|
+
|
|
5089
|
+
server.registerTool('delete_whop_post', {
|
|
5090
|
+
title: 'Delete a Whop chat message',
|
|
5091
|
+
description: "PERMANENTLY delete a Whop CHAT message Hermoso posted. Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Whop's API cannot delete a FORUM post; asked to, it says so and points to Whop. Free.",
|
|
5092
|
+
inputSchema: { ...MANAGE_BRAND, id: z.string(), kind: z.enum(['forum', 'chat']), confirm: z.boolean().optional().describe('true only once the user agreed') },
|
|
5093
|
+
outputSchema: { deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), unsupported: z.boolean().optional(), id: z.string().optional(), note: z.string().optional() },
|
|
5094
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5095
|
+
}, wrap(async (a) => {
|
|
5096
|
+
const d = await apiPost('/api/whop/delete', bodyBrand(a, { id: a.id, kind: a.kind, confirm: a.confirm === true }));
|
|
5097
|
+
return ok(d.note, d);
|
|
5098
|
+
}));
|
|
5099
|
+
|
|
5100
|
+
server.registerTool('get_whop_post', {
|
|
5101
|
+
title: 'Read a Whop post',
|
|
5102
|
+
description: "Read back a Whop forum post or chat message Hermoso posted: its text and the numbers Whop reports (views, likes and comments on a forum post; views and reactions on a chat message). A count Whop does not report is listed as unavailable, never zero. Free.",
|
|
5103
|
+
inputSchema: { ...MANAGE_BRAND, id: z.string(), kind: z.enum(['forum', 'chat']) },
|
|
5104
|
+
outputSchema: { id: z.string().optional(), kind: z.string().optional(), title: z.string().nullable().optional(), content: z.string().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), note: z.string().optional() },
|
|
5105
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5106
|
+
}, wrap(async (a) => {
|
|
5107
|
+
const d = await apiGet('/api/whop/post', queryBrand(a, { id: a.id, kind: a.kind }));
|
|
5108
|
+
const m = d.metrics || {};
|
|
5109
|
+
return ok(`Whop ${d.kind === 'chat' ? 'chat message' : 'forum post'} ${d.id}: ${Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${v} ${k}`).join(', ') : 'no counts'}.`, d);
|
|
5110
|
+
}));
|
|
5111
|
+
|
|
4622
5112
|
server.registerTool('list_inbox', {
|
|
4623
5113
|
title: 'One inbox for comments, replies, mentions, reviews and DMs',
|
|
4624
5114
|
description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
|
|
4625
|
-
+ "Instagram comments, Threads replies and mentions, YouTube
|
|
5115
|
+
+ "Instagram comments, Threads replies and mentions, YouTube comments, Google Business reviews, "
|
|
4626
5116
|
+ "Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram "
|
|
4627
5117
|
+ "Direct), Instagram, Bluesky, X and Telegram. Support tickets too: the open conversations in the brand's Intercom inbox. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
|
|
4628
5118
|
+ "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
|
|
@@ -4741,7 +5231,6 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4741
5231
|
if (t.source === 'facebook' || t.source === 'instagram') payload.commentId = t.nativeId;
|
|
4742
5232
|
else if (t.source === 'threads' || t.source === 'threads_mention') payload.replyToId = t.nativeId;
|
|
4743
5233
|
else if (t.source === 'youtube') payload.parentId = t.nativeId;
|
|
4744
|
-
else if (t.source === 'reddit') payload.parentId = t.nativeId;
|
|
4745
5234
|
else if (t.source === 'google_business') payload.reviewName = t.nativeId;
|
|
4746
5235
|
// Bluesky's native id is the post's AT-URI. The route resolves the two strong refs (root AND parent, each a
|
|
4747
5236
|
// uri+cid pair) from it with one getPosts call — carrying four values on the wire would mean four ways for a
|
|
@@ -4802,6 +5291,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4802
5291
|
// voice engines (generate_voice) + writing models (generate_text) — so the RAW PLAYGROUND is usable from one probe
|
|
4803
5292
|
const voice = d.options?.voice ? (d.options.voice.engines || []).map(e => `${e.id} (${e.label}: ${(e.voices || []).slice(0, 6).join('/')}${(e.voices || []).length > 6 ? '…' : ''}, ${e.creditsPer1k}cr/1k chars)`).join('; ') : 'unavailable';
|
|
4804
5293
|
const llm = d.options?.llm ? (d.options.llm.models || []).map(m => `${m.id} (${m.label})`).join('; ') : 'unavailable';
|
|
5294
|
+
const dlg = d.options?.voice?.dialogue; const sfxE = d.options?.sfx?.engines || [];
|
|
5295
|
+
const audioMore = `${dlg ? `\nDialogue (generate_voice lines): ${dlg.engine} (${dlg.label}, up to ${dlg.maxVoices} voices and ${dlg.maxChars} characters, ${dlg.creditsPer1k}cr/1k chars; voices ${(dlg.voices || []).join('/')}), or any voice engine above read line by line` : ''}${sfxE.length ? `\nSound effects (generate_sfx): ${sfxE.map(e => `${e.id} (${e.label}${e.default ? ', default' : ''}: ${e.credits5s}cr for 5 s, ${e.creditsBySeconds?.[30]}cr for 30 s, ${e.exactLength ? 'exact length' : 'length asked in words'}${e.loopFlag ? ', loop flag' : ''})`).join('; ')}` : ''}`;
|
|
4805
5296
|
const music = d.options?.music ? (d.options.music.models || []).map(m => `${m.id} (${m.label}: ${m.credits15s != null ? `${m.credits15s}cr for a 15 s bed, by the second` : `${m.credits}cr flat`}; ${m.usage})`).join('; ') : 'unavailable';
|
|
4806
5297
|
// CONNECTORS, LIVE. The instructions blob names every channel we ship, but a channel can be held back by a
|
|
4807
5298
|
// platform gate (`CONNECTOR_GATES`) or simply not linked on this account — and a hand-written roster in a prompt
|
|
@@ -4831,7 +5322,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4831
5322
|
// Stated as a rule rather than as today's allowlist, so it stays true when the allowlist changes.
|
|
4832
5323
|
const _pickLine = 'NAMING A MODEL IS HOW YOU GET ONE: a render that passes no `model` is routed by the server\'s own auto-pool, which is deliberately NARROWER than this catalog — the `best` flag and the longest-clip row do NOT decide it. If you need a particular model\'s length, resolution, audio or reference-count capability, pass its id in `model`; that is a deliberate pick and the server will not swap it without telling you.';
|
|
4833
5324
|
// RESOLUTION IS PER MODEL, and asking outside the list is not an error — it is a quiet downgrade.
|
|
4834
|
-
|
|
5325
|
+
// THE PRICE AND THE TIER ARE ONE FACT (2026-11-03): a 30s Seedance 2.5 clip was quoted ~350 off the per-duration prices
|
|
5326
|
+
// below and rendered at 1080p for 853, because the tier those prices assume was never said here.
|
|
5327
|
+
const _defRes = d.options?.video?.defaultResolution || '720p';
|
|
5328
|
+
const _resLine = `RESOLUTION: every video price listed above is at the DEFAULT tier, ${_defRes}: a render that names no resolution renders AND is charged at ${_defRes}. Another tier costs differently (1080p is about 2.25x on Seedance): read \`creditsByRes\` for it, quote that figure, and pass the same \`resolution\` you quoted. Each model's \`resolutions\` list is its REAL enum; ask for a tier a model does not list and the render is delivered at that model's best available tier instead — the reply does not say so — so read \`resolutions\` here before promising anyone 1080p or 4k.`;
|
|
4835
5329
|
// recast_motion's two tiers, priced off the same live quote the app's costs page reads (toolExamples), never a number here.
|
|
4836
5330
|
const _mq = (q) => (q ? Object.entries(q).map(([s, c]) => `${s}s=${c}cr`).join(' ') : null);
|
|
4837
5331
|
const _motionLine = _mq(d.toolExamples?.motion) ? `\nRecast motion (recast_motion, billed per output second): tier pro (default) ${_mq(d.toolExamples.motion)}${_mq(d.toolExamples.motionStandard) ? `; tier standard ${_mq(d.toolExamples.motionStandard)}` : ''}${d.toolExamples.motionH3 ? `; engine h3 (a person clip recast with people) 720p ${_mq(d.toolExamples.motionH3['720p'])}, 1080p ${_mq(d.toolExamples.motionH3['1080p'])}` : ''}` : '';
|
|
@@ -4846,8 +5340,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4846
5340
|
const _rm = d.remake; // the remake engines (recast_motion / restyle_video / edit_video engine), priced live
|
|
4847
5341
|
const _rmLine = _rm ? `\nRemake engines (recast_motion, restyle_video, edit_video engine): auto picks, for a clip with no real-looking person, ${Object.entries(_rm.defaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}; with a person in the clip, ${Object.entries(_rm.faceDefaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}${_rm.personClipLibraryDefaults ? ` (a person clip our face library can carry, i.e. one of our renders or faceRoute on a paid plan: ${Object.entries(_rm.personClipLibraryDefaults).map(([k, v]) => `${k} → ${v}`).join(', ')})` : ''}. seedance ${(_rm.seconds || []).join('-')}s, up to ${_rm.imagesMax} pictures, ${Object.entries(_rm.seedanceCredits || {}).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanCredits ? `. wan clips up to ${_rm.wanMaxSourceSeconds}s, ${Object.entries(_rm.wanCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanLabel ? ` (${_rm.wanLabel})` : ''}` : ''}${_rm.wanPrimeCredits ? `. wan-prime (Wan 3.0 Prime by name) ${Object.entries(_rm.wanPrimeCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}` : ''}` : '';
|
|
4848
5342
|
const _je = d.jobEngines; // the certified engine per job (watched side-by-side tests, 2026-10-19)
|
|
4849
|
-
const _jeLine = _je && _je.defaults ? `\nDefault engine per job (watched side-by-side tests): ${Object.entries(_je.defaults).map(([k, v]) => `${k} → ${v}`).join(', ')}; a talking ad is one take of up to ${_je.talkingMaxSeconds}s. A model the user picks by name is kept; never switch it.` : '';
|
|
4850
|
-
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\nExtend a clip (generate_video extend:true + refVideo): by default it cuts to a NEW SHOT of what happens next (new angle, same scene, any i2v model, default ${d.jobEngines?.defaults?.extend || 'certified'})${_extLine ? `; extendMode:'continue' keeps the SAME take instead, on: ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${d.pricing ? `\nPRICES ABOVE ARE THIS ACCOUNT'S OWN: ${d.pricing.memberRate ? 'no markup on image, video and voice models (exactly the model maker\'s price, 1 credit = $0.02; no fee is added to a run or at checkout)' : 'standard prices (every paid plan pays exactly the model maker\'s price on image, video and voice models; model rows carry memberCredits)'}.` : ''}${_motionLine}${_avLine}\nMusic (generate_music, music beds): ${music}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
|
|
5343
|
+
const _jeLine = _je && _je.defaults ? `\nDefault engine per job (watched side-by-side tests): ${Object.entries(_je.defaults).map(([k, v]) => `${k} → ${v}`).join(', ')}; a talking ad is one take of up to ${_je.talkingMaxSeconds}s.${_je.image ? ` Image engine per generate_image job (used when no model is named): ${Object.entries(_je.image).filter(([, v]) => v).map(([k, v]) => `${k} → ${v}`).join(', ')}.` : ''} A model the user picks by name is kept; never switch it.` : '';
|
|
5344
|
+
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\nExtend a clip (generate_video extend:true + refVideo): by default it cuts to a NEW SHOT of what happens next (new angle, same scene, any i2v model, default ${d.jobEngines?.defaults?.extend || 'certified'})${_extLine ? `; extendMode:'continue' keeps the SAME take instead, on: ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${d.pricing ? `\nPRICES ABOVE ARE THIS ACCOUNT'S OWN: ${d.pricing.memberRate ? 'no markup on image, video and voice models (exactly the model maker\'s price, 1 credit = $0.02; no fee is added to a run or at checkout)' : 'standard prices (every paid plan pays exactly the model maker\'s price on image, video and voice models; model rows carry memberCredits)'}.` : ''}${_motionLine}${_avLine}\nMusic (generate_music, music beds): ${music}\nVoice engines (generate_voice): ${voice}${audioMore}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
|
|
4851
5345
|
}));
|
|
4852
5346
|
|
|
4853
5347
|
server.registerTool('hermoso_credits', {
|
|
@@ -5101,13 +5595,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5101
5595
|
title: 'List profiles',
|
|
5102
5596
|
description: "List every profile on this account (id + name; a profile is a brand, a creator or a personal workspace) and which one this connection acts on, PLUS any profile another account shared with you. Call this, then use_brand to switch; a SHARED profile switches the same way (pass its name or the profile id printed here). If a profile looks empty when the app shows it full, you are acting on a different one: call this first. Read-only, free.",
|
|
5103
5597
|
inputSchema: {}, outputSchema: {
|
|
5104
|
-
brands: z.array(z.any()).optional().describe('every profile on the account ({id, name, active})'),
|
|
5598
|
+
brands: z.array(z.any()).optional().describe('every profile on the account ({id, name, active, brandName?})'),
|
|
5105
5599
|
sharedWorkspaces: z.array(z.any()).optional().describe('profiles another account shared with you ({name, ownerAccountId, profileUuid, role})'),
|
|
5106
5600
|
},
|
|
5107
5601
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
5108
5602
|
}, wrap(async () => {
|
|
5109
5603
|
const d = await apiGet('/api/brands');
|
|
5110
|
-
const lines = (d.brands || []).map(b => `• ${b.name} (id: ${b.id})${b.active ? ' ← active' : ''}`).join('\n');
|
|
5604
|
+
const lines = (d.brands || []).map(b => `• ${b.name}${b.brandName ? ` — brand: ${b.brandName}` : ''} (id: ${b.id})${b.active ? ' ← active' : ''}`).join('\n'); // a profile the user named shows that name, with its brand's own name beside it (lib/profile-label.mjs)
|
|
5111
5605
|
// SHARED WORKSPACES WERE INVISIBLE HEADLESSLY. /api/brands is own-account-only, so a teammate driving Hermoso
|
|
5112
5606
|
// over MCP had no way to learn the two values that let them reach the brand they were invited to — and the
|
|
5113
5607
|
// owner header the client now sends would have been a knob nobody could find the value for. Best-effort: a
|
|
@@ -5134,7 +5628,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5134
5628
|
const makeUseBrandHandler = (ctx) => wrap(async ({ brand }) => {
|
|
5135
5629
|
const d = await apiGet('/api/brands');
|
|
5136
5630
|
const want = String(brand || '').trim().toLowerCase();
|
|
5137
|
-
const hit = (d.brands || []).find(b => b.id.toLowerCase() === want || String(b.name || '').toLowerCase() === want);
|
|
5631
|
+
const hit = (d.brands || []).find(b => b.id.toLowerCase() === want || String(b.name || '').toLowerCase() === want) || (d.brands || []).filter(b => String(b.brandName || '').toLowerCase() === want).reduce((one, b, i, all) => (all.length === 1 ? b : one), null); // the brand's own name works too when exactly one profile carries it
|
|
5138
5632
|
// Pin the pair EVERY time: switching back to an own brand must clear a previous shared pin, which is why the
|
|
5139
5633
|
// server always writes both fields rather than leaving an omitted one alone.
|
|
5140
5634
|
if (hit) {
|
|
@@ -5482,7 +5976,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5482
5976
|
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
5483
5977
|
},
|
|
5484
5978
|
outputSchema: { mediaId: z.string().optional(), accepted: z.boolean().optional(), vendorSuccess: z.boolean().optional(), confirmed: z.boolean().optional(), summary: z.string().optional() },
|
|
5485
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5979
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5486
5980
|
}, wrap(async (a) => {
|
|
5487
5981
|
const d = await apiPost('/api/instagram/collab-invites/respond', { mediaId: a.mediaId, accept: a.accept, account: a.account, pageId: a.pageId });
|
|
5488
5982
|
return ok(d.summary, d);
|
|
@@ -5854,9 +6348,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5854
6348
|
// durable public URL to feed post_to_meta / upload_meta_asset / create_meta_ad. Makes the publishing tools work on the
|
|
5855
6349
|
// user's OWN files, not just generated ones.
|
|
5856
6350
|
const EXT_MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4' };
|
|
6351
|
+
// WHERE THE UPLOAD WAS FILED (2026-11-03). The server keeps every uploaded image, video and PDF in the brand's Library
|
|
6352
|
+
// and answers `library: {saved, id}`; the sentence says so, so the user hears they will not need to upload it again.
|
|
6353
|
+
const uploadLibraryNote = (d) => (d && d.library && d.library.saved ? ` It is also kept in this brand's Library as ${d.library.id || 'its newest item'} (list_library), so it can be reused later without uploading it again.` : '');
|
|
5857
6354
|
server.registerTool('upload_file', {
|
|
5858
6355
|
title: 'Upload a local file -> durable public URL',
|
|
5859
|
-
description: 'Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user\'s desktop, an agency\'s finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), ' + (opts.hosted ? '' : '`path` (a local file, ONLY when Hermoso runs on the user\'s own machine over stdio/CLI; the hosted connector cannot see their disk), or ') + '`dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file\'s RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.',
|
|
6356
|
+
description: 'Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. Kept in the Library. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user\'s desktop, an agency\'s finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), ' + (opts.hosted ? '' : '`path` (a local file, ONLY when Hermoso runs on the user\'s own machine over stdio/CLI; the hosted connector cannot see their disk), or ') + '`dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file\'s RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.',
|
|
5860
6357
|
inputSchema: {
|
|
5861
6358
|
// THE THIRD SOURCE, AND THE ONLY ONE THAT CARRIES BYTES WITHOUT SPENDING THEM AS TOKENS (2026-09-16). Asked
|
|
5862
6359
|
// for by the first real user of the connector: `path` cannot work on a hosted surface and a data: URI puts
|
|
@@ -5895,7 +6392,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5895
6392
|
// The SERVER fetches it, so the bytes never cross this transport and the SSRF guard runs on our side, on
|
|
5896
6393
|
// EVERY redirect hop. This is what makes bring-your-own-creative work on the hosted connector at all.
|
|
5897
6394
|
const d = await apiUploadUrl('/api/upload', String(a.url).trim(), { fileName: fileName0 });
|
|
5898
|
-
return ok(`Ingested ${d.kind || 'file'} (${d.bytes} bytes) from that URL → ${d.url}. Pass this url to any publish, schedule or ad-build tool
|
|
6395
|
+
return ok(`Ingested ${d.kind || 'file'} (${d.bytes} bytes) from that URL → ${d.url}. Pass this url to any publish, schedule or ad-build tool.${uploadLibraryNote(d)}`, { url: d.url, kind: d.kind, bytes: d.bytes, ...(d.library?.saved && d.library.id ? { libraryId: d.library.id } : {}) });
|
|
5899
6396
|
}
|
|
5900
6397
|
let buf, contentType = 'application/octet-stream', fileName = fileName0;
|
|
5901
6398
|
if (a.dataUri) {
|
|
@@ -5922,7 +6419,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5922
6419
|
contentType = EXT_MIME[(fileName.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
|
|
5923
6420
|
}
|
|
5924
6421
|
const d = await apiUpload('/api/upload', buf, { contentType, fileName });
|
|
5925
|
-
return ok(`Uploaded ${d.kind || 'file'} (${d.bytes || buf.length} bytes) → ${d.url}. Pass this url to post_to_meta / upload_meta_asset / create_meta_ad
|
|
6422
|
+
return ok(`Uploaded ${d.kind || 'file'} (${d.bytes || buf.length} bytes) → ${d.url}. Pass this url to post_to_meta / upload_meta_asset / create_meta_ad.${uploadLibraryNote(d)}`, { url: d.url, kind: d.kind, bytes: d.bytes, ...(d.library?.saved && d.library.id ? { libraryId: d.library.id } : {}) });
|
|
5926
6423
|
}));
|
|
5927
6424
|
|
|
5928
6425
|
server.group('channel_admin');
|
|
@@ -6131,7 +6628,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6131
6628
|
confirmCount: z.number().optional().describe('REQUIRED — the exact number of DISTINCT valid recipients, echoed back. confirm:true alone cannot tell a 12-person test from a 2,000-person send'),
|
|
6132
6629
|
},
|
|
6133
6630
|
outputSchema: { id: z.string().optional(), status: z.string().optional(), queued: z.boolean().optional(), dryRun: z.boolean().optional(), recipients: z.number().optional(), sent: z.number().optional(), failed: z.number().optional(), unresolved: z.number().optional(), skipped: z.number().optional(), duplicates: z.number().optional(), results: z.array(z.any()).optional(), template: z.string().optional(), language: z.string().optional(), category: z.string().nullable().optional(), cost: z.string().optional(), messagingLimit: z.string().optional(), unitedStates: z.string().optional(), idempotencyKey: z.string().optional(), line: z.string().optional(), note: z.string().optional(), optIn: z.string().optional(), pricing: z.string().optional() },
|
|
6134
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6631
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6135
6632
|
}, wrap(async (a) => {
|
|
6136
6633
|
const d = await apiPost('/api/whatsapp/broadcast', { jobId: a.jobId, phoneNumberId: a.phoneNumberId, template: a.template, language: a.language, wabaId: a.wabaId, recipients: a.recipients, components: a.components, skipInvalid: a.skipInvalid, dryRun: a.dryRun, confirm: a.confirm, confirmCount: a.confirmCount });
|
|
6137
6634
|
// NEVER `undefined`: the MCP SDK validates the content block and answers `-32602 Invalid tools/call result` for a
|
|
@@ -6240,10 +6737,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6240
6737
|
// no need for one edge case just for Facebook."
|
|
6241
6738
|
server.registerTool('schedule_post', {
|
|
6242
6739
|
title: 'Schedule a post for later',
|
|
6243
|
-
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram
|
|
6740
|
+
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack — every one live. whop is held back until its connector is available, so a whop post is refused, not queued. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
|
|
6244
6741
|
inputSchema: {
|
|
6245
6742
|
...HOOK_ATTR,
|
|
6246
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
|
|
6743
|
+
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'),
|
|
6247
6744
|
at: z.string().optional().describe('when to post: local wall clock, no offset (2026-10-30T10:00), read in `timezone` or the brand\u2019s zone ON THAT DATE so a series survives a clock change; or UTC (…Z) / epoch ms. Future, at most 365 days out. Give this OR useQueue, never both.'),
|
|
6248
6745
|
useQueue: z.boolean().optional().describe('instead of naming a minute, drop this into the brand’s POSTING QUEUE: Hermoso takes the earliest of its saved posting times that is still free (skipping any slot another queued post already holds). This is the natural answer to “just queue it” / “post it at my next opening”. Mutually exclusive with `at` — passing both is refused rather than one being silently preferred. If the profile has no posting times set, or every slot for the next 90 days is taken, it is refused by name and nothing is scheduled.'),
|
|
6249
6746
|
timezone: z.string().optional().describe('IANA zone (e.g. "Africa/Cairo") a wall-clock `at` or the queue is read in; overrides the brand’s saved zone for this one post. Without either, UTC.'),
|
|
@@ -6366,10 +6863,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6366
6863
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
6367
6864
|
boardId: z.string().optional().describe('PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.'),
|
|
6368
6865
|
chatId: z.string().optional().describe('TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@yourchannel) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours.'),
|
|
6866
|
+
whopExperienceId: z.string().optional().describe('WHOP — which forum or chat in the whop (exp_…, from list_whop_destinations). Omit it when the whop has only one; with several and none named the post is refused at fire time, never guessed.'),
|
|
6867
|
+
whopPinned: z.boolean().optional().describe('WHOP FORUM POSTS ONLY — pin it to the top of the forum.'),
|
|
6868
|
+
whopMention: z.boolean().optional().describe('WHOP FORUM POSTS ONLY — notify every member (off by default).'),
|
|
6869
|
+
whopVisibility: z.enum(['members_only', 'globally_visible']).optional().describe('WHOP FORUM POSTS ONLY — who can see it; unset is the forum’s own default.'),
|
|
6870
|
+
whopPaywallCents: z.number().int().min(0).optional().describe('WHOP FORUM POSTS ONLY — a paywall in cents (500 = $5.00): readers pay to see the body; needs a title.'),
|
|
6871
|
+
whopPaywallCurrency: z.string().optional().describe('WHOP — the paywall currency, a 3-letter code (default usd).'),
|
|
6872
|
+
whopPoll: z.array(z.string()).optional().describe('WHOP FORUM POSTS ONLY — a poll: 2 to 10 options, in order.'),
|
|
6369
6873
|
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album.'),
|
|
6370
6874
|
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text."),
|
|
6371
|
-
silent: z.boolean().optional().describe('TELEGRAM
|
|
6875
|
+
silent: z.boolean().optional().describe('TELEGRAM / DISCORD — no notification.'),
|
|
6372
6876
|
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
|
|
6877
|
+
slackChannel: z.string().optional().describe('SLACK app: channel id or #name (default: the brand\'s)'),
|
|
6878
|
+
slackWorkspace: z.string().optional().describe('SLACK app: which workspace'),
|
|
6879
|
+
slackThreadTs: z.string().optional().describe('SLACK app: reply in this thread (parent ts)'),
|
|
6880
|
+
discordChannel: z.string().optional().describe('DISCORD bot: channel id or #name (default: the brand\'s)'),
|
|
6881
|
+
discordServer: z.string().optional().describe('DISCORD bot: which server'),
|
|
6882
|
+
discordReplyTo: z.string().optional().describe('DISCORD bot: reply to this message id'),
|
|
6883
|
+
discordStartThread: z.string().optional().describe('DISCORD bot: open a thread under the post, this title'),
|
|
6884
|
+
discordMentions: z.boolean().optional().describe('DISCORD: let mentions ping'),
|
|
6885
|
+
threadName: z.string().optional().describe('DISCORD: forum thread title'),
|
|
6886
|
+
wpCategories: z.array(z.string()).optional().describe('WORDPRESS — categories (names or ids)'),
|
|
6887
|
+
wpTags: z.array(z.string()).optional().describe('WORDPRESS — tags (names or ids)'),
|
|
6888
|
+
excerpt: z.string().optional().describe('WORDPRESS — the excerpt under the title'),
|
|
6889
|
+
wpSlug: z.string().optional().describe('WORDPRESS — the post slug'),
|
|
6373
6890
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — publish as a COMPANY PAGE instead of the connected personal profile. The organization id from list_linkedin_pages. Omit and it posts as the person: an unset id means the profile, never “probably the company”. A Page can also carry VIDEO, which a personal profile cannot.'),
|
|
6374
6891
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Facebook Page (and its linked Instagram) publishes, from list_meta_pages. Needed when the profile has more than one Page connected; with several and no id the post is refused at fire time rather than sent from the wrong brand.'),
|
|
6375
6892
|
locationId: z.string().optional().describe("GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user."),
|
|
@@ -6467,7 +6984,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6467
6984
|
message: z.string().optional().describe('replace the caption used for every channel that has no override'),
|
|
6468
6985
|
captions: z.record(z.string()).optional().describe('replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one'),
|
|
6469
6986
|
optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written; a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
|
|
6470
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('replaces the channel list'),
|
|
6987
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).optional().describe('replaces the channel list'),
|
|
6471
6988
|
imageUrl: z.string().optional().describe('swap the image; "" removes it'),
|
|
6472
6989
|
videoUrl: z.string().optional().describe('swap the video; "" removes it'),
|
|
6473
6990
|
imageUrls: z.array(z.string()).optional().describe('replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later.'),
|
|
@@ -6550,10 +7067,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6550
7067
|
aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
|
|
6551
7068
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
6552
7069
|
chatId: z.string().optional().describe('TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.'),
|
|
7070
|
+
whopExperienceId: z.string().optional().describe('WHOP — move it to a different forum or chat (exp_…, from list_whop_destinations).'),
|
|
6553
7071
|
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — replace the URL buttons ([] removes them).'),
|
|
6554
7072
|
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags."),
|
|
6555
|
-
silent: z.boolean().optional().describe('TELEGRAM
|
|
7073
|
+
silent: z.boolean().optional().describe('TELEGRAM / DISCORD — no notification.'),
|
|
6556
7074
|
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
|
|
7075
|
+
slackChannel: z.string().optional().describe('SLACK app: channel id or #name (default: the brand\'s)'),
|
|
7076
|
+
slackWorkspace: z.string().optional().describe('SLACK app: which workspace'),
|
|
7077
|
+
slackThreadTs: z.string().optional().describe('SLACK app: reply in this thread (parent ts)'),
|
|
7078
|
+
discordChannel: z.string().optional().describe('DISCORD bot: channel id or #name (default: the brand\'s)'),
|
|
7079
|
+
discordServer: z.string().optional().describe('DISCORD bot: which server'),
|
|
7080
|
+
discordReplyTo: z.string().optional().describe('DISCORD bot: reply to this message id'),
|
|
7081
|
+
discordStartThread: z.string().optional().describe('DISCORD bot: open a thread under the post, this title'),
|
|
7082
|
+
discordMentions: z.boolean().optional().describe('DISCORD: let mentions ping'),
|
|
7083
|
+
threadName: z.string().optional().describe('DISCORD: forum thread title'),
|
|
7084
|
+
whopPinned: z.boolean().optional().describe('WHOP: pin the forum post'),
|
|
7085
|
+
whopMention: z.boolean().optional().describe('WHOP: notify every member'),
|
|
7086
|
+
whopVisibility: z.enum(['members_only', 'globally_visible']).optional().describe('WHOP: who can see the forum post'),
|
|
7087
|
+
whopPaywallCents: z.number().int().min(0).optional().describe('WHOP: forum-post paywall in cents (needs a title)'),
|
|
7088
|
+
whopPaywallCurrency: z.string().optional().describe('WHOP: paywall currency'),
|
|
7089
|
+
whopPoll: z.array(z.string()).optional().describe('WHOP: poll options, 2 to 10'),
|
|
7090
|
+
wpCategories: z.array(z.string()).optional().describe('WORDPRESS — categories (names or ids)'),
|
|
7091
|
+
wpTags: z.array(z.string()).optional().describe('WORDPRESS — tags (names or ids)'),
|
|
7092
|
+
excerpt: z.string().optional().describe('WORDPRESS — the excerpt under the title'),
|
|
7093
|
+
wpSlug: z.string().optional().describe('WORDPRESS — the post slug'),
|
|
6557
7094
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
|
|
6558
7095
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)'),
|
|
6559
7096
|
locationId: z.string().optional().describe('GOOGLE BUSINESS — a different listing (list_business_locations)'),
|
|
@@ -6593,11 +7130,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6593
7130
|
inputSchema: {
|
|
6594
7131
|
brand: z.string().optional().describe('WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED.'),
|
|
6595
7132
|
id: z.string().describe('the scheduled post id from list_scheduled'),
|
|
6596
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
|
|
7133
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
|
|
6597
7134
|
at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
|
|
6598
7135
|
allowDuplicate: z.boolean().optional().describe('ONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated.'),
|
|
6599
7136
|
boardId: z.string().optional().describe('CORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed.'),
|
|
6600
7137
|
chatId: z.string().optional().describe('CORRECT THE TELEGRAM DESTINATION on retry — the @username or numeric id of the chat. A post aimed at a chat the bot is not in fails every time it is retried until this changes.'),
|
|
7138
|
+
whopExperienceId: z.string().optional().describe('WHOP — correct the forum or chat on retry (exp_…, from list_whop_destinations).'),
|
|
6601
7139
|
pageId: z.string().optional().describe('CORRECT THE PAGE on retry — which connected Facebook Page (and its linked Instagram/Threads) publishes, from list_meta_pages.'),
|
|
6602
7140
|
linkedinOrganizationId: z.string().optional().describe('CORRECT THE LINKEDIN AUTHOR on retry — the company Page id from list_linkedin_pages. Set it to an empty string to fall back to the personal profile.'),
|
|
6603
7141
|
locationId: z.string().optional().describe('CORRECT THE LISTING on retry — which Google Business Profile location, e.g. "locations/123" from list_business_locations.'),
|
|
@@ -6621,7 +7159,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6621
7159
|
at: z.string().optional().describe('when the copy goes out: local wall clock (2026-10-30T10:00), UTC or epoch ms (default: an hour from now)'),
|
|
6622
7160
|
useQueue: z.boolean().optional().describe('instead of naming a time, take the brand’s next free posting slot'),
|
|
6623
7161
|
timezone: z.string().optional().describe('IANA zone for a wall-clock `at` or the queue'),
|
|
6624
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('post the copy to these channels instead of the original’s'),
|
|
7162
|
+
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).optional().describe('post the copy to these channels instead of the original’s'),
|
|
6625
7163
|
message: z.string().optional().describe('a different caption for the copy'),
|
|
6626
7164
|
captions: z.record(z.string()).optional().describe('per-channel caption overrides for the copy'),
|
|
6627
7165
|
imageUrl: z.string().optional().describe('replacement image for the copy: a Hermoso render URL or an upload_file URL'), videoUrl: z.string().optional().describe('replacement video for the copy: a Hermoso render URL or an upload_file URL'),
|
|
@@ -6629,6 +7167,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6629
7167
|
title: z.string().optional().describe('Pinterest or YouTube headline for the copy, max 100 characters'), link: z.string().optional().describe('destination URL for the copy (Pinterest, Facebook text post or Google Business post)'),
|
|
6630
7168
|
boardId: z.string().optional().describe('PINTEREST — the board for the copy (list_pinterest_boards)'),
|
|
6631
7169
|
chatId: z.string().optional().describe('TELEGRAM — which chat, group or channel the copy goes to (@username or numeric id)'),
|
|
7170
|
+
whopExperienceId: z.string().optional().describe('WHOP — which forum or chat the copy goes to (exp_…, from list_whop_destinations).'),
|
|
6632
7171
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — publish the copy as this company Page (list_linkedin_pages)'),
|
|
6633
7172
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Page (list_meta_pages)'),
|
|
6634
7173
|
locationId: z.string().optional().describe('GOOGLE BUSINESS — which listing (list_business_locations)'),
|
|
@@ -6664,9 +7203,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6664
7203
|
const draftLines = drafts.slice(0, 20).map((x, i) => ` ${i + 1}. ${x.id} · ${x.at} → ${(x.channels || []).join(', ')} · ${x.kind}\n ${String(x.message || '').replace(/\n+/g, ' / ').slice(0, 200)}\n ${x.media || '(no file)'}`);
|
|
6665
7204
|
return ok([
|
|
6666
7205
|
`Autoposting: ${state}.${d.note ? `\n${d.note}` : ''}`,
|
|
7206
|
+
d.targets?.summary ? `WHERE IT POSTS: ${d.targets.summary}` : '',
|
|
6667
7207
|
d.creditNote || '',
|
|
6668
7208
|
`Posting times: ${(d.postingTimes || []).join(', ') || 'none set (add some on the Schedule tab)'} ${d.postingSchedule?.timezone || 'UTC'}. It fills ${d.daysAhead} day(s) ahead; ${d.queuedPosts} post(s) queued now.`,
|
|
6669
|
-
(d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under Workspace ▸ Connectors.'),
|
|
7209
|
+
(d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}${(c.accounts || []).length > 1 ? ` · accounts: ${c.accounts.map((a) => a.name).join(', ')}${c.pickedAccounts ? ` (picked: ${c.pickedAccounts === 'all' ? 'all' : c.accounts.filter((a) => c.pickedAccounts.includes(a.id)).map((a) => a.name).join(', ')})` : ''}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under Workspace ▸ Connectors.'),
|
|
6670
7210
|
`Makes ${d.postsPerDayMade || 0} new post(s) a day, every one a fresh render made for it${d.estCreditsPerDay != null && d.postsPerDayMade ? `, about ${d.estCreditsPerDay} credits a day at today's prices` : ''}.${d.zeroBudget ? ` ${d.budgetNote}` : ''} Used: ${d.spend?.today || 0} credits today, ${d.spend?.week || 0} this week.`,
|
|
6671
7211
|
`Brief: goal ${d.brief?.goal || 'not set (required to switch it on)'}${d.brief?.goalNote ? ` (${d.brief.goalNote})` : ''} · format ${d.brief?.formats || 'mix'}${(d.brief?.pillars || []).length ? ` · pillars: ${d.brief.pillars.join('; ')}` : (d.suggestedPillars || []).length ? ` · no pillars yet; suggested from the brand: ${d.suggestedPillars.join('; ')}` : ''}${(d.brief?.avoid || []).length ? ` · avoid: ${d.brief.avoid.join('; ')}` : ''}`,
|
|
6672
7212
|
(d.brief?.learnings || []).length || (d.brief?.editExamples || []).length ? `Learned from reviews (every batch follows these): ${(d.brief.learnings || []).map((x) => `"${x}"`).join('; ') || 'no notes'}${(d.brief.editExamples || []).length ? ` · imitates the user's last ${d.brief.editExamples.length} caption rewrite(s)` : ''}. Edit the list with set_post_refill learnings.` : '',
|
|
@@ -6693,11 +7233,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6693
7233
|
avoid: z.array(z.string()).optional().describe('THE BRIEF: never mention or show these — topics, claims, competitors, faces.'),
|
|
6694
7234
|
learnings: z.array(z.string()).optional().describe('notes learned from reviews; REPLACES the list'),
|
|
6695
7235
|
forgetCaptionEdits: z.boolean().optional().describe('true = forget the caption edits learned from past reviews'),
|
|
6696
|
-
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('
|
|
7236
|
+
channels: z.union([z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])), z.literal('all')]).optional().describe('REPLACES the list; a channel connected later stays out until added. "all" = every channel, later ones too, only if the user asks'),
|
|
7237
|
+
addChannels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).optional().describe('add these'),
|
|
7238
|
+
removeChannels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).optional().describe('stop posting here'),
|
|
7239
|
+
excludeChannels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).optional().describe('never these channels, even ones connected later; [] clears'),
|
|
6697
7240
|
boardId: z.string().optional().describe('PINTEREST — which board Pins go on (list_pinterest_boards). Without one, Pinterest is skipped: a Pin on the wrong board is a public mistake, so it is never guessed.'),
|
|
6698
7241
|
chatId: z.string().optional().describe('TELEGRAM — which chat, group or channel posts go to (@username or numeric id). Without one, telegram is skipped: there is no default chat and posting to the wrong one is a public mistake.'),
|
|
7242
|
+
whopExperienceId: z.string().optional().describe('WHOP — which forum or chat posts go to (exp_…, from list_whop_destinations). Without one, the whop’s only forum or chat is used; with several, whop is skipped rather than guessed.'),
|
|
6699
7243
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this profile is ignored rather than failing the whole post.'),
|
|
6700
7244
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page.'),
|
|
7245
|
+
accounts: z.record(z.union([z.array(z.string()), z.literal('all'), z.null()])).optional().describe('accounts to post to on a channel holding several: {"tiktok":["@a","@b"]} or "all"; null clears. A new setup must pick; an older one posts to the primary until it does.'),
|
|
6701
7246
|
},
|
|
6702
7247
|
outputSchema: { enabled: z.boolean().optional(), mode: z.string().optional(), running: z.boolean().optional(), daysAhead: z.number().optional() },
|
|
6703
7248
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
@@ -6705,7 +7250,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6705
7250
|
const { goal, goalNote, pillars, formats, avoid, learnings, forgetCaptionEdits, ...rest } = a;
|
|
6706
7251
|
const brief = Object.fromEntries(Object.entries({ goal, goalNote, pillars, formats, avoid, learnings, ...(forgetCaptionEdits ? { editExamples: [] } : {}) }).filter(([, v]) => v !== undefined));
|
|
6707
7252
|
const d = await apiPut('/api/schedule/refill', { ...rest, ...(Object.keys(brief).length ? { brief } : {}) });
|
|
6708
|
-
|
|
7253
|
+
let _where = ''; try { _where = (await apiGet('/api/schedule/refill', {})).targets?.summary || ''; } catch {} // the saved choice, read back: where it posts now
|
|
7254
|
+
return ok((_where ? `WHERE IT POSTS: ${_where}\n` : '') + (d.enabled
|
|
6709
7255
|
? `Autoposting is ON in ${d.mode === 'auto' ? 'AUTO-PUBLISH mode — each run makes fresh posts and schedules them to go out on their own.' : 'REVIEW mode — each run makes fresh posts and keeps them as drafts; nothing is scheduled until you approve them with run_post_refill.'} get_post_refill lists the channels it posts to and what it costs a day. Run run_post_refill (a preview by default: it renders and queues nothing, and writing its copy bills its model cost) to see a plan.`
|
|
6710
7256
|
: 'Autoposting is OFF. The recurring job is gone and nothing new will be made. Posts already in the calendar are untouched — cancel those with cancel_scheduled if you want them gone.') + (Array.isArray(d.postingTimes) ? `\nPosting times are now ${d.postingTimes.join(', ')} (${d.timezone || 'UTC'}).` : '') + (d.creditNote ? `\n${d.creditNote}` : ''), d);
|
|
6711
7257
|
}));
|
|
@@ -6799,7 +7345,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6799
7345
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
6800
7346
|
server.registerTool('post_to_x', {
|
|
6801
7347
|
title: 'Publish a post to X (Twitter)',
|
|
6802
|
-
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. Only the first ~280 characters show in the timeline; the rest sits behind \u201cShow more\u201d. 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
|
|
7348
|
+
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. Only the first ~280 characters show in the timeline; the rest sits behind \u201cShow more\u201d. 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).',
|
|
6803
7349
|
inputSchema: {
|
|
6804
7350
|
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."),
|
|
6805
7351
|
...HOOK_ATTR,
|
|
@@ -7034,23 +7580,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7034
7580
|
// event id, which is the only evidence we have that anything was stored.
|
|
7035
7581
|
return ok(`${d.delivered ? 'Sent' : 'X accepted the request but returned no message id'} — conversation ${d.conversationId || '?'}${d.eventId ? `, message ${d.eventId}` : ''}. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
7036
7582
|
}));
|
|
7037
|
-
// ── REDDIT (2026-07-30). The anti-spam framing is deliberately IN the tool description, not left to judgement:
|
|
7038
|
-
// Reddit's Responsible Builder Policy explicitly bans "posting identical or substantially similar content across
|
|
7039
|
-
// subreddits", and an agent told only "you can post to Reddit" will happily fan one ad out to eight communities
|
|
7040
|
-
// and get the user's account banned.
|
|
7041
7583
|
server.group('channels');
|
|
7042
7584
|
server.group('channel_admin');
|
|
7043
|
-
// ── OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing
|
|
7044
|
-
// else — no list, no edit, no delete, and not one comment. On the platform whose entire value IS the thread that
|
|
7045
|
-
// is the worst version of the gap. Every endpoint behind these uses a scope this connector ALREADY requests
|
|
7046
|
-
// (`edit`, `submit`, `read`, `history`), so nobody reconnects.
|
|
7047
|
-
// EDIT — the body, on a self post, and nothing else. Reddit's own endpoint is documented as editing "the body
|
|
7048
|
-
// text of a comment or self-post"; a LINK post is refused outright, and `title` appears on exactly one endpoint in
|
|
7049
|
-
// Reddit's entire API (creation), so a published title is frozen for everyone. Both refusals are stated up front
|
|
7050
|
-
// here rather than discovered as a 403, because an agent told an edit is possible will promise it to a user.
|
|
7051
|
-
// DELETE. THE REASON THE READ-BACK IS NOT OPTIONAL HERE: Reddit's /api/del answers `{}` with HTTP 200 no matter
|
|
7052
|
-
// what — a wrong id, someone ELSE'S post and a real delete are byte-identical responses, and there is no error to
|
|
7053
|
-
// catch. So ownership is resolved before the gate (server-side) and the verdict comes from re-reading the post.
|
|
7054
7585
|
// ── PINTEREST (2026-07-30). Two tools on purpose: the board is a REQUIRED, user-owned choice, and a Pin on the
|
|
7055
7586
|
// wrong board is a public mistake that cannot be quietly undone.
|
|
7056
7587
|
server.group('channels');
|
|
@@ -7070,7 +7601,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7070
7601
|
server.group('channel_admin');
|
|
7071
7602
|
server.registerTool('pinterest_analytics', {
|
|
7072
7603
|
title: 'Pinterest organic analytics',
|
|
7073
|
-
description: 'ORGANIC Pinterest performance — impressions, saves, Pin clicks, outbound clicks and their rates, for the whole ACCOUNT, for the TOP PINS, for the TOP VIDEO PINS (with view-through and average watch time), or for ONE Pin. This is unpaid reach;
|
|
7604
|
+
description: 'ORGANIC Pinterest performance — impressions, saves, Pin clicks, outbound clicks and their rates, for the whole ACCOUNT, for the TOP PINS, for the TOP VIDEO PINS (with view-through and average watch time), or for ONE Pin. This is unpaid reach; paid Pinterest ads have their own report. Use scope:"top_pins" to answer "what is actually working on our Pinterest" — it ranks the account’s own Pins by whichever metric you sort on. NOTE Pinterest keeps only 90 DAYS of organic analytics and refuses a longer window, which is refused here with the reason rather than as an opaque error. A VIDEO Pin takes a different metric set from a static one (pass video:true for scope:"pin"). THERE IS NO BOARD ANALYTICS: Pinterest’s v5 API publishes no such endpoint, so board-level performance genuinely does not exist in any API — do not promise it. An unknown metric is refused by name, and a metric Pinterest omits from a row is MISSING data ("if a column has no value, it may not be returned"), never a measured zero. Works on Pinterest’s Trial access tier — unlike creating Pins, every read row in Pinterest’s access-tier table is available on Trial. Read-only, 0 credits.',
|
|
7074
7605
|
inputSchema: {
|
|
7075
7606
|
scope: z.enum(['account', 'top_pins', 'top_video_pins', 'pin']).optional().describe('default account'),
|
|
7076
7607
|
pinId: z.string().optional().describe('required for scope:"pin" — the id post_to_pinterest returned'),
|
|
@@ -8075,7 +8606,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8075
8606
|
description: 'Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per API call).',
|
|
8076
8607
|
inputSchema: { replyId: z.string().describe('the REPLY’s id, or its x.com link'), hidden: z.boolean().optional().describe('default true; false unhides') },
|
|
8077
8608
|
outputSchema: { replyId: z.string().optional(), hidden: z.boolean().optional(), costCredits: z.number().optional(), note: z.string().optional() },
|
|
8078
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8609
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
8079
8610
|
}, wrap(async (a = {}) => { const d = await apiPost('/api/x/hide-reply', a); return ok(d.note, d); }));
|
|
8080
8611
|
server.registerTool('list_x_blocks', {
|
|
8081
8612
|
title: 'Who the connected X account has blocked',
|
|
@@ -8221,7 +8752,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8221
8752
|
enhancedCallPrivacy: z.boolean().optional().describe('true = enhanced privacy for calls'),
|
|
8222
8753
|
acceptCallsFrom: z.object({ everyone: z.boolean().optional().describe('true = anyone may call'), following: z.boolean().optional().describe('true = accounts the brand follows may call'), verified: z.boolean().optional().describe('true = verified accounts may call'), addressBook: z.boolean().optional().describe("true = contacts in the brand's address book may call") }).optional().describe('who may call the brand: flags for each group below'),
|
|
8223
8754
|
},
|
|
8224
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8755
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
8225
8756
|
}, wrap(async (a) => {
|
|
8226
8757
|
const d = await apiPost('/api/x/chat/settings', a);
|
|
8227
8758
|
return ok(`X stored: ${xcSettingsLine(d.settings)}. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
@@ -8386,7 +8917,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8386
8917
|
url: z.string().optional().describe('join or info link for an online or external event'), address: z.object({ line1: z.string().optional().describe('venue street address, first line'), city: z.string().describe('venue city'), region: z.string().optional().describe('venue state or region'), postalCode: z.string().optional().describe('venue postal or ZIP code'), country: z.string().describe('2-letter code, e.g. US') }).optional().describe('in-person venue address: {line1, city, region, postalCode, country}'), venueDetails: z.string().optional().describe('room, floor or parking notes for an in-person event'),
|
|
8387
8918
|
discoveryMode: z.enum(['LISTED', 'URL_ONLY']).optional().describe('LISTED or URL_ONLY (see create_linkedin_event)'),
|
|
8388
8919
|
},
|
|
8389
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8920
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
8390
8921
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/events/update', a); return ok(d.note, d); }));
|
|
8391
8922
|
server.registerTool('list_linkedin_events', {
|
|
8392
8923
|
title: 'List a LinkedIn Page\u2019s events',
|
|
@@ -9201,7 +9732,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9201
9732
|
// 200 {"success":true}. Rules, citations and every refusal sentence in lib/instagram-business-discovery.mjs.
|
|
9202
9733
|
server.registerTool('instagram_profile', {
|
|
9203
9734
|
title: 'Look up an Instagram account by handle',
|
|
9204
|
-
description: 'Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect. RECENT POSTS: pass posts (1–50) to also get that account\'s latest posts with their like, comment and view counts and links, read first-party from Meta for FREE
|
|
9735
|
+
description: 'Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect. RECENT POSTS: pass posts (1–50) to also get that account\'s latest posts with their like, comment and view counts and links, read first-party from Meta for FREE, for a business or creator account\'s recent Instagram posts (competitor organic research); page with cursor.',
|
|
9205
9736
|
inputSchema: {
|
|
9206
9737
|
handle: z.string().describe('the Instagram handle — "fellowproducts", "@fellowproducts" or an instagram.com profile link all work. Meta matches it EXACTLY and does not strip a leading @, so Hermoso normalises it first.'),
|
|
9207
9738
|
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected. Meta requires the lookup to be made AS a professional account, so the Page must have an Instagram account linked.'),
|
|
@@ -9612,7 +10143,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9612
10143
|
name: z.string().describe('what it will be called in Events Manager — name it after the website or brand it measures'),
|
|
9613
10144
|
},
|
|
9614
10145
|
outputSchema: { ok: z.boolean().optional(), pixelId: z.string().optional(), adAccountId: z.string().optional(), name: z.string().optional(), verified: z.boolean().optional(), pixel: z.any().optional(), note: z.string().optional() },
|
|
9615
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
10146
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
9616
10147
|
}, wrap(async (a) => {
|
|
9617
10148
|
const d = await apiPost('/api/meta/pixel', a);
|
|
9618
10149
|
return ok(d.note, d); // print the READ-BACK sentence verbatim — never narrate an object we did not read back
|
|
@@ -9800,7 +10331,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9800
10331
|
minConversationGapSeconds: z.number().optional().describe('hold the send if the person chatted with the Page within this many seconds'),
|
|
9801
10332
|
dryRun: z.boolean().optional().describe('true = preview the request body and send nothing'),
|
|
9802
10333
|
},
|
|
9803
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
10334
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
9804
10335
|
}, wrap(async (a) => {
|
|
9805
10336
|
const d = await apiPost('/api/meta/marketing-messages/send', a);
|
|
9806
10337
|
const bad = (d.results || []).filter(r => r.error);
|
|
@@ -10536,7 +11067,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10536
11067
|
loginCustomerId: z.string().optional().describe('manager (MCC) account id when acting through a manager account; omit otherwise'),
|
|
10537
11068
|
},
|
|
10538
11069
|
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), note: z.string().optional() },
|
|
10539
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11070
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
10540
11071
|
}, wrap(async (a) => {
|
|
10541
11072
|
const d = await apiPost('/api/google-ads/targeting', a);
|
|
10542
11073
|
return ok(d.note, d);
|
|
@@ -10959,7 +11490,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10959
11490
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
10960
11491
|
},
|
|
10961
11492
|
outputSchema: { ok: z.boolean().optional(), previews: z.array(z.any()).optional(), note: z.string().optional() },
|
|
10962
|
-
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint:
|
|
11493
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
10963
11494
|
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/preview', a); return ok(d.note, d); }));
|
|
10964
11495
|
server.registerTool('google_ads_policy_issues', {
|
|
10965
11496
|
title: 'Why Google rejected or limited an ad',
|
|
@@ -10984,7 +11515,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10984
11515
|
assetType: z.string().optional().describe('update — the asset kind'), asset: z.record(z.string(), z.any()).optional().describe('update — the new content'),
|
|
10985
11516
|
confirm: z.boolean().optional().describe("true only after the user agreed to change a LIVE campaign's assets"), dryRun: z.boolean().optional().describe('true = validate with Google and change nothing'), loginCustomerId: z.string().optional().describe('manager (MCC) account id when acting through a manager account; omit otherwise'),
|
|
10986
11517
|
},
|
|
10987
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11518
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
10988
11519
|
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/assets/manage', a); return ok(d.note || 'Done.', d); }));
|
|
10989
11520
|
server.registerTool('set_google_ads_tracking', {
|
|
10990
11521
|
title: 'Set UTMs / tracking on a Google Ads campaign, ad group or ad',
|
|
@@ -10996,7 +11527,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10996
11527
|
utmDefault: z.boolean().optional().describe('true = apply the standard UTMs (utm_source, utm_medium, utm_campaign, utm_id, utm_content)'), confirm: z.boolean().optional().describe('needed to change the tracking redirect on a LIVE campaign'),
|
|
10997
11528
|
dryRun: z.boolean().optional().describe('true = validate with Google and change nothing'), loginCustomerId: z.string().optional().describe('manager (MCC) account id when acting through a manager account; omit otherwise'),
|
|
10998
11529
|
},
|
|
10999
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11530
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
11000
11531
|
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/tracking', a); return ok(d.note || 'Done.', d); }));
|
|
11001
11532
|
server.registerTool('manage_google_ads_audience', {
|
|
11002
11533
|
title: 'Find or build Google Ads audiences',
|
|
@@ -11026,7 +11557,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11026
11557
|
excludeAges: z.array(z.string()).optional().describe('age ranges to exclude: 18-24, 25-34, 35-44, 45-54, 55-64, 65+ or unknown'), excludeGenders: z.array(z.string()).optional().describe('genders to exclude: male, female or unknown'), remove: z.array(z.string()).optional().describe('criterion resource names to remove; a call with no other changes lists the current ones'),
|
|
11027
11558
|
confirm: z.boolean().optional().describe('true to approve a change that alters who a live ad group reaches'), dryRun: z.boolean().optional().describe('true = validate with Google and change nothing'), loginCustomerId: z.string().optional().describe('manager (MCC) account id, when the account is managed through one'),
|
|
11028
11559
|
},
|
|
11029
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11560
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11030
11561
|
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/audiences/targeting', a); return ok(d.note || 'Done.', d); }));
|
|
11031
11562
|
server.registerTool('upload_google_ads_video', {
|
|
11032
11563
|
title: 'Upload a video straight into Google Ads',
|
|
@@ -11057,7 +11588,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11057
11588
|
startDate: z.string().optional().describe('campaign start date, YYYY-MM-DD'), endDate: z.string().optional().describe('campaign end date, YYYY-MM-DD'), containsEuPoliticalAds: z.boolean().optional().describe('true only for genuine EU political advertising'),
|
|
11058
11589
|
dryRun: z.boolean().optional().describe('true = validate with Google and create nothing'), loginCustomerId: z.string().optional().describe('manager (MCC) account id, when the account is managed through one'),
|
|
11059
11590
|
},
|
|
11060
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11591
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11061
11592
|
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/demand-gen', a); return ok(d.note || 'Done.', d); }));
|
|
11062
11593
|
server.registerTool('create_google_ads_performance_max_campaign', {
|
|
11063
11594
|
title: 'Create a Google Ads Performance Max campaign',
|
|
@@ -11255,7 +11786,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11255
11786
|
description: z.string().optional().describe('optional description, ≤150 characters'),
|
|
11256
11787
|
},
|
|
11257
11788
|
outputSchema: { property: z.string().optional(), customDimension: z.any().optional(), note: z.string().optional() },
|
|
11258
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11789
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11259
11790
|
}, wrap(async (a) => {
|
|
11260
11791
|
const d = await apiPost('/api/analytics/custom-dimension', a);
|
|
11261
11792
|
const c = d.customDimension || {};
|
|
@@ -12469,7 +13000,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12469
13000
|
// (c) Mixpanel had no per-user lane at all, though PostHog and Amplitude both do.
|
|
12470
13001
|
server.registerTool('amplitude_chart', {
|
|
12471
13002
|
title: 'Read a saved Amplitude chart',
|
|
12472
|
-
description: "READ BACK A SAVED AMPLITUDE CHART by its id — the chart the user already built in Amplitude's own UI, with whatever segments, filters and date range they configured there. This is
|
|
13003
|
+
description: "READ BACK A SAVED AMPLITUDE CHART by its id — the chart the user already built in Amplitude's own UI, with whatever segments, filters and date range they configured there. This is the saved-chart read for Amplitude, and it is the right lane whenever the report already exists: you inherit their definitions instead of rebuilding them from a segmentation query, and it costs one call rather than several. THE CHART ID MUST COME FROM THE USER — Amplitude publishes no endpoint that lists a project's charts — and it is the segment after '/chart/' in the chart's URL (e.g. 'abc123' in https://analytics.amplitude.com/demo/chart/abc123); pasting the whole URL works too. THE RESPONSE SHAPE VARIES BY CHART TYPE, in Amplitude's own words, so read what comes back rather than assuming a series: it may be JSON or a text/CSV body, and BOTH are correct answers rather than one being an error. Read-only, 0 credits.",
|
|
12473
13004
|
inputSchema: { chartId: z.string().describe("the saved chart's id, or its full URL — the id is the segment after /chart/") },
|
|
12474
13005
|
outputSchema: { chartId: z.string().optional(), format: z.string().optional(), data: z.any().optional(), text: z.string().nullable().optional(), note: z.string().optional() },
|
|
12475
13006
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12571,7 +13102,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12571
13102
|
description: "Change the text or tags of a Mixpanel annotation (annotationId from list_mixpanel_annotations). Mixpanel cannot move an annotation to a new date; to fix a wrong date, delete it and create a new one. The edited row is read back. Needs the ANALYST role on the service account. 0 credits. Needs Mixpanel connected.",
|
|
12572
13103
|
inputSchema: { annotationId: z.number().describe('from list_mixpanel_annotations'), description: z.string().optional().describe('new annotation text; empty text is refused'), tags: z.array(z.number()).optional().describe('annotation-tag ids') },
|
|
12573
13104
|
outputSchema: { project: z.string().optional(), updated: z.boolean().optional(), annotation: z.any().optional() },
|
|
12574
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13105
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
12575
13106
|
}, wrap(async (a) => {
|
|
12576
13107
|
const d = await apiPost('/api/mixpanel/annotation/update', a);
|
|
12577
13108
|
const x = d.annotation || {};
|
|
@@ -12758,6 +13289,331 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12758
13289
|
const d = await apiPost(`/api/vercel/deployments/${encodeURIComponent(String(deploymentId || ''))}/delete`, rest);
|
|
12759
13290
|
return ok(d.note, d);
|
|
12760
13291
|
}));
|
|
13292
|
+
// ---------- Mailchimp (2026-11-03): the brand's OWN email marketing — audiences, segments, campaigns, templates, File Manager, reports ----------
|
|
13293
|
+
const MC_NEEDS = ' Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).';
|
|
13294
|
+
const mcPct = (x) => (x == null ? '?' : `${(x * 100).toFixed(1)}%`);
|
|
13295
|
+
const mcPage = { limit: z.number().optional(), offset: z.number().optional() };
|
|
13296
|
+
server.registerTool('list_mailchimp_audiences', {
|
|
13297
|
+
title: 'List Mailchimp audiences',
|
|
13298
|
+
description: "The brand's Mailchimp AUDIENCES (lists): subscribed member count, total contacts, unsubscribes, double opt-in, the default from name and email, and when each last sent. An audience id is what create_mailchimp_campaign and sync_leads_to_mailchimp take. Read-only, free." + MC_NEEDS,
|
|
13299
|
+
inputSchema: { ...mcPage },
|
|
13300
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), total: z.number().optional(), audiences: z.array(z.any()).optional() },
|
|
13301
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13302
|
+
}, wrap(async (a = {}) => {
|
|
13303
|
+
const d = await apiGet('/api/mailchimp/audiences', a);
|
|
13304
|
+
return ok(d.count ? `${d.count} Mailchimp audience(s) on ${d.account}:\n` + (d.audiences || []).map(x => `- ${x.name} (${x.id}), ${x.members == null ? 'member count unavailable' : `${x.members} subscribed`}${x.totalContacts != null ? ` of ${x.totalContacts} contacts` : ''}${x.doubleOptin ? ', double opt-in' : ''}`).join('\n') : `No Mailchimp audiences on ${d.account}. That is a real empty list, not a failed read.`, d);
|
|
13305
|
+
}));
|
|
13306
|
+
server.registerTool('list_mailchimp_segments', {
|
|
13307
|
+
title: 'List Mailchimp segments',
|
|
13308
|
+
description: "The SEGMENTS and tags of one Mailchimp audience with their member counts and type (saved, static/tag, fuzzy). A segment id narrows a campaign to part of the audience. Read-only, free." + MC_NEEDS,
|
|
13309
|
+
inputSchema: { audienceId: z.string(), ...mcPage },
|
|
13310
|
+
outputSchema: { account: z.string().optional(), audienceId: z.string().optional(), count: z.number().optional(), total: z.number().optional(), segments: z.array(z.any()).optional() },
|
|
13311
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13312
|
+
}, wrap(async (a) => {
|
|
13313
|
+
const { audienceId, ...rest } = a;
|
|
13314
|
+
const d = await apiGet(`/api/mailchimp/audiences/${encodeURIComponent(String(audienceId || ''))}/segments`, rest);
|
|
13315
|
+
return ok(d.count ? `${d.count} segment(s) in audience ${d.audienceId}:\n` + (d.segments || []).map(x => `- ${x.name} (${x.id}), ${x.members ?? '?'} members, ${x.type || '?'}`).join('\n') : 'This audience has no segments or tags. That is a real empty list, not a failed read.', d);
|
|
13316
|
+
}));
|
|
13317
|
+
server.registerTool('list_mailchimp_campaigns', {
|
|
13318
|
+
title: 'List Mailchimp campaigns',
|
|
13319
|
+
description: "The brand's Mailchimp CAMPAIGNS, newest first: title, status (save = draft, schedule, sending, sent, paused), subject, preview text, sender, audience and segment, recipient count, send time. Filter by status or audienceId; or pass campaignId to read ONE with Mailchimp's own send checklist (is it ready, and what is missing). For opens, clicks and revenue use mailchimp_report. Read-only, free." + MC_NEEDS,
|
|
13320
|
+
inputSchema: { campaignId: z.string().optional(), status: z.enum(['save', 'paused', 'schedule', 'sending', 'sent']).optional(), audienceId: z.string().optional(), ...mcPage },
|
|
13321
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), total: z.number().optional(), campaigns: z.array(z.any()).optional() },
|
|
13322
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13323
|
+
}, wrap(async (a = {}) => {
|
|
13324
|
+
const d = await apiGet('/api/mailchimp/campaigns', a);
|
|
13325
|
+
return ok(d.count ? `${d.count} Mailchimp campaign(s) on ${d.account}:\n` + (d.campaigns || []).map(x => `- ${x.title || '(untitled)'} (${x.id}), ${x.status || '?'}, subject "${x.subject}", ${x.audienceName || x.audienceId || '?'}${x.segment ? ` (${x.segment})` : ''}, ${x.recipients ?? '?'} recipient(s)${x.ready === false ? `, NOT ready: ${(x.checklist || []).join('; ')}` : x.ready ? ', ready to send' : ''}`).join('\n') : 'No Mailchimp campaigns match. That is a real empty list, not a failed read.', d);
|
|
13326
|
+
}));
|
|
13327
|
+
server.registerTool('list_mailchimp_templates', {
|
|
13328
|
+
title: 'List Mailchimp templates',
|
|
13329
|
+
description: "The brand's own Mailchimp EMAIL TEMPLATES (name, id, created, edited). A template id is what create_mailchimp_campaign sends. Read-only, free." + MC_NEEDS,
|
|
13330
|
+
inputSchema: { ...mcPage },
|
|
13331
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), total: z.number().optional(), templates: z.array(z.any()).optional() },
|
|
13332
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13333
|
+
}, wrap(async (a = {}) => {
|
|
13334
|
+
const d = await apiGet('/api/mailchimp/templates', a);
|
|
13335
|
+
return ok(d.count ? `${d.count} Mailchimp template(s):\n` + (d.templates || []).map(x => `- ${x.name} (${x.id}), edited ${String(x.edited || x.created || '').slice(0, 10)}`).join('\n') : "No templates of the brand's own in Mailchimp. That is a real empty list, not a failed read.", d);
|
|
13336
|
+
}));
|
|
13337
|
+
server.registerTool('list_mailchimp_files', {
|
|
13338
|
+
title: 'List Mailchimp File Manager images',
|
|
13339
|
+
description: "The pictures in the brand's Mailchimp FILE MANAGER: id, name, hosted URL, size and dimensions, newest first. Read-only, free." + MC_NEEDS,
|
|
13340
|
+
inputSchema: { ...mcPage },
|
|
13341
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), total: z.number().optional(), files: z.array(z.any()).optional() },
|
|
13342
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13343
|
+
}, wrap(async (a = {}) => {
|
|
13344
|
+
const d = await apiGet('/api/mailchimp/files', a);
|
|
13345
|
+
return ok(d.count ? `${d.count} picture(s) in the Mailchimp File Manager:\n` + (d.files || []).map(x => `- ${x.name} (${x.id}), ${x.url}`).join('\n') : 'The Mailchimp File Manager has no pictures. That is a real empty list, not a failed read.', d);
|
|
13346
|
+
}));
|
|
13347
|
+
server.registerTool('upload_mailchimp_image', {
|
|
13348
|
+
title: 'Upload an image to Mailchimp',
|
|
13349
|
+
description: "Put a picture into the brand's Mailchimp FILE MANAGER for use in emails: a Hermoso render or Library id (list_library gives L0, L1...), a Hermoso link, or an upload_file URL (for an outside picture, call upload_file first). JPEG, PNG and GIF go up as they are; another format or a file over 5 MB is converted to a JPEG first; a video is refused. Returns Mailchimp's own file row (id and hosted URL). create_mailchimp_template uploads its image itself, so call this only to stock the File Manager. Free." + MC_NEEDS,
|
|
13350
|
+
inputSchema: { image: z.string().describe('a Library id (L0), or a Hermoso or upload_file URL'), name: z.string().optional(), folderId: z.number().optional().describe('a File Manager folder id') },
|
|
13351
|
+
outputSchema: { account: z.string().optional(), file: z.any().optional(), converted: z.boolean().optional() },
|
|
13352
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13353
|
+
}, wrap(async (a) => { const d = await apiPost('/api/mailchimp/files', a); return ok(`Uploaded to the Mailchimp File Manager: ${d.file?.name} (${d.file?.id}), ${d.file?.url}${d.converted ? ' (converted to JPEG)' : ''}.`, d); }));
|
|
13354
|
+
server.registerTool('create_mailchimp_template', {
|
|
13355
|
+
title: 'Create a Mailchimp email template',
|
|
13356
|
+
description: "Make an EMAIL TEMPLATE in Mailchimp from a creative: image (any reference upload_mailchimp_image takes; it goes into the File Manager first) plus copy YOU write in the brand's voice (read get_brand for voice and colours): headline, body (paragraphs split by a blank line), ctaText + ctaUrl for the button, accent (#RRGGBB button colour). It is laid out as a 600px email with the unsubscribe link and mailing address Mailchimp requires. Or pass html for the user's own markup (that footer is added if it has no *|UNSUB|*). Saves the template only, nothing is sent; returns the templateId for create_mailchimp_campaign. Free." + MC_NEEDS,
|
|
13357
|
+
inputSchema: {
|
|
13358
|
+
name: z.string().optional(), image: z.string().optional().describe('a Library id, or a Hermoso or upload_file URL'), imageAlt: z.string().optional(),
|
|
13359
|
+
headline: z.string().optional(), body: z.string().optional(), ctaText: z.string().optional(), ctaUrl: z.string().optional(),
|
|
13360
|
+
accent: z.string().optional().describe('#RRGGBB'), background: z.string().optional().describe('#RRGGBB'), html: z.string().optional().describe('your own full HTML instead of the layout'),
|
|
13361
|
+
},
|
|
13362
|
+
outputSchema: { account: z.string().optional(), template: z.any().optional(), image: z.any().optional(), footerAdded: z.boolean().optional(), note: z.string().optional() },
|
|
13363
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13364
|
+
}, wrap(async (a) => { const d = await apiPost('/api/mailchimp/templates', a); return ok(`${d.note}${d.image ? ` Image: ${d.image.url}.` : ''}`, d); }));
|
|
13365
|
+
server.registerTool('update_mailchimp_template', {
|
|
13366
|
+
title: 'Update a Mailchimp template',
|
|
13367
|
+
description: "Replace a Mailchimp template's HTML (Mailchimp's update replaces the whole template, so pass the full html), optionally with a new name. The unsubscribe footer is added if the html has none. Free." + MC_NEEDS,
|
|
13368
|
+
inputSchema: { templateId: z.string(), html: z.string(), name: z.string().optional() },
|
|
13369
|
+
outputSchema: { account: z.string().optional(), template: z.any().optional(), changed: z.array(z.string()).optional(), footerAdded: z.boolean().optional() },
|
|
13370
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13371
|
+
}, wrap(async (a) => { const { templateId, ...rest } = a; const d = await apiPost(`/api/mailchimp/templates/${encodeURIComponent(String(templateId || ''))}`, rest); return ok(`Template "${d.template?.name}" (${d.template?.id}) updated: ${(d.changed || []).join(', ')}.${d.footerAdded ? ' The unsubscribe footer Mailchimp requires was added.' : ''}`, d); }));
|
|
13372
|
+
server.registerTool('delete_mailchimp_template', {
|
|
13373
|
+
title: 'Delete a Mailchimp template',
|
|
13374
|
+
description: "PERMANENTLY delete a Mailchimp template. Without confirm:true nothing is deleted and you get the template it would delete; call again with confirm:true only after the user says yes. Free." + MC_NEEDS,
|
|
13375
|
+
inputSchema: { templateId: z.string(), confirm: z.boolean().optional().describe('true only after the user approved deleting this template') },
|
|
13376
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), template: z.any().optional(), deleted: z.string().nullable().optional(), note: z.string().optional() },
|
|
13377
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13378
|
+
}, wrap(async (a) => { const { templateId, ...rest } = a; const d = await apiPost(`/api/mailchimp/templates/${encodeURIComponent(String(templateId || ''))}/delete`, rest); return ok(d.note, d); }));
|
|
13379
|
+
server.registerTool('send_mailchimp_test_email', {
|
|
13380
|
+
title: 'Send a Mailchimp test email',
|
|
13381
|
+
description: "Send ONE TEST of a Mailchimp campaign draft to up to 5 addresses the user names (never to the audience), so they can see it in a real inbox. Without confirm:true nothing is sent and you get the preview; call again with confirm:true after they say yes. Free." + MC_NEEDS,
|
|
13382
|
+
inputSchema: { campaignId: z.string(), recipients: z.array(z.string()).describe('up to 5 email addresses'), confirm: z.boolean().optional() },
|
|
13383
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), recipients: z.array(z.string()).optional(), campaign: z.any().optional(), note: z.string().optional() },
|
|
13384
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13385
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/mailchimp/campaigns/${encodeURIComponent(String(campaignId || ''))}/test`, rest); return ok(d.note, d); }));
|
|
13386
|
+
server.registerTool('create_mailchimp_campaign', {
|
|
13387
|
+
title: 'Create a Mailchimp campaign draft',
|
|
13388
|
+
description: "Create a Mailchimp email CAMPAIGN DRAFT: templateId (create_mailchimp_template), subject, previewText, audienceId and optionally segmentId (part of the audience), optional name, fromName and replyTo (default: the audience's own defaults). It is created as a DRAFT and NOTHING IS SENT; the answer is Mailchimp's own row read back with its send checklist. To send or schedule it use send_mailchimp_campaign, which shows the recipient count first. Free." + MC_NEEDS,
|
|
13389
|
+
inputSchema: { templateId: z.string(), subject: z.string(), audienceId: z.string(), previewText: z.string().optional(), segmentId: z.string().optional(), name: z.string().optional(), fromName: z.string().optional(), replyTo: z.string().optional() },
|
|
13390
|
+
outputSchema: { account: z.string().optional(), campaign: z.any().optional(), note: z.string().optional() },
|
|
13391
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13392
|
+
}, wrap(async (a) => { const d = await apiPost('/api/mailchimp/campaigns', a); return ok(d.note, d); }));
|
|
13393
|
+
server.registerTool('update_mailchimp_campaign', {
|
|
13394
|
+
title: 'Update a Mailchimp campaign draft',
|
|
13395
|
+
description: "Edit a Mailchimp campaign that is still a DRAFT: name, subject, previewText, fromName, replyTo, templateId, or its audience / segment. A scheduled one must be unscheduled first (cancel_mailchimp_campaign). Read back with its send checklist. Free." + MC_NEEDS,
|
|
13396
|
+
inputSchema: { campaignId: z.string(), name: z.string().optional(), subject: z.string().optional(), previewText: z.string().optional(), fromName: z.string().optional(), replyTo: z.string().optional(), templateId: z.string().optional(), audienceId: z.string().optional(), segmentId: z.string().optional() },
|
|
13397
|
+
outputSchema: { account: z.string().optional(), changed: z.array(z.string()).optional(), campaign: z.any().optional() },
|
|
13398
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13399
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/mailchimp/campaigns/${encodeURIComponent(String(campaignId || ''))}`, rest); return ok(`Draft "${d.campaign?.title}" updated in Mailchimp: ${(d.changed || []).join(', ')}. Nothing was sent.`, d); }));
|
|
13400
|
+
server.registerTool('send_mailchimp_campaign', {
|
|
13401
|
+
title: 'Send or schedule a Mailchimp campaign',
|
|
13402
|
+
description: "SEND or SCHEDULE a Mailchimp campaign draft. An email cannot be unsent, so this is gated on how many people it reaches: without confirm:true nothing is sent and you get Mailchimp's own recipient count with the subject, sender, audience, timing and Mailchimp's send checklist. Show the user that number; only after they say yes call again with confirm:true AND confirmRecipients set to that count (a mismatch, a zero, or a campaign Mailchimp says is not ready is refused). sendTime (ISO, in the future, on :00/:15/:30/:45; Mailchimp schedules only on the quarter-hour) schedules instead of sending now; timewarp:true sends at that time in each recipient's own timezone. Free." + MC_NEEDS,
|
|
13403
|
+
inputSchema: { campaignId: z.string(), sendTime: z.string().optional().describe('ISO date-time on the quarter-hour; omit to send now'), timewarp: z.boolean().optional(), confirmRecipients: z.number().optional().describe('the recipient count the dry run showed the user'), confirm: z.boolean().optional().describe('true only after the user approved this exact send') },
|
|
13404
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), recipients: z.number().nullable().optional(), ready: z.boolean().nullable().optional(), sendTime: z.string().nullable().optional(), campaign: z.any().optional(), confirmed: z.boolean().optional(), note: z.string().optional() },
|
|
13405
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13406
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/mailchimp/campaigns/${encodeURIComponent(String(campaignId || ''))}/send`, rest); return ok(d.note, d); }));
|
|
13407
|
+
server.registerTool('cancel_mailchimp_campaign', {
|
|
13408
|
+
title: 'Stop a Mailchimp campaign',
|
|
13409
|
+
description: "Stop a Mailchimp campaign: action 'unschedule' puts a scheduled one back to a draft; 'cancel' stops one that is sending (emails already delivered stay delivered). Without confirm:true nothing changes and you get what would happen. Free." + MC_NEEDS,
|
|
13410
|
+
inputSchema: { campaignId: z.string(), action: z.enum(['unschedule', 'cancel']).optional(), confirm: z.boolean().optional() },
|
|
13411
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), campaign: z.any().optional(), note: z.string().optional() },
|
|
13412
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13413
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/mailchimp/campaigns/${encodeURIComponent(String(campaignId || ''))}/cancel`, rest); return ok(d.note, d); }));
|
|
13414
|
+
server.registerTool('delete_mailchimp_campaign', {
|
|
13415
|
+
title: 'Delete a Mailchimp campaign',
|
|
13416
|
+
description: "PERMANENTLY delete a Mailchimp campaign (a draft or a sent one; a scheduled one must be unscheduled first). Without confirm:true nothing is deleted and you get the campaign it would delete; call again with confirm:true only after the user says yes. Free." + MC_NEEDS,
|
|
13417
|
+
inputSchema: { campaignId: z.string(), confirm: z.boolean().optional().describe('true only after the user approved deleting this campaign') },
|
|
13418
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), campaign: z.any().optional(), deleted: z.string().nullable().optional(), note: z.string().optional() },
|
|
13419
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13420
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/mailchimp/campaigns/${encodeURIComponent(String(campaignId || ''))}/delete`, rest); return ok(d.note, d); }));
|
|
13421
|
+
server.registerTool('mailchimp_report', {
|
|
13422
|
+
title: 'Mailchimp campaign performance',
|
|
13423
|
+
description: "Mailchimp campaign PERFORMANCE: emails sent, unique opens and open rate (and the rate without machine opens), unique clicks and click rate, unsubscribes, bounces, abuse reports and, where a store is connected to Mailchimp, orders and REVENUE, per campaign sent in the last `days` (default 30), or one campaign by campaignId. Rates are fractions (0.25 = 25%). Read-only, free." + MC_NEEDS,
|
|
13424
|
+
inputSchema: { campaignId: z.string().optional(), days: z.number().optional().describe('1-3650, default 30'), ...mcPage },
|
|
13425
|
+
outputSchema: { account: z.string().optional(), since: z.string().optional(), count: z.number().optional(), total: z.number().optional(), reports: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13426
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13427
|
+
}, wrap(async (a = {}) => {
|
|
13428
|
+
const d = await apiPost('/api/mailchimp/report', a);
|
|
13429
|
+
if (!d.count) return ok(d.note, d);
|
|
13430
|
+
return ok(`Mailchimp campaign performance${d.since ? ` since ${String(d.since).slice(0, 10)}` : ''}:\n` + (d.reports || []).slice(0, 40).map(r => `- ${r.title || r.subject} (${r.campaignId}): ${r.sent ?? '?'} sent, opens ${r.uniqueOpens ?? '?'} (${mcPct(r.openRate)}), clicks ${r.uniqueClicks ?? '?'} (${mcPct(r.clickRate)}), unsubscribes ${r.unsubscribed ?? '?'}${r.revenue != null ? `, revenue ${r.revenue} ${r.currency || ''} from ${r.orders ?? '?'} order(s)` : ''}`).join('\n') + `\n${d.note}`, d);
|
|
13431
|
+
}));
|
|
13432
|
+
server.registerTool('sync_leads_to_mailchimp', {
|
|
13433
|
+
title: 'Sync lead-form leads to a Mailchimp audience',
|
|
13434
|
+
description: "Put the leads the brand's LEAD FORMS collected (Meta, LinkedIn, TikTok, X Ads, Snapchat, ChatGPT Ads) into a Mailchimp AUDIENCE: each lead with an email is added with their name. By default they are NOT subscribed (a non-marketing 'transactional' contact), because a lead form is not consent Hermoso can vouch for; pass consented:true only when the user confirms the form asked for marketing consent. An existing contact's status is never changed, and synced leads are remembered per audience so a re-run never repeats a write. since (default 7 days ago), platforms narrows it, dryRun:true lists what would be synced. Free." + MC_NEEDS,
|
|
13435
|
+
inputSchema: { audienceId: z.string(), consented: z.boolean().optional(), since: z.string().optional().describe('ISO date; default 7 days ago'), platforms: z.array(z.enum(['meta', 'linkedin', 'tiktok', 'x', 'snapchat', 'chatgpt', 'reddit'])).optional(), dryRun: z.boolean().optional() },
|
|
13436
|
+
outputSchema: { account: z.string().optional(), ok: z.boolean().optional(), dryRun: z.boolean().optional(), audienceId: z.string().optional(), consented: z.boolean().optional(), gathered: z.number().optional(), perPlatform: z.any().optional(), unavailable: z.any().optional(), notConnected: z.array(z.string()).optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13437
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13438
|
+
}, wrap(async (a) => { const d = await apiPost('/api/mailchimp/sync-leads', a); return ok(d.note, d); }));
|
|
13439
|
+
// ---------- Klaviyo (2026-11-03): the brand's OWN email marketing — lists, segments, campaigns, flows, templates, images, performance ----------
|
|
13440
|
+
const KL_NEEDS = ' Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).';
|
|
13441
|
+
const klPage = { limit: z.number().optional(), cursor: z.string().optional().describe('nextCursor from a previous call') };
|
|
13442
|
+
const klPct = (x) => (x == null ? '?' : `${(x * 100).toFixed(1)}%`);
|
|
13443
|
+
const klGroups = (seg) => async (a = {}) => {
|
|
13444
|
+
const d = await apiGet(`/api/klaviyo/${seg ? 'segments' : 'lists'}`, a);
|
|
13445
|
+
const rows = d[seg ? 'segments' : 'lists'] || [];
|
|
13446
|
+
return ok(rows.length ? `${rows.length} Klaviyo ${seg ? 'segment' : 'list'}(s) on ${d.account}:\n` + rows.map(x => `- ${x.name} (${x.id}), ${x.profileCount == null ? 'profile count unavailable' : `${x.profileCount} profiles`}${seg && x.isActive === false ? ', inactive' : ''}${x.optIn ? `, ${x.optIn.replace(/_/g, ' ')}` : ''}`).join('\n') + (d.nextCursor ? `\n(more: cursor ${d.nextCursor})` : '') : `No Klaviyo ${seg ? 'segments' : 'lists'}${a.name ? ' with that name' : ''} on ${d.account}. That is a real empty list, not a failed read.`, d);
|
|
13447
|
+
};
|
|
13448
|
+
server.registerTool('list_klaviyo_lists', {
|
|
13449
|
+
title: 'List Klaviyo lists',
|
|
13450
|
+
description: "The brand's Klaviyo LISTS with how many profiles each holds (profile count), single or double opt-in, and when each was created and updated, newest first. Klaviyo pages lists 10 at a time: pass nextCursor for more, or name for one exact list. A list id is a campaign audience (create_klaviyo_campaign listIds) or a lead-sync target. Read-only, free." + KL_NEEDS,
|
|
13451
|
+
inputSchema: { name: z.string().optional().describe('an exact list name'), ...klPage },
|
|
13452
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), lists: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
13453
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13454
|
+
}, wrap(klGroups(false)));
|
|
13455
|
+
server.registerTool('list_klaviyo_segments', {
|
|
13456
|
+
title: 'List Klaviyo segments',
|
|
13457
|
+
description: "The brand's Klaviyo SEGMENTS with how many profiles each holds, whether it is active and still processing, newest first, 10 per page (nextCursor for more). A segment id is a campaign audience (create_klaviyo_campaign segmentIds). Read-only, free." + KL_NEEDS,
|
|
13458
|
+
inputSchema: { name: z.string().optional().describe('an exact segment name'), ...klPage },
|
|
13459
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), segments: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
13460
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13461
|
+
}, wrap(klGroups(true)));
|
|
13462
|
+
server.registerTool('list_klaviyo_campaigns', {
|
|
13463
|
+
title: 'List Klaviyo campaigns',
|
|
13464
|
+
description: "The brand's Klaviyo CAMPAIGNS, newest first: name, status (draft, scheduled, sending, sent or cancelled, read off its messages), send time, and the list / segment ids it targets. Filter by name (contains), status or archived; or pass campaignId to read ONE in full with its subject line, preview text, sender and template. For opens, clicks and revenue use klaviyo_report. Read-only, free." + KL_NEEDS,
|
|
13465
|
+
inputSchema: { campaignId: z.string().optional().describe('read one campaign in full'), name: z.string().optional().describe('part of a campaign name'), status: z.enum(['draft', 'scheduled', 'sending', 'sent', 'cancelled']).optional(), archived: z.boolean().optional(), ...klPage },
|
|
13466
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
13467
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13468
|
+
}, wrap(async (a = {}) => {
|
|
13469
|
+
const d = await apiGet('/api/klaviyo/campaigns', a);
|
|
13470
|
+
return ok(d.count ? `${d.count} Klaviyo campaign(s) on ${d.account}:\n` + (d.campaigns || []).map(x => `- ${x.name} (${x.id}), ${x.status || '?'}${x.messages?.[0]?.sendTime ? `, send ${String(x.messages[0].sendTime).slice(0, 16).replace('T', ' ')} UTC` : ''}, audience ${(x.audiences || []).map(au => (au.included || []).join('+')).join(', ') || '?'}${(x.messages || []).flatMap(m => m.variations || []).map(v => `, subject "${v.subject || ''}"${v.previewText ? `, preview "${v.previewText}"` : ''}, from ${v.fromEmail || '?'}${v.templateId ? `, template ${v.templateId}` : ''}`).join('')}`).join('\n') + (d.nextCursor ? `\n(more: cursor ${d.nextCursor})` : '') : 'No Klaviyo campaigns match. That is a real empty list, not a failed read.', d);
|
|
13471
|
+
}));
|
|
13472
|
+
server.registerTool('list_klaviyo_flows', {
|
|
13473
|
+
title: 'List Klaviyo flows',
|
|
13474
|
+
description: "The brand's Klaviyo FLOWS (automations such as a welcome series, abandoned cart, post-purchase): name, status (live, manual, draft), trigger type, archived, created and updated, newest first. Filter by status or name. For a flow's opens, clicks and revenue use klaviyo_report with of:'flows'. Read-only, free." + KL_NEEDS,
|
|
13475
|
+
inputSchema: { status: z.string().optional().describe('live, manual or draft'), name: z.string().optional().describe('part of a flow name'), ...klPage },
|
|
13476
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), flows: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
13477
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13478
|
+
}, wrap(async (a = {}) => {
|
|
13479
|
+
const d = await apiGet('/api/klaviyo/flows', a);
|
|
13480
|
+
return ok(d.count ? `${d.count} Klaviyo flow(s):\n` + (d.flows || []).map(x => `- ${x.name} (${x.id}), ${x.status || '?'}${x.triggerType ? `, trigger ${x.triggerType}` : ''}${x.archived ? ', archived' : ''}`).join('\n') : 'No Klaviyo flows match. That is a real empty list, not a failed read.', d);
|
|
13481
|
+
}));
|
|
13482
|
+
server.registerTool('list_klaviyo_templates', {
|
|
13483
|
+
title: 'List Klaviyo templates',
|
|
13484
|
+
description: "The brand's Klaviyo EMAIL TEMPLATES (name, id, editor type, created, updated), newest first, 10 per page. A template id is what create_klaviyo_campaign sends. Read-only, free." + KL_NEEDS,
|
|
13485
|
+
inputSchema: { name: z.string().optional().describe('part of a template name'), ...klPage },
|
|
13486
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), templates: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
13487
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13488
|
+
}, wrap(async (a = {}) => {
|
|
13489
|
+
const d = await apiGet('/api/klaviyo/templates', a);
|
|
13490
|
+
return ok(d.count ? `${d.count} Klaviyo template(s):\n` + (d.templates || []).map(x => `- ${x.name} (${x.id}), ${x.editorType || '?'}, updated ${String(x.updated || '').slice(0, 10)}`).join('\n') + (d.nextCursor ? `\n(more: cursor ${d.nextCursor})` : '') : 'No Klaviyo templates match. That is a real empty list, not a failed read.', d);
|
|
13491
|
+
}));
|
|
13492
|
+
server.registerTool('list_klaviyo_images', {
|
|
13493
|
+
title: 'List Klaviyo images',
|
|
13494
|
+
description: "The pictures in the brand's Klaviyo IMAGE LIBRARY: id, name, hosted URL, format and size, newest first. Read-only, free." + KL_NEEDS,
|
|
13495
|
+
inputSchema: { name: z.string().optional().describe('part of an image name'), ...klPage },
|
|
13496
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), images: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
13497
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13498
|
+
}, wrap(async (a = {}) => {
|
|
13499
|
+
const d = await apiGet('/api/klaviyo/images', a);
|
|
13500
|
+
return ok(d.count ? `${d.count} image(s) in the Klaviyo library:\n` + (d.images || []).map(x => `- ${x.name || x.id} (${x.id}), ${x.format || '?'}, ${x.url}`).join('\n') : 'The Klaviyo image library has no images that match. That is a real empty list, not a failed read.', d);
|
|
13501
|
+
}));
|
|
13502
|
+
server.registerTool('upload_klaviyo_image', {
|
|
13503
|
+
title: 'Upload an image to Klaviyo',
|
|
13504
|
+
description: "Put a picture into the brand's Klaviyo IMAGE LIBRARY for use in emails: a Hermoso render or Library id (list_library gives L0, L1…), a Hermoso link, an upload_file URL, or a public https image link (Klaviyo fetches an outside link itself). Klaviyo takes JPEG, PNG or GIF up to 5 MB, so another format or a bigger file is converted to a JPEG first; a video is refused. Returns Klaviyo's own image row (id and hosted URL). create_klaviyo_template uploads its image itself, so call this only to stock the library. Free." + KL_NEEDS,
|
|
13505
|
+
inputSchema: { image: z.string().describe('a Library id (L0), a Hermoso or upload_file URL, or a public https image URL'), name: z.string().optional(), hidden: z.boolean().optional().describe('keep it out of the visible asset library') },
|
|
13506
|
+
outputSchema: { account: z.string().optional(), image: z.any().optional(), via: z.string().optional(), converted: z.boolean().optional() },
|
|
13507
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13508
|
+
}, wrap(async (a) => {
|
|
13509
|
+
const d = await apiPost('/api/klaviyo/images', a);
|
|
13510
|
+
return ok(`Uploaded to the Klaviyo image library: ${d.image?.name || d.image?.id} (${d.image?.id}), ${d.image?.url}${d.converted ? ' (converted to JPEG, which Klaviyo takes)' : ''}.`, d);
|
|
13511
|
+
}));
|
|
13512
|
+
server.registerTool('create_klaviyo_template', {
|
|
13513
|
+
title: 'Create a Klaviyo email template',
|
|
13514
|
+
description: "Make an EMAIL TEMPLATE in Klaviyo from a creative: image (any reference upload_klaviyo_image takes; it goes into the image library first) plus copy YOU write in the brand's voice (read get_brand for voice and colours): headline, body (paragraphs split by a blank line), ctaText + ctaUrl for the button, accent (#RRGGBB button colour). It is laid out as a 600px email with the unsubscribe link and sender address Klaviyo requires. Or pass html for the user's own markup (that footer is added if it has no unsubscribe link). Saves the template only, nothing is sent; returns the templateId for create_klaviyo_campaign. Free." + KL_NEEDS,
|
|
13515
|
+
inputSchema: {
|
|
13516
|
+
name: z.string().optional(), image: z.string().optional().describe('a Library id, Hermoso or upload_file URL, or a public image URL'), imageAlt: z.string().optional(),
|
|
13517
|
+
headline: z.string().optional(), body: z.string().optional(), ctaText: z.string().optional(), ctaUrl: z.string().optional(),
|
|
13518
|
+
accent: z.string().optional().describe('#RRGGBB'), background: z.string().optional().describe('#RRGGBB'),
|
|
13519
|
+
html: z.string().optional().describe('your own full HTML instead of the layout'), text: z.string().optional().describe('plain-text version'),
|
|
13520
|
+
},
|
|
13521
|
+
outputSchema: { account: z.string().optional(), template: z.any().optional(), image: z.any().optional(), footerAdded: z.boolean().optional(), note: z.string().optional() },
|
|
13522
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13523
|
+
}, wrap(async (a) => { const d = await apiPost('/api/klaviyo/templates', a); return ok(`${d.note}${d.image ? ` Image: ${d.image.url}.` : ''}`, d); }));
|
|
13524
|
+
server.registerTool('update_klaviyo_template', {
|
|
13525
|
+
title: 'Update a Klaviyo template',
|
|
13526
|
+
description: "Change a Klaviyo template: a new name, html (the unsubscribe footer is added if it has none) or plain text. Read back. Free." + KL_NEEDS,
|
|
13527
|
+
inputSchema: { templateId: z.string(), name: z.string().optional(), html: z.string().optional(), text: z.string().optional() },
|
|
13528
|
+
outputSchema: { account: z.string().optional(), template: z.any().optional(), changed: z.array(z.string()).optional(), footerAdded: z.boolean().optional() },
|
|
13529
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13530
|
+
}, wrap(async (a) => { const { templateId, ...rest } = a; const d = await apiPost(`/api/klaviyo/templates/${encodeURIComponent(String(templateId || ''))}`, rest); return ok(`Template "${d.template?.name}" (${d.template?.id}) updated: ${(d.changed || []).join(', ')}.${d.footerAdded ? ' The unsubscribe footer Klaviyo requires was added.' : ''}`, d); }));
|
|
13531
|
+
server.registerTool('delete_klaviyo_template', {
|
|
13532
|
+
title: 'Delete a Klaviyo template',
|
|
13533
|
+
description: "PERMANENTLY delete a Klaviyo template. Without confirm:true nothing is deleted and you get the template it would delete; call again with confirm:true only after the user says yes. Free." + KL_NEEDS,
|
|
13534
|
+
inputSchema: { templateId: z.string(), confirm: z.boolean().optional().describe('true only after the user approved deleting this template') },
|
|
13535
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), template: z.any().optional(), deleted: z.string().nullable().optional(), note: z.string().optional() },
|
|
13536
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13537
|
+
}, wrap(async (a) => { const { templateId, ...rest } = a; const d = await apiPost(`/api/klaviyo/templates/${encodeURIComponent(String(templateId || ''))}/delete`, rest); return ok(d.note, d); }));
|
|
13538
|
+
server.registerTool('send_klaviyo_test_email', {
|
|
13539
|
+
title: 'Send a Klaviyo test email',
|
|
13540
|
+
description: "Send ONE TEST EMAIL of a Klaviyo template to up to 5 addresses the user names (never to a list), so they can see it in a real inbox. Without confirm:true nothing is sent and you get the preview; call again with confirm:true after they say yes. Klaviyo allows 100 test sends a day. Free." + KL_NEEDS,
|
|
13541
|
+
inputSchema: { templateId: z.string(), recipients: z.array(z.string()).describe('up to 5 email addresses'), confirm: z.boolean().optional() },
|
|
13542
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), recipients: z.array(z.string()).optional(), job: z.any().optional(), note: z.string().optional() },
|
|
13543
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13544
|
+
}, wrap(async (a) => { const { templateId, ...rest } = a; const d = await apiPost(`/api/klaviyo/templates/${encodeURIComponent(String(templateId || ''))}/test`, rest); return ok(d.note, d); }));
|
|
13545
|
+
server.registerTool('create_klaviyo_campaign', {
|
|
13546
|
+
title: 'Create a Klaviyo campaign draft',
|
|
13547
|
+
description: "Create a Klaviyo email CAMPAIGN DRAFT: templateId (create_klaviyo_template), subject, previewText, the audience as listIds and/or segmentIds (excludeIds leaves people out), optional name, fromEmail / fromLabel (default: the account's default sender) and replyTo. It is created as a DRAFT and NOTHING IS SENT; the answer is Klaviyo's own row read back. To send or schedule it use send_klaviyo_campaign, which shows the recipient count first. Free." + KL_NEEDS,
|
|
13548
|
+
inputSchema: {
|
|
13549
|
+
templateId: z.string(), subject: z.string(), previewText: z.string().optional(),
|
|
13550
|
+
listIds: z.array(z.string()).optional(), segmentIds: z.array(z.string()).optional(), excludeIds: z.array(z.string()).optional(),
|
|
13551
|
+
name: z.string().optional(), fromEmail: z.string().optional(), fromLabel: z.string().optional(), replyTo: z.string().optional(),
|
|
13552
|
+
},
|
|
13553
|
+
outputSchema: { account: z.string().optional(), campaign: z.any().optional(), note: z.string().optional() },
|
|
13554
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13555
|
+
}, wrap(async (a) => { const d = await apiPost('/api/klaviyo/campaigns', a); return ok(d.note, d); }));
|
|
13556
|
+
server.registerTool('update_klaviyo_campaign', {
|
|
13557
|
+
title: 'Update a Klaviyo campaign draft',
|
|
13558
|
+
description: "Edit a Klaviyo campaign that is still a DRAFT: name, subject, previewText, fromEmail, fromLabel, replyTo, templateId, or its audience (listIds / segmentIds / excludeIds). A scheduled one must be reverted first (cancel_klaviyo_campaign). Read back. Free." + KL_NEEDS,
|
|
13559
|
+
inputSchema: {
|
|
13560
|
+
campaignId: z.string(), name: z.string().optional(), subject: z.string().optional(), previewText: z.string().optional(), fromEmail: z.string().optional(), fromLabel: z.string().optional(), replyTo: z.string().optional(), templateId: z.string().optional(),
|
|
13561
|
+
listIds: z.array(z.string()).optional(), segmentIds: z.array(z.string()).optional(), excludeIds: z.array(z.string()).optional(),
|
|
13562
|
+
},
|
|
13563
|
+
outputSchema: { account: z.string().optional(), changed: z.array(z.string()).optional(), campaign: z.any().optional() },
|
|
13564
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13565
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/klaviyo/campaigns/${encodeURIComponent(String(campaignId || ''))}`, rest); return ok(`Draft "${d.campaign?.name}" updated in Klaviyo: ${(d.changed || []).join(', ')}. Nothing was sent.`, d); }));
|
|
13566
|
+
server.registerTool('send_klaviyo_campaign', {
|
|
13567
|
+
title: 'Send or schedule a Klaviyo campaign',
|
|
13568
|
+
description: "SEND or SCHEDULE a Klaviyo campaign draft. An email cannot be unsent, so this is gated on how many people it reaches: without confirm:true nothing is sent and you get Klaviyo's own count of recipients with the subject, sender, lists and timing. Show the user that number; only after they say yes call again with confirm:true AND confirmRecipients set to that count (a mismatch, a zero, or a count Klaviyo cannot produce is refused). sendTime (ISO, in the future) schedules instead of sending now; strategy 'local_timezone' sends at that time in each recipient's own timezone. Free." + KL_NEEDS,
|
|
13569
|
+
inputSchema: {
|
|
13570
|
+
campaignId: z.string(), sendTime: z.string().optional().describe('ISO date-time in the future; omit to send now'), strategy: z.enum(['static', 'local_timezone']).optional(),
|
|
13571
|
+
confirmRecipients: z.number().optional().describe('the recipient count the dry run showed the user'), confirm: z.boolean().optional().describe('true only after the user approved this exact send'),
|
|
13572
|
+
},
|
|
13573
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), recipients: z.number().nullable().optional(), sendTime: z.string().nullable().optional(), strategy: z.string().optional(), campaign: z.any().optional(), confirmed: z.boolean().optional(), note: z.string().optional() },
|
|
13574
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13575
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/klaviyo/campaigns/${encodeURIComponent(String(campaignId || ''))}/send`, rest); return ok(d.note, d); }));
|
|
13576
|
+
server.registerTool('cancel_klaviyo_campaign', {
|
|
13577
|
+
title: 'Stop a Klaviyo campaign',
|
|
13578
|
+
description: "Stop a Klaviyo campaign that is scheduled or sending: action 'revert' puts it back to a draft (only before it goes), 'cancel' cancels it for good. Emails already delivered stay delivered. Without confirm:true nothing changes and you get what would happen. Free." + KL_NEEDS,
|
|
13579
|
+
inputSchema: { campaignId: z.string(), action: z.enum(['revert', 'cancel']).optional(), confirm: z.boolean().optional() },
|
|
13580
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), campaign: z.any().optional(), note: z.string().optional() },
|
|
13581
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13582
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/klaviyo/campaigns/${encodeURIComponent(String(campaignId || ''))}/cancel`, rest); return ok(d.note, d); }));
|
|
13583
|
+
server.registerTool('delete_klaviyo_campaign', {
|
|
13584
|
+
title: 'Delete a Klaviyo campaign',
|
|
13585
|
+
description: "PERMANENTLY delete a Klaviyo campaign (a draft, a sent or a cancelled one; a scheduled one must be reverted first). Without confirm:true nothing is deleted and you get the campaign it would delete; call again with confirm:true only after the user says yes. Free." + KL_NEEDS,
|
|
13586
|
+
inputSchema: { campaignId: z.string(), confirm: z.boolean().optional().describe('true only after the user approved deleting this campaign') },
|
|
13587
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), campaign: z.any().optional(), deleted: z.string().nullable().optional(), note: z.string().optional() },
|
|
13588
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13589
|
+
}, wrap(async (a) => { const { campaignId, ...rest } = a; const d = await apiPost(`/api/klaviyo/campaigns/${encodeURIComponent(String(campaignId || ''))}/delete`, rest); return ok(d.note, d); }));
|
|
13590
|
+
server.registerTool('klaviyo_report', {
|
|
13591
|
+
title: 'Klaviyo campaign and flow performance',
|
|
13592
|
+
description: "Klaviyo PERFORMANCE for campaigns (default) or flows (of:'flows'): recipients, delivered, unique opens and open rate, unique clicks and click rate, conversions and conversion rate, REVENUE (conversion_value) and revenue per recipient, unsubscribes, bounces and spam complaints, per campaign (or per flow message), over timeframe (last_7_days, last_30_days default, last_90_days, this_month, last_12_months...) or start + end (at most a year). Conversions and revenue count on conversionMetric (default 'Placed Order'). Narrow with campaignIds / flowIds or channel. Rates are fractions (0.25 = 25%). Klaviyo allows only 2 report queries a minute and 225 a day, so ask once with a wide window; the same question within 10 minutes is answered from the last read unless fresh:true. Read-only, free." + KL_NEEDS,
|
|
13593
|
+
inputSchema: {
|
|
13594
|
+
of: z.enum(['campaigns', 'flows']).optional(),
|
|
13595
|
+
timeframe: z.enum(['last_12_months', 'last_30_days', 'last_365_days', 'last_3_months', 'last_7_days', 'last_90_days', 'last_month', 'last_week', 'last_year', 'this_month', 'this_week', 'this_year', 'today', 'yesterday']).optional(),
|
|
13596
|
+
start: z.string().optional(), end: z.string().optional(),
|
|
13597
|
+
statistics: z.array(z.string()).optional().describe('Klaviyo statistic names, e.g. opens_unique, click_rate, conversion_value'),
|
|
13598
|
+
campaignIds: z.array(z.string()).optional(), flowIds: z.array(z.string()).optional(),
|
|
13599
|
+
channel: z.enum(['email', 'sms', 'push-notification', 'whatsapp']).optional(),
|
|
13600
|
+
conversionMetric: z.string().optional().describe("metric name or id; default 'Placed Order'"), fresh: z.boolean().optional(),
|
|
13601
|
+
},
|
|
13602
|
+
outputSchema: { account: z.string().optional(), of: z.string().optional(), timeframe: z.any().optional(), conversionMetric: z.any().optional(), statistics: z.array(z.string()).optional(), count: z.number().optional(), results: z.array(z.any()).optional(), cachedAt: z.string().optional(), note: z.string().optional() },
|
|
13603
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13604
|
+
}, wrap(async (a = {}) => {
|
|
13605
|
+
const d = await apiPost('/api/klaviyo/report', a);
|
|
13606
|
+
if (!d.count) return ok(d.note, d);
|
|
13607
|
+
const tf = d.timeframe?.key || `${String(d.timeframe?.start).slice(0, 10)} to ${String(d.timeframe?.end).slice(0, 10)}`;
|
|
13608
|
+
return ok(`Klaviyo ${d.of} performance (${tf}, conversions on "${d.conversionMetric?.name}")${d.cachedAt ? `, from the read at ${d.cachedAt}` : ''}:\n` + (d.results || []).slice(0, 40).map(r => { const s = r.statistics || {}; return `- ${r.name || r.groupings?.campaign_id || r.groupings?.flow_id}${r.groupings?.send_channel ? ` (${r.groupings.send_channel})` : ''}: ${s.recipients ?? '?'} recipients, opens ${s.opens_unique ?? '?'} (${klPct(s.open_rate)}), clicks ${s.clicks_unique ?? '?'} (${klPct(s.click_rate)}), conversions ${s.conversions ?? '?'} (${klPct(s.conversion_rate)}), revenue ${s.conversion_value ?? '?'}, unsubscribes ${s.unsubscribes ?? '?'}`; }).join('\n') + `\n${d.note}`, d);
|
|
13609
|
+
}));
|
|
13610
|
+
server.registerTool('sync_leads_to_klaviyo', {
|
|
13611
|
+
title: 'Sync lead-form leads to a Klaviyo list',
|
|
13612
|
+
description: "Put the leads the brand's LEAD FORMS collected (Meta, LinkedIn, TikTok, X Ads, Snapchat, ChatGPT Ads) into a Klaviyo LIST: each lead with an email becomes a Klaviyo profile (name and the form it came from) and joins listId. Nobody is SUBSCRIBED to marketing: a lead form is not consent Hermoso can vouch for, so their consent status is unchanged. Synced leads are remembered per list, so a re-run never repeats a write. since (default 7 days ago), platforms narrows it, dryRun:true lists what would be synced. Needs profiles:write and lists:write on the key. Free." + KL_NEEDS,
|
|
13613
|
+
inputSchema: { listId: z.string(), since: z.string().optional().describe('ISO date; default 7 days ago'), platforms: z.array(z.enum(['meta', 'linkedin', 'tiktok', 'x', 'snapchat', 'chatgpt', 'reddit'])).optional(), dryRun: z.boolean().optional() },
|
|
13614
|
+
outputSchema: { account: z.string().optional(), ok: z.boolean().optional(), dryRun: z.boolean().optional(), listId: z.string().optional(), gathered: z.number().optional(), perPlatform: z.any().optional(), unavailable: z.any().optional(), notConnected: z.array(z.string()).optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13615
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13616
|
+
}, wrap(async (a) => { const d = await apiPost('/api/klaviyo/sync-leads', a); return ok(d.note, d); }));
|
|
12761
13617
|
// ---------- Stripe (2026-09-09): the brand's OWN revenue, read-only via a restricted key ----------
|
|
12762
13618
|
server.registerTool('stripe_report', {
|
|
12763
13619
|
title: 'Stripe revenue report',
|
|
@@ -13125,7 +13981,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13125
13981
|
description: `Add an Intercom tag to a conversation, or remove it (remove:true). Name it by tagId or exact tagName (list_intercom_tags); a tagName the workspace does not have yet is created only with create:true (ask the user first). Internal to the inbox: the customer never sees tags. Read back. Free. ${icNeeds}`,
|
|
13126
13982
|
inputSchema: { conversationId: z.string().describe('Intercom conversation id to tag'), tagId: z.string().optional().describe('Intercom tag id from list_intercom_tags; use this or tagName'), tagName: z.string().optional().describe('exact tag name, as an alternative to tagId'), remove: z.boolean().optional().describe('true = remove the tag from the conversation; omit or false = add it'), create: z.boolean().optional().describe('true = create tagName if the workspace has no such tag') },
|
|
13127
13983
|
outputSchema: { workspace: z.string().optional(), ok: z.boolean().optional(), confirmed: z.boolean().optional(), removed: z.boolean().optional(), tag: z.any().optional(), note: z.string().optional() },
|
|
13128
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13984
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13129
13985
|
}, wrap(async (a) => { const { conversationId, ...rest } = a; const d = await apiPost(`/api/intercom/conversations/${encodeURIComponent(String(conversationId || '').trim())}/tags`, rest); return ok(d.note, d); }));
|
|
13130
13986
|
server.registerTool('search_intercom_contacts', {
|
|
13131
13987
|
title: 'Search Intercom contacts',
|
|
@@ -13899,7 +14755,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13899
14755
|
data: z.record(z.any()).optional().describe('currency, value, transaction_id, tax, shipping, items [{item_id, price, quantity}], search_term'),
|
|
13900
14756
|
}).passthrough()).describe('1–100 events; one invalid event makes AppLovin drop the whole batch, so all are checked first') },
|
|
13901
14757
|
outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), events: z.any().optional(), dedupeIds: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13902
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
14758
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13903
14759
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/events', a); return ok(d.note, d); }));
|
|
13904
14760
|
// ---------- ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same
|
|
13905
14761
|
// spend law as Google/Microsoft — everything is created PAUSED, only an explicit confirm:true arms real
|
|
@@ -14120,7 +14976,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14120
14976
|
})).describe('up to 1000 per request — Microsoft applies each request independently'),
|
|
14121
14977
|
},
|
|
14122
14978
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), sent: z.number().optional(), enhanced: z.number().optional(), rejected: z.array(z.any()).optional(), goals: z.array(z.string()).optional(), note: z.string().optional() },
|
|
14123
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
14979
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14124
14980
|
}, wrap(async (a) => {
|
|
14125
14981
|
const d = await apiPost('/api/microsoft-ads/offline-conversions', a);
|
|
14126
14982
|
return ok(d.note, d);
|
|
@@ -14661,14 +15517,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14661
15517
|
|
|
14662
15518
|
server.registerTool('apply_apple_ads_recommendation', {
|
|
14663
15519
|
title: 'Apply an Apple Ads recommendation',
|
|
14664
|
-
description: 'THIS CHANGES WHAT A LIVE CAMPAIGN MAY SPEND, and it is the only tool in the Apple Ads suggestion/recommendation family that touches money. Applying a daily-budget recommendation raises that campaign’s daily budget to Apple’s suggested amount; applying a target-CPA recommendation moves the CPA its auto-bidder chases. REQUIRES confirm:true, and the refusal you get without it is built from the recommendation READ BACK FROM APPLE — it names the campaign, states current -> recommended, and quotes Apple’s own projected spend
|
|
15520
|
+
description: 'THIS CHANGES WHAT A LIVE CAMPAIGN MAY SPEND, and it is the only tool in the Apple Ads suggestion/recommendation family that touches money. Applying a daily-budget recommendation raises that campaign’s daily budget to Apple’s suggested amount; applying a target-CPA recommendation moves the CPA its auto-bidder chases. REQUIRES confirm:true, and the refusal you get without it is built from the recommendation READ BACK FROM APPLE — it names the campaign, states the daily budget current -> recommended (Apple keeps no current target CPA anywhere readable, so a target-CPA refusal states the recommended target against the campaign’s average CPA so far), and quotes Apple’s own projected spend over the next seven days — so you are confirming a specific change to a specific campaign rather than confirming that you pressed a button. Apple publishes no dry-run for this call; that refusal IS the preview, and it dispatches nothing. An id Hermoso cannot read back from Apple right now (unknown, expired, another app, or Apple not answering) is refused even WITH confirm:true. Pass amount to apply a DIFFERENT number than Apple suggested; omit it to take Apple’s. The campaign’s STATUS IS NOT TOUCHED: this moves a ceiling, it never enables or pauses anything, and it does not make a paused campaign start spending. Only an AVAILABLE recommendation can be applied and applying is TERMINAL. APPLIED is reported only from READ-BACKS, never from Apple’s acknowledgement: a daily budget only when the re-read CAMPAIGN carries the new budget; a target CPA (which Apple stores on no campaign) only when the re-read campaign exists and the recommendation re-queries as APPLIED. Anything less comes back NOT CONFIRMED and says which read failed. Free to call; the spending it enables is real.',
|
|
14665
15521
|
inputSchema: {
|
|
14666
15522
|
type: z.enum(['daily_budget', 'target_cpa']).optional().describe('Default daily_budget.'),
|
|
14667
15523
|
id: z.string().describe('REQUIRED — the recommendation id from list_apple_ads_recommendations.'),
|
|
14668
15524
|
promotedObjectId: z.string().describe('REQUIRED — the same app or brand id the recommendation was queried for.'),
|
|
14669
15525
|
promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional().describe('Default APPSTORE_APP.'),
|
|
14670
15526
|
amount: z.string().optional().describe('Override Apple’s suggestion with your own decimal amount, e.g. "500.00". Omit to apply what Apple suggested.'),
|
|
14671
|
-
currency: z.string().optional().describe('ISO 4217; defaults to the ad account’s currency.'),
|
|
15527
|
+
currency: z.string().optional().describe('ISO 4217; defaults to the currency of the recommendation Apple quoted (then the ad account’s). A different currency from the recommendation’s is refused — an Apple Ads account cannot mix currencies.'),
|
|
14672
15528
|
confirm: z.boolean().optional().describe('REQUIRED true. Without it nothing changes and the refusal states the campaign and the exact budget or CPA change.'),
|
|
14673
15529
|
},
|
|
14674
15530
|
outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), id: z.string().optional(), state: z.string().optional(), campaignId: z.string().nullable().optional(), armsSpend: z.boolean().optional(), verified: z.boolean().optional(), applied: z.any().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
@@ -15057,7 +15913,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15057
15913
|
thankYouHeadline: z.string().optional().describe('thank-you page headline; X replaces the whole section'), thankYouDescription: z.string().optional().describe('thank-you page text; X replaces the whole section'), thankYouUrl: z.string().optional().describe('https link on the thank-you button'), thankYouButton: z.string().optional().describe('thank-you page button label'), conversionEventId: z.string().optional().describe('X Events Manager event id; needed when the account has no X Pixel'),
|
|
15058
15914
|
},
|
|
15059
15915
|
outputSchema: { accountId: z.string().optional(), leadFormId: z.string().optional(), leadForm: z.any().optional(), updated: z.array(z.string()).optional(), note: z.string().optional() },
|
|
15060
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
15916
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
15061
15917
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/lead-form-update', a); return ok(d.note, d); }));
|
|
15062
15918
|
server.registerTool('get_x_ads_leads', {
|
|
15063
15919
|
title: 'Read the leads from an X lead form',
|
|
@@ -15168,7 +16024,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15168
16024
|
})).describe('conversion events to report, one entry per purchase, sign-up or lead'),
|
|
15169
16025
|
},
|
|
15170
16026
|
outputSchema: { pixelId: z.string().optional(), sent: z.number().optional(), processed: z.number().nullable().optional(), debugId: z.string().nullable().optional(), note: z.string().optional() },
|
|
15171
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16027
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
15172
16028
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/conversions', a); return ok(d.note, d); }));
|
|
15173
16029
|
server.registerTool('create_x_ads_promoted_tweet', {
|
|
15174
16030
|
title: 'Attach a post to an X line item (paused)',
|
|
@@ -15530,7 +16386,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15530
16386
|
integrationSource: z.string().optional().describe('stable identifier for the integration sending the batch; defaults to "hermoso"'),
|
|
15531
16387
|
},
|
|
15532
16388
|
outputSchema: { ok: z.boolean().optional(), pixelSnippetId: z.string().optional(), sent: z.number().optional(), validated: z.number().optional(), validateOnly: z.boolean().optional(), integrationSource: z.string().optional(), duplicateIds: z.array(z.string()).optional(), response: z.any().optional(), note: z.string().optional() },
|
|
15533
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16389
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
15534
16390
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/send-conversions', a); return ok(d.note, d); }));
|
|
15535
16391
|
server.registerTool('create_openai_ads_conversion_api_key', {
|
|
15536
16392
|
title: 'Create a ChatGPT Ads Conversions API key',
|
|
@@ -15641,7 +16497,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15641
16497
|
title: 'Save a new draft of a ChatGPT Ads lead form',
|
|
15642
16498
|
description: 'Save a NEW DRAFT REVISION of a ChatGPT Ads lead form (the whole definition: name + fields are replaced). expectedDraftRevisionId must be the draftRevisionId you last read — ChatGPT Ads refuses a stale one so two editors cannot clobber each other. The published version is untouched until publish_openai_ads_lead_form.',
|
|
15643
16499
|
inputSchema: { leadFormId: z.string().describe('lead form id to edit'), expectedDraftRevisionId: z.string().describe('draftRevisionId from the last read; refused if stale'), name: z.string().describe('lead form name; the whole definition is replaced'), privacyPolicyUrl: z.string().optional().describe('privacy policy URL shown with the form'), fields: z.array(z.object({ fieldId: z.string().describe('your stable key for the field, e.g. email'), fieldType: z.enum(['text', 'choice']).describe('text, or choice (choice needs options)'), label: z.string().describe('question text shown to the person'), required: z.boolean().optional().describe('true = the person must answer this question'), options: z.array(z.string()).optional().describe('answer choices; choice fields only') })).describe('all form questions in order; replaces the whole field list') },
|
|
15644
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16500
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
15645
16501
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/lead-form/update', a))));
|
|
15646
16502
|
server.registerTool('publish_openai_ads_lead_form', {
|
|
15647
16503
|
title: 'Publish a ChatGPT Ads lead form', description: 'Publish the current draft of a ChatGPT Ads lead form so Business Agents can use it. expectedDraftRevisionId is the draftRevisionId you last read. Show the user the fields before publishing: the form is what real people will see.',
|
|
@@ -15660,7 +16516,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15660
16516
|
inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
15661
16517
|
}, wrap(async () => oaiNote(await apiGet('/api/openai-ads/lead-sync'))));
|
|
15662
16518
|
server.registerTool('set_openai_ads_lead_sync', {
|
|
15663
|
-
title: 'Provision ChatGPT Ads lead delivery (webhook)', description: 'Provision lead delivery for this ChatGPT Ads account. OMIT destinationUrl to deliver to HERMOSO: leads land in Hermoso’s own signed receiver, list_openai_ads_leads reads them and
|
|
16519
|
+
title: 'Provision ChatGPT Ads lead delivery (webhook)', description: 'Provision lead delivery for this ChatGPT Ads account. OMIT destinationUrl to deliver to HERMOSO: leads land in Hermoso’s own signed receiver, list_openai_ads_leads reads them and the HubSpot lead sync can push them into HubSpot (no secret to store). Or pass your own https destinationUrl (a managed webhook), optionally with your own signingSecret; then THE SIGNING SECRET IS RETURNED ONCE — every lead webhook is signed with it. Needs ChatGPT Ads connected.',
|
|
15664
16520
|
inputSchema: { destinationUrl: z.string().optional().describe('omit to deliver the leads to Hermoso'), signingSecret: z.string().optional().describe('your own webhook signing secret; only used with destinationUrl, returned once') }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
15665
16521
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/lead-sync', a))));
|
|
15666
16522
|
server.registerTool('list_openai_ads_leads', {
|
|
@@ -15770,7 +16626,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15770
16626
|
server.registerTool('update_openai_ads_feed_settings', {
|
|
15771
16627
|
title: 'Change a ChatGPT Ads feed’s name or organic-listing setting', description: 'Rename a product feed and/or set searchEligibleRespected (its organic ChatGPT search listing follows each product’s is_eligible_search flag). Touches no ads or spend; the reply is what OpenAI stored.',
|
|
15772
16628
|
inputSchema: { feedId: z.string().describe('product feed id to edit'), name: z.string().optional().describe('new feed name'), searchEligibleRespected: z.boolean().optional().describe("true = the organic ChatGPT listing follows each product's is_eligible_search flag") },
|
|
15773
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16629
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
15774
16630
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/settings', a))));
|
|
15775
16631
|
server.registerTool('list_openai_ads_audit_logs', {
|
|
15776
16632
|
title: 'ChatGPT Ads change history (audit log)', description: 'Who changed what on the ChatGPT Ads account and when: each create/update with its actor and every changed field’s before and after value. Read it first when a campaign starts or stops spending unexpectedly. Filter by campaignId (includes its ad groups and ads), adGroupId, adId, actorId, since/until (date, ISO time or unix seconds). Read-only, 0 credits.',
|
|
@@ -16061,7 +16917,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16061
16917
|
})).describe('up to 1,000 events'),
|
|
16062
16918
|
},
|
|
16063
16919
|
outputSchema: { ok: z.boolean().optional(), test: z.boolean().optional(), sent: z.number().optional(), received: z.number().optional(), processed: z.number().optional(), failed: z.array(z.any()).optional(), note: z.string().optional() },
|
|
16064
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16920
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16065
16921
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/conversions', a); return ok(d.note, d); }));
|
|
16066
16922
|
server.registerTool('list_pinterest_ads_audiences', {
|
|
16067
16923
|
title: 'List Pinterest audiences and customer lists',
|
|
@@ -16119,7 +16975,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16119
16975
|
description: 'ADD people to (or REMOVE people from) an existing Pinterest customer list by email or mobile ad id — hashed on our server before they leave it. The list’s counts are read back before and after. Pinterest matches in the background, so the audience size can take hours to move. 0 credits.',
|
|
16120
16976
|
inputSchema: { adAccountId: pinAcct, customerListId: z.string().describe('id of the customer list to change'), operation: z.enum(['ADD', 'REMOVE']).optional().describe('default ADD'), emails: z.array(z.string()).optional().describe('email addresses to add or remove (see operation); hashed on our server'), maids: z.array(z.string()).optional().describe('mobile ad ids to add or remove (see operation); hashed on our server') },
|
|
16121
16977
|
outputSchema: { ok: z.boolean().optional(), operation: z.string().optional(), sent: z.number().optional(), before: z.any().optional(), after: z.any().optional(), note: z.string().optional() },
|
|
16122
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16978
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16123
16979
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/customer-list/update', a); return ok(d.note, d); }));
|
|
16124
16980
|
// Gap rows 6-15 (2026-11-01): keywords, audience size, bulk, and the ad-account extras. lib/pinterest-phase2.mjs reads each off Pinterest's own spec.
|
|
16125
16981
|
const pinKeywordList = z.array(z.object({ value: z.string().describe('the search words, up to 120 characters'), matchType: z.enum(['BROAD', 'PHRASE', 'EXACT', 'EXACT_NEGATIVE', 'PHRASE_NEGATIVE']).optional().describe('default BROAD; a campaign takes only the _NEGATIVE types') }));
|
|
@@ -16785,7 +17641,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16785
17641
|
}).optional().describe('how often Reddit re-fetches the file; omit for a one-off'),
|
|
16786
17642
|
},
|
|
16787
17643
|
outputSchema: { id: z.string().optional(), catalogId: z.string().optional(), url: z.string().optional(), mode: z.string().optional(), schedule: z.any().optional(), nextImportAt: z.string().nullable().optional(), note: z.string().optional() },
|
|
16788
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17644
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
16789
17645
|
}, wrap(async (a) => {
|
|
16790
17646
|
const d = await apiPost('/api/reddit-ads/catalogs/feeds', a);
|
|
16791
17647
|
return ok(d.note, d);
|
|
@@ -16870,7 +17726,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16870
17726
|
products: z.array(z.record(z.any())).describe('each {id, title, price:"19.99 USD", link, image_link, availability, …}'),
|
|
16871
17727
|
},
|
|
16872
17728
|
outputSchema: { counts: z.any().optional(), invalid: z.array(z.any()).optional(), total: z.number().optional(), note: z.string().optional() },
|
|
16873
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17729
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
16874
17730
|
}, wrap(async (a) => {
|
|
16875
17731
|
const d = await apiPost('/api/reddit-ads/catalogs/products', a);
|
|
16876
17732
|
return ok(d.note, d);
|
|
@@ -17046,7 +17902,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17046
17902
|
})).describe('up to 1,000 events per call'),
|
|
17047
17903
|
},
|
|
17048
17904
|
outputSchema: { ok: z.boolean().optional(), pixelId: z.string().optional(), sent: z.number().optional(), withMatchKeys: z.number().optional(), note: z.string().optional() },
|
|
17049
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17905
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
17050
17906
|
}, wrap(async (a) => {
|
|
17051
17907
|
const d = await apiPost('/api/reddit-ads/conversions', a);
|
|
17052
17908
|
return ok(d.note, d);
|
|
@@ -18119,7 +18975,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18119
18975
|
max: z.number().optional().describe('rows to return, default 200, cap 500. The `total` field always reports how many the file actually held.'),
|
|
18120
18976
|
},
|
|
18121
18977
|
outputSchema: { advertiserId: z.string().optional(), region: z.string().optional(), taskId: z.string().optional(), status: z.string().optional(), fileType: z.string().optional(), read: z.boolean().optional(), columns: z.array(z.string()).optional(), leads: z.array(z.any()).optional(), total: z.number().optional(), truncated: z.boolean().optional(), note: z.string().optional() },
|
|
18122
|
-
annotations: { readOnlyHint:
|
|
18978
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
18123
18979
|
}, wrap(async (a) => {
|
|
18124
18980
|
const d = await apiPost('/api/tiktok-ads/leads', a);
|
|
18125
18981
|
if (!d.read) return ok(d.note, d);
|
|
@@ -18165,7 +19021,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18165
19021
|
})).describe('up to 1000 per request — above that TikTok rejects the WHOLE request, not the overflow'),
|
|
18166
19022
|
},
|
|
18167
19023
|
outputSchema: { advertiserId: z.string().optional(), eventSource: z.string().optional(), eventSourceId: z.string().optional(), sent: z.number().optional(), testEventCode: z.string().nullable().optional(), accepted: z.number().nullable().optional(), hashedByHermoso: z.array(z.string()).optional(), note: z.string().optional() },
|
|
18168
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19024
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
18169
19025
|
}, wrap(async (a) => {
|
|
18170
19026
|
const d = await apiPost('/api/tiktok-ads/events', a);
|
|
18171
19027
|
return ok(`Sent ${d.sent} event(s) to TikTok ${d.eventSource} source ${d.eventSourceId}.\n\n${d.note}`, d);
|
|
@@ -18248,7 +19104,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18248
19104
|
name: z.string().describe('the new name, ≤128 characters'),
|
|
18249
19105
|
},
|
|
18250
19106
|
outputSchema: { advertiserId: z.string().optional(), audienceId: z.string().optional(), requested: z.string().optional(), name: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
18251
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19107
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
18252
19108
|
}, wrap(async (a) => {
|
|
18253
19109
|
const d = await apiPost('/api/tiktok-ads/audience-update', a);
|
|
18254
19110
|
return ok(`TikTok audience ${d.audienceId} — requested "${d.requested}", READ BACK from TikTok as "${d.name || '(not returned)'}". ${d.note}`, d);
|
|
@@ -18264,7 +19120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18264
19120
|
usageMode: z.enum(['Include', 'Exclude']).optional().describe('REQUIRED when actionMode is Apply, and refused with Disconnect'),
|
|
18265
19121
|
},
|
|
18266
19122
|
outputSchema: { advertiserId: z.string().optional(), audienceId: z.string().optional(), adgroupIds: z.array(z.string()).optional(), actionMode: z.string().optional(), usageMode: z.string().nullable().optional(), adGroups: z.array(z.any()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
18267
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19123
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
18268
19124
|
}, wrap(async (a) => {
|
|
18269
19125
|
const d = await apiPost('/api/tiktok-ads/audience-apply', a);
|
|
18270
19126
|
return ok(`${d.note}${(d.adGroups || []).length ? '\n' + d.adGroups.map(g => `• ${g.name || '(unnamed)'} (${g.id}) — ${g.status || 'status unreported'}`).join('\n') : ''}`, d);
|
|
@@ -19186,7 +20042,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19186
20042
|
})).describe('one event goes to TikTok’s single endpoint, several to its bulk one — Hermoso picks'),
|
|
19187
20043
|
},
|
|
19188
20044
|
outputSchema: { advertiserId: z.string().optional(), eventSetId: z.string().optional(), endpoint: z.string().optional(), sent: z.number().optional(), hashedByHermoso: z.array(z.string()).optional(), response: z.any().optional(), note: z.string().optional() },
|
|
19189
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
20045
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
19190
20046
|
}, wrap(async (a) => {
|
|
19191
20047
|
const d = await apiPost('/api/tiktok-ads/offline-events', a);
|
|
19192
20048
|
return ok(`Sent ${d.sent} offline event(s) to Offline Event Set ${d.eventSetId}.\n\n${d.note}`, d);
|
|
@@ -20150,7 +21006,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20150
21006
|
})).describe('conversion events to send, up to 2,000 per call'),
|
|
20151
21007
|
},
|
|
20152
21008
|
outputSchema: { adAccountId: z.string().optional(), pixelId: z.string().optional(), snapAppId: z.string().optional(), validateOnly: z.boolean().optional(), sent: z.number().optional(), status: z.string().nullable().optional(), reason: z.string().nullable().optional(), note: z.string().optional() },
|
|
20153
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21009
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
20154
21010
|
}, wrap(async (a) => { const d = await apiPost('/api/snapchat-ads/conversions', a); return ok(d.note, d); }));
|
|
20155
21011
|
|
|
20156
21012
|
// ══ LINKEDIN COMPANY PAGES + ADS (2026-07-30) ══════════════════════════════════════════════════════════════
|
|
@@ -20559,7 +21415,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20559
21415
|
state: z.enum(['PUBLISHED', 'ARCHIVED']).optional().describe('PUBLISHED keeps the form live; ARCHIVED retires it'),
|
|
20560
21416
|
},
|
|
20561
21417
|
outputSchema: { ok: z.boolean().optional(), formId: z.string().optional(), changed: z.array(z.string()).optional(), form: z.any().optional(), note: z.string().optional() },
|
|
20562
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21418
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20563
21419
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/lead-form-update', a); return ok(d.note, d); }));
|
|
20564
21420
|
server.registerTool('update_linkedin_ads_creative', {
|
|
20565
21421
|
title: 'Edit a LinkedIn ad (copy, landing page, button, lead form)',
|
|
@@ -20576,7 +21432,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20576
21432
|
confirm: z.boolean().optional().describe('REQUIRED true to edit an ACTIVE ad'),
|
|
20577
21433
|
},
|
|
20578
21434
|
outputSchema: { ok: z.boolean().optional(), creativeId: z.string().optional(), intendedStatus: z.string().optional(), text: z.string().optional(), landingPage: z.string().nullable().optional(), callToAction: z.string().nullable().optional(), reviewStatus: z.string().nullable().optional(), note: z.string().optional() },
|
|
20579
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21435
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20580
21436
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-creative-update', a); return ok(d.note, d); }));
|
|
20581
21437
|
server.registerTool('preview_linkedin_ad', {
|
|
20582
21438
|
title: 'Preview a LinkedIn ad + its review status',
|
|
@@ -20598,7 +21454,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20598
21454
|
clear: z.boolean().optional().describe('true removes all dynamic tracking parameters'),
|
|
20599
21455
|
},
|
|
20600
21456
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), tracking: z.any().optional(), note: z.string().optional() },
|
|
20601
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21457
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20602
21458
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-tracking', a); return ok(d.note, d); }));
|
|
20603
21459
|
server.registerTool('delete_linkedin_ads_object', {
|
|
20604
21460
|
title: 'Delete a LinkedIn campaign group / campaign / ad',
|
|
@@ -20652,7 +21508,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20652
21508
|
attributionType: z.enum(['LAST_TOUCH_BY_CAMPAIGN', 'LAST_TOUCH_BY_CONVERSION']).optional().describe('how conversions are credited across campaigns: last touch by campaign or by conversion'),
|
|
20653
21509
|
},
|
|
20654
21510
|
outputSchema: { ok: z.boolean().optional(), conversionId: z.string().optional(), note: z.string().optional() },
|
|
20655
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21511
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20656
21512
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-conversion-rule-update', a); return ok(d.note, d); }));
|
|
20657
21513
|
server.registerTool('associate_linkedin_conversion_campaigns', {
|
|
20658
21514
|
title: 'Attach campaigns to a LinkedIn conversion rule',
|
|
@@ -20664,7 +21520,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20664
21520
|
remove: z.boolean().optional().describe('detach instead of attach'),
|
|
20665
21521
|
},
|
|
20666
21522
|
outputSchema: { ok: z.boolean().optional(), conversion: z.string().optional(), removed: z.boolean().optional(), note: z.string().optional() },
|
|
20667
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21523
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20668
21524
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-conversion-campaigns', a); return ok(d.note, d); }));
|
|
20669
21525
|
server.registerTool('send_linkedin_conversions', {
|
|
20670
21526
|
title: 'Send conversions to LinkedIn (Conversions API)',
|
|
@@ -20675,7 +21531,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20675
21531
|
events: z.array(z.record(z.any())).describe('the conversion events — each takes conversionId, conversionHappenedAt (epoch ms or ISO, within 90 days), and at least one identifier: email (PLAIN — hashed here, never hash it yourself), emailSha256, liFatId, ipAddress (IPv4, hashed here), googleAid, acxiomId, firstName WITH lastName, companyName, title, countryCode, lead (urn:li:leadGenFormResponse:…), externalIds (max 1), plus optional amount + currencyCode and an eventId for Insight-Tag deduplication'),
|
|
20676
21532
|
},
|
|
20677
21533
|
outputSchema: { ok: z.boolean().optional(), accepted: z.number().optional(), batch: z.boolean().optional(), note: z.string().optional() },
|
|
20678
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21534
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
20679
21535
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-conversions', a); return ok(d.note, d); }));
|
|
20680
21536
|
server.registerTool('update_meta_object', {
|
|
20681
21537
|
title: 'Edit a Meta campaign / ad set / ad',
|
|
@@ -21100,23 +21956,24 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21100
21956
|
description: "Change properties on ONE row of a Notion database (pageId from query_notion_database) — e.g. mark a calendar row Status \"Posted\" and put the live post link in its URL column. properties is {\"Column\": value} by column name: text, number, checkbox, select (an existing option or a new one), status (must be an existing option), multi-select (array; replaces the whole value), date (ISO, or {start, end}), url, email, phone; null clears. Validated against the database's own columns before anything is sent and refused by name otherwise, never coerced; columns you do not name keep their values. Read back from Notion. trash:true moves the row to Notion's trash (restorable; returns a preview first and needs confirm:true), trash:false restores it. Free.",
|
|
21101
21957
|
inputSchema: { pageId: z.string().describe('the row id or link'), properties: z.record(z.any()).optional().describe('{"Column": value}'), trash: z.boolean().optional().describe('true moves the row to Notion trash (needs confirm); false restores it'), confirm: z.boolean().optional().describe('true confirms moving the row to trash; not needed for other changes') },
|
|
21102
21958
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), title: z.string().optional(), url: z.string().optional(), confirmed: z.boolean().optional(), dryRun: z.boolean().optional(), changed: z.array(z.string()).optional(), row: z.any().optional(), inTrash: z.boolean().optional(), note: z.string().optional() },
|
|
21103
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
21959
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
21104
21960
|
}, wrap(async (a) => { const d = await apiPost('/api/notion/row', { ...a, confirm: a.confirm === true }); return ok(d.note, d); }));
|
|
21105
21961
|
|
|
21106
21962
|
// ---------- Canva (2026-10-27): the user's OWN Canva, the same /api/canva routes the Studio tools call ----------
|
|
21107
21963
|
// Renders go in (an upload, and for an image a new editable design); finished designs come back out into the Library.
|
|
21964
|
+
// Every call reaches Canva through Canva's hosted MCP server with the user's own token (lib/canva-mcp.mjs).
|
|
21108
21965
|
// Free (no model call). Nothing here deletes or overwrites anything in Canva.
|
|
21109
21966
|
server.group('files');
|
|
21110
21967
|
server.registerTool('save_to_canva', {
|
|
21111
21968
|
title: 'Save to Canva',
|
|
21112
|
-
description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva
|
|
21113
|
-
inputSchema: { ids: z.array(z.string()).optional().describe('Library ids, e.g. L0'), urls: z.array(z.string()).optional().describe('render links or any public https image/video link'), title: z.string().optional().describe('title for the new Canva design or upload'), folderId: z.string().optional().describe('Canva folder id from list_canva_folder or create_canva_folder; omit for the default folder'), asDesign: z.boolean().optional().describe('
|
|
21969
|
+
description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva uploads, ready to drag into any design. asDesign:true also asks Canva AI to rebuild a PNG, JPEG or WEBP image as a new design whose text, objects and background are separate editable layers: it uses the account's Canva credits and can redraw details, so only when the user asks for an editable design. Pass Library ids (L0… from list_library), Hermoso render links, or any public https image/video link (upload_file turns a local file into one); optional title, folderId (list_canva_folder / create_canva_folder), asDesign:false for the upload only. Answers with each upload, and each design's edit link. Free on Hermoso. Needs Canva connected (Settings > Connectors > Canva).",
|
|
21970
|
+
inputSchema: { ids: z.array(z.string()).optional().describe('Library ids, e.g. L0'), urls: z.array(z.string()).optional().describe('render links or any public https image/video link'), title: z.string().optional().describe('title for the new Canva design or upload'), folderId: z.string().optional().describe('Canva folder id from list_canva_folder or create_canva_folder; omit for the default folder'), asDesign: z.boolean().optional().describe('true asks Canva AI to rebuild an image as an editable layered design (uses Canva credits); default false: the upload only') },
|
|
21114
21971
|
outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), failed: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
21115
21972
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21116
21973
|
}, wrap(async (a) => { const d = await apiPost('/api/canva/send', a); return ok(d.note, d); }));
|
|
21117
21974
|
server.registerTool('list_canva_designs', {
|
|
21118
21975
|
title: 'List Canva designs',
|
|
21119
|
-
description: "The user's Canva designs
|
|
21976
|
+
description: "The user's Canva designs by search: title, edit link and design id. query matches titles and content; ownership any|owned|shared and sort relevance|modified_descending|modified_ascending|title_ascending|title_descending where Canva's search takes them (the answer says if one was left out). Read-only, free.",
|
|
21120
21977
|
inputSchema: { query: z.string().optional().describe('words to match in design titles and content'), ownership: z.enum(['any', 'owned', 'shared']).optional().describe('any, owned or shared'), sort: z.enum(['relevance', 'modified_descending', 'modified_ascending', 'title_descending', 'title_ascending']).optional().describe('relevance, modified or title, ascending or descending'), limit: z.number().optional().describe('1-100, default 25'), continuation: z.string().optional().describe('continuation token from the previous reply') },
|
|
21121
21978
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), designs: z.array(z.any()).optional(), continuation: z.string().nullable().optional(), note: z.string().optional() },
|
|
21122
21979
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -21126,14 +21983,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21126
21983
|
}));
|
|
21127
21984
|
server.registerTool('export_canva_design', {
|
|
21128
21985
|
title: 'Export a Canva design',
|
|
21129
|
-
description: "Export one of the user's Canva designs (designId or its Canva link) and save the files into the Hermoso Library, ready to post, schedule or use as ad creative. format png (default), jpg, mp4 or gif (pdf and pptx return Canva's
|
|
21130
|
-
inputSchema: { designId: z.string().describe('design id or Canva link'), format: z.enum(['png', 'jpg', 'pdf', 'mp4', 'gif', 'pptx']).optional().describe('png (default), jpg, mp4 or gif saved to the Library; pdf and pptx return a
|
|
21986
|
+
description: "Export one of the user's Canva designs (designId or its Canva link) and save the files into the Hermoso Library, ready to post, schedule or use as ad creative. format png (default), jpg, mp4 or gif (pdf and pptx return Canva's short-lived download link instead of a Library file); pages [1,3] for some pages; quality, width/height (PNG and JPG) and proQuality:true (Canva's lossless pro quality, Pro plans) go to Canva as its export tool takes them, and the answer says if one was left out. A design with unlicensed premium elements fails with that reason. Free.",
|
|
21987
|
+
inputSchema: { designId: z.string().describe('design id or Canva link'), format: z.enum(['png', 'jpg', 'pdf', 'mp4', 'gif', 'pptx']).optional().describe('png (default), jpg, mp4 or gif saved to the Library; pdf and pptx return a short-lived download link'), pages: z.array(z.number()).optional().describe('page numbers to export, e.g. [1, 3]; omit for all pages'), quality: z.union([z.string(), z.number()]).optional().describe('export quality, passed to Canva as its export tool takes it'), width: z.number().optional().describe('output width in pixels, 40 to 25000; PNG and JPG only'), height: z.number().optional().describe('output height in pixels, 40 to 25000; PNG and JPG only'), transparentBackground: z.boolean().optional().describe('PNG only: true exports a transparent background'), proQuality: z.boolean().optional().describe('true requests Canva lossless pro quality (Canva Pro plans)') },
|
|
21131
21988
|
outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), designId: z.string().optional(), title: z.string().optional(), format: z.string().optional(), files: z.array(z.any()).optional(), landed: z.boolean().optional(), note: z.string().optional() },
|
|
21132
21989
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21133
21990
|
}, wrap(async (a) => { const d = await apiPost('/api/canva/export', a); return ok(d.note, d); }));
|
|
21134
21991
|
server.registerTool('list_canva_brand_templates', {
|
|
21135
21992
|
title: 'List Canva brand templates',
|
|
21136
|
-
description: "The user's Canva brand templates (needs Canva Pro,
|
|
21993
|
+
description: "The user's Canva brand templates (needs Canva Pro, Business or Enterprise; Canva answers an empty list on other plans). With brandTemplateId it names that template's autofill fields and their types instead. autofillOnly:true lists only templates with fields. Read-only, free.",
|
|
21137
21994
|
inputSchema: { query: z.string().optional().describe('words to match in template titles'), brandTemplateId: z.string().optional().describe('template id; returns its autofill fields and their types instead of the list'), autofillOnly: z.boolean().optional().describe('true lists only templates that have autofill fields'), limit: z.number().optional().describe('templates per page'), continuation: z.string().optional().describe('continuation token from the previous reply') },
|
|
21138
21995
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), templates: z.array(z.any()).optional(), template: z.any().optional(), fields: z.array(z.any()).optional(), note: z.string().optional() },
|
|
21139
21996
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -21143,15 +22000,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21143
22000
|
}));
|
|
21144
22001
|
server.registerTool('autofill_canva_template', {
|
|
21145
22002
|
title: 'Autofill a Canva template',
|
|
21146
|
-
description: "Fill a Canva brand template's fields into a NEW Canva design: data = { fieldName: text, or an image/video as a Library id or link }. Media is uploaded to Canva first. Needs Canva Pro,
|
|
22003
|
+
description: "Fill a Canva brand template's fields into a NEW Canva design: data = { fieldName: text, or an image/video as a Library id or link }. Media is uploaded to Canva first. Needs Canva Pro, Business or Enterprise (Canva's autofill). Answers with the new design's edit link. Free.",
|
|
21147
22004
|
inputSchema: { brandTemplateId: z.string().describe('brand template id from list_canva_brand_templates'), data: z.record(z.any()).describe('{ field: value }'), title: z.string().optional().describe('title of the new design') },
|
|
21148
22005
|
outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), design: z.any().optional(), note: z.string().optional() },
|
|
21149
22006
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21150
22007
|
}, wrap(async (a) => { const d = await apiPost('/api/canva/autofill', a); return ok(d.note, d); }));
|
|
21151
22008
|
server.registerTool('list_canva_folder', {
|
|
21152
22009
|
title: 'List a Canva folder',
|
|
21153
|
-
description: "What is in one of the user's Canva folders: folderId root (their Projects, default)
|
|
21154
|
-
inputSchema: { folderId: z.string().optional().describe('
|
|
22010
|
+
description: "What is in one of the user's Canva folders: folderId root (their Projects, default) or a folder id. itemTypes design, folder, image. Canva does not list videos here. Read-only, free.",
|
|
22011
|
+
inputSchema: { folderId: z.string().optional().describe('root (default, Projects) or a folder id from an earlier list'), itemTypes: z.array(z.enum(['design', 'folder', 'image'])).optional().describe('filter to design, folder or image'), limit: z.number().optional().describe('items per page'), continuation: z.string().optional().describe('continuation token from the previous reply') },
|
|
21155
22012
|
outputSchema: { ok: z.boolean().optional(), folderId: z.string().optional(), count: z.number().optional(), items: z.array(z.any()).optional(), continuation: z.string().nullable().optional(), note: z.string().optional() },
|
|
21156
22013
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
21157
22014
|
}, wrap(async (a) => {
|
|
@@ -21161,7 +22018,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21161
22018
|
server.registerTool('create_canva_folder', {
|
|
21162
22019
|
title: 'Create a Canva folder',
|
|
21163
22020
|
description: "Create a folder in the user's Canva (in their Projects by default, or inside parentFolderId). save_to_canva's folderId then files renders into it. Free.",
|
|
21164
|
-
inputSchema: { name: z.string().describe('name of the new folder'), parentFolderId: z.string().optional().describe('root (default)
|
|
22021
|
+
inputSchema: { name: z.string().describe('name of the new folder'), parentFolderId: z.string().optional().describe('root (default) or a folder id') },
|
|
21165
22022
|
outputSchema: { ok: z.boolean().optional(), folder: z.any().optional(), note: z.string().optional() },
|
|
21166
22023
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21167
22024
|
}, wrap(async (a) => { const d = await apiPost('/api/canva/folders', a); return ok(d.note, d); }));
|
|
@@ -21407,7 +22264,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21407
22264
|
description: "Create a SHARING LINK to a OneDrive file or folder (fileId from list_onedrive_files): type view (read-only, default), edit, or embed (an <iframe> link, personal OneDrive only); scope anonymous (anyone with the link, default), organization (work or school OneDrive only) or users (only people you invite); optional expiresAt (date or ISO) and password (on a personal OneDrive both need a Microsoft 365 subscription; the refusal says so). An ANONYMOUS EDIT link lets anyone holding it change or delete the file, so it needs confirm:true after you show the user that; every other link is created straight away. If Hermoso already made the same kind of link, OneDrive returns that one and the reply says it was reused. Read back from the item's permission list. 0 credits. Needs OneDrive connected.",
|
|
21408
22265
|
inputSchema: { fileId: z.string().describe('OneDrive file or folder id from list_onedrive_files'), type: z.enum(['view', 'edit', 'embed']).optional().describe('view (default), edit, or embed (personal OneDrive only)'), scope: z.enum(['anonymous', 'organization', 'users']).optional().describe('anonymous (default, anyone with the link), organization (work or school), or users (invited people only)'), expiresAt: z.string().optional().describe('e.g. 2026-11-30 or 2026-11-30T17:00:00Z'), password: z.string().optional().describe('personal OneDrive with Microsoft 365'), confirm: z.boolean().optional().describe('required for an anonymous edit link') },
|
|
21409
22266
|
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), fileId: z.string().optional(), name: z.string().optional(), link: z.any().optional(), reused: z.boolean().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
21410
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
22267
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
21411
22268
|
}, wrap(async (a) => { const d = await apiPost('/api/onedrive/share', a); return ok(d.note, d); }));
|
|
21412
22269
|
server.registerTool('onedrive_file_permissions', {
|
|
21413
22270
|
title: 'Who can open a OneDrive file',
|
|
@@ -21457,15 +22314,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21457
22314
|
// image may be an https URL (an upload_file URL included), a Library id from list_library (L0, L1… newest first), or a
|
|
21458
22315
|
// local path on the CLI (sent as bytes, stored once by the server). An id or path that resolves to nothing is refused
|
|
21459
22316
|
// by name, free, before anything is planned or rendered.
|
|
22317
|
+
// (2026-11-03: the default is now a full-frame cutaway cropped to `focus`, lib/insert-windows.mjs; a card only on ask.)
|
|
21460
22318
|
// HOW IT LOOKS (2026-10-19): a screen sits in a rounded card over the footage, which keeps playing around it (look
|
|
21461
22319
|
// 'card', the default; 'full' covers the frame, only when the user asked); a photo of a person, product or place (kind
|
|
21462
22320
|
// 'photo') is never laid over the film. Never in the first second or the last 1.5 s; 2 s each at most (lib/insert-windows.mjs).
|
|
21463
22321
|
const INSERTS_Z = z.array(z.object({
|
|
21464
22322
|
image: z.string().describe('an https or upload_file URL, a Library id (L0, L1…) or a local path on the CLI'),
|
|
21465
|
-
start: z.number().describe('second it appears
|
|
22323
|
+
start: z.number().describe('second it appears, never before 1 s (the film opens on moving footage)'),
|
|
21466
22324
|
end: z.number().describe('second it ends: at most 2 s after start (2.5 s full-screen), never in the last 1.5 s of the film'),
|
|
21467
|
-
kind: z.enum(['screen', 'photo']).optional().describe("screen (default): an app,
|
|
21468
|
-
look: z.enum(['card', 'full']).optional().describe("
|
|
22325
|
+
kind: z.enum(['screen', 'photo']).optional().describe("screen (default): an app, page, dashboard, document, review or chart. photo: a person, product or place, NOT laid over the video (a product photo goes in as the brand's product photo, a person as a saved creator)"),
|
|
22326
|
+
look: z.enum(['cutaway', 'card', 'full']).optional().describe("cutaway (default): a hard cut to the screen filling the frame, cropped to focus, held still, never enlarged past 1.25x its pixels (any gap is the screenshot's own background colour; a screen too small to read is left out and said). card: picture-in-picture over the playing footage, clear of the speaker's face. full: the whole picture uncropped on a blurred backdrop. card and full ONLY when the user asked"),
|
|
22327
|
+
focus: z.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }).optional().describe("the readable part to fill the frame with, as fractions of the picture from its top-left (e.g. the flagged-items table); a crop as tall as wide, or taller, reads best on a phone. Omit for the whole picture, plain margins trimmed"),
|
|
22328
|
+
key: z.boolean().optional().describe('true = the story needs this one (the payoff): never left out when there are more screens than the length takes'),
|
|
21469
22329
|
})).max(8);
|
|
21470
22330
|
const insertsResolved = async (list) => {
|
|
21471
22331
|
if (!Array.isArray(list)) return undefined;
|
|
@@ -21486,7 +22346,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21486
22346
|
}
|
|
21487
22347
|
return out;
|
|
21488
22348
|
};
|
|
21489
|
-
|
|
22349
|
+
// AN APP ON A SCREEN WITH NOTHING TO SHOW THERE (2026-11-03, the web Studio's screen hold, for agents): render_ad's
|
|
22350
|
+
// "Find them on <site>" (screenChoice:'site') found the brand's own screenshots. Saved on the brand, as the web's button
|
|
22351
|
+
// does, with a typed single-field write (the fetch_app_screens rule), so the next render and the Brand tab have them.
|
|
22352
|
+
const appScreensSaved = async (appScreens) => {
|
|
22353
|
+
const found = appScreens?.found; const n = Array.isArray(found?.screens) ? found.screens.length : 0;
|
|
22354
|
+
if (!n) return '';
|
|
22355
|
+
try { let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object' || Array.isArray(b)) b = {}; await writeStore('heist.brand.v1', { ...b, appScreens: found }); return `\nApp screens: ${n} found on the brand's website and saved to the brand.`; }
|
|
22356
|
+
catch (e) { return `\nApp screens: ${n} found on the brand's website (not saved to the brand: ${String(e?.message || e).slice(0, 120)}).`; }
|
|
22357
|
+
};
|
|
22358
|
+
const insertsLine = (ins, verb = 'are laid') => (Array.isArray(ins) && ins.length ? `\nCut-ins: ${ins.length === 1 ? 'the user\'s picture' : `${ins.length} of the user's pictures`} ${ins.length === 1 ? verb.replace(/^are/, 'is') : verb} at ${ins.map((x) => `${x.start}–${x.end}s${x.look === 'full' ? ' (full-screen)' : ''}`).join(', ')}${ins.some((x) => x.look === 'card') ? ' (a card over the footage, which keeps playing, where asked)' : ''}${ins.some((x) => !x.look || x.look === 'cutaway') ? ', as full-frame cutaways, the voice running underneath' : ''}.` : '');
|
|
22359
|
+
// "3 VARIANTS" IS THREE DIFFERENT CONCEPTS (2026-11-03, lib/count-ask.mjs): say exactly what the plan renders. A distinct-concept
|
|
22360
|
+
// image plan lists each concept with its own prompt (one render per concept); a video plan built concept 1 only; takes re-roll one.
|
|
22361
|
+
const conceptsLines = (c) => {
|
|
22362
|
+
const cs = Array.isArray(c.concepts) ? c.concepts : [];
|
|
22363
|
+
const note = c.concepts_note ? String(c.concepts_note) : '';
|
|
22364
|
+
if (c.concepts_mode === 'distinct' && cs.length > 1) return `${note}\n${cs.map((x, i) => `${i + 1}. ${x.concept}${x.copy?.headline ? ` (headline: ${x.copy.headline})` : ''}\n image_prompt: ${String(x.image_prompt || '').slice(0, 400)}${String(x.image_prompt || '').length > 400 ? '…' : ''}`).join('\n')}\nRender EACH concept with generate_image using ITS OWN image_prompt (concept 1's is image_concept.prompt): one call per concept, never takes of the first.`;
|
|
22365
|
+
if (c.concepts_mode === 'first_built' && cs.length > 1) return `${note}\n${cs.map((x, i) => `${i + 1}. ${x.concept}${i === 0 ? ' ← built' : ''}`).join('\n')}\nTo build another, call plan_ad with that concept as the product.`;
|
|
22366
|
+
return note || `You asked for ${c.concept_count}: ${c.variants || 1} render(s) of this plan.`;
|
|
22367
|
+
};
|
|
21490
22368
|
server.registerTool('plan_ad', {
|
|
21491
22369
|
title: 'Plan an ad concept',
|
|
21492
22370
|
description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. When the user names a length ("a 30 second ad", "make it 45s"), pass it as durationSeconds: the storyboard is written to that length and render_ad renders it as one clip or as joined clips. Without it the planner chooses a length that suits the format, so a length the user asked for belongs in durationSeconds rather than only in the brief. The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. Uses credits for the planning step.',
|
|
@@ -21501,8 +22379,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21501
22379
|
reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post, YouTube Short/video or a direct video file — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
|
|
21502
22380
|
language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
|
|
21503
22381
|
talent: z.enum(['auto', 'creator', 'product_only']).optional().describe("who is on camera; omit to follow the format. Say 'someone new' in product to skip reusing a saved creator."),
|
|
22382
|
+
concepts: z.array(z.string()).max(4).optional().describe('several variants: one line per concept (a different angle each); built exactly'),
|
|
21504
22383
|
draft: z.object({ model: z.string().describe('video model id from a refusal that offered a lighter draft; use only the value it gave'), durationSeconds: z.number().describe('clip length in seconds from that same refusal; use only the value it gave') }).optional().describe("Only after a video refusal that offered a lighter draft: the {model, durationSeconds} that refusal named. The plan is then written to that length and priced on that model. Use only the values the refusal gave; when an account cannot cover a video, the refusal comes before planning with three options (an image, adding credits, or this draft when one fits) and the user chooses."),
|
|
21505
|
-
inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's own SCREENS
|
|
22384
|
+
inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's own SCREENS cut in at these seconds (each item's look, kind, start and end say how), the voice running underneath. How many: at most 8, about one per 2.5 s of film (6 s: 2, 10 s: 4, 15 s: 6, 20 s: 8, 30 s: 8), 1.2-2 s each, on the line that talks about it. When there are more, the most crowded are left out first, never the last (the payoff) or one marked key, and the reply names them. The plan keeps those shots free of screens and returns the pictures as creative.inserts for render_ad to lay in (about 2 credits). One that does not fit is left out and the reply says so."),
|
|
21506
22385
|
},
|
|
21507
22386
|
outputSchema: {
|
|
21508
22387
|
format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
|
|
@@ -21512,7 +22391,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21512
22391
|
format_note: z.string().optional().describe("the plan's own read-back when it chose an IMAGE on an open format because the balance could not cover the default video — states the video's real credit price"),
|
|
21513
22392
|
budget_pick: z.any().optional().describe('{format, reason, videoCredits, balance, seconds} when format_note is set'),
|
|
21514
22393
|
for_fun: z.boolean().optional().describe('true when the ask is personal or playful with nothing to sell: the plan is a plain picture or clip (no CTA, no ad copy) and nothing should be added to it'),
|
|
21515
|
-
concept_count: z.number().optional().describe('
|
|
22394
|
+
concept_count: z.number().optional().describe('variants asked for'),
|
|
22395
|
+
concepts: z.array(z.any()).optional().describe('[{concept, copy, image_prompt}]: render each with its own image_prompt'),
|
|
22396
|
+
concepts_note: z.string().optional().describe('what renders: print it'),
|
|
21516
22397
|
recipe: z.string().optional().describe('the resolved recipe id'),
|
|
21517
22398
|
recipe_label: z.string().optional().describe('the resolved recipe display name'),
|
|
21518
22399
|
copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
|
|
@@ -21525,7 +22406,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21525
22406
|
presence_note: z.string().optional(),
|
|
21526
22407
|
},
|
|
21527
22408
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21528
|
-
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent, inserts }) => {
|
|
22409
|
+
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent, inserts, concepts }) => {
|
|
21529
22410
|
// LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
|
|
21530
22411
|
// 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
|
|
21531
22412
|
// duration at all). /api/create has honored `durationSeconds` all along (it becomes the planner's "Target video
|
|
@@ -21546,7 +22427,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21546
22427
|
}
|
|
21547
22428
|
const _draft = (draft && typeof draft === 'object' && String(draft.model || '').trim()) ? { model: String(draft.model).trim(), durationSeconds: Math.round(+draft.durationSeconds || 0) } : null; // the accepted light draft rides as itself; its length is the plan's length
|
|
21548
22429
|
const _ins = await insertsResolved(inserts); // the user's own pictures, laid over the video after it renders (resolved before anything is billed)
|
|
21549
|
-
const d = await apiPost('/api/create', { brand: brandObj, product, format: _draft ? 'video' : (_ins && _ins.length && format === 'auto' ? 'video' : format), recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_draft ? { draft: _draft, durationSeconds: _draft.durationSeconds || _len || undefined } : (_len ? { durationSeconds: _len } : {})), hook: hook || '', setting: setting || '', userAsk: String(product || ''), ...(talent && talent !== 'auto' ? { talent } : {}), ...(_ins && _ins.length ? { inserts: _ins } : {}) });
|
|
22430
|
+
const d = await apiPost('/api/create', { brand: brandObj, product, ...(Array.isArray(concepts) && concepts.filter(Boolean).length >= 2 ? { concepts: concepts.filter(Boolean).slice(0, 4) } : {}), format: _draft ? 'video' : (_ins && _ins.length && format === 'auto' ? 'video' : format), recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_draft ? { draft: _draft, durationSeconds: _draft.durationSeconds || _len || undefined } : (_len ? { durationSeconds: _len } : {})), hook: hook || '', setting: setting || '', userAsk: String(product || ''), ...(talent && talent !== 'auto' ? { talent } : {}), ...(_ins && _ins.length ? { inserts: _ins } : {}) });
|
|
21550
22431
|
const c = d.creative || d;
|
|
21551
22432
|
// EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
|
|
21552
22433
|
// grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
|
|
@@ -21569,9 +22450,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21569
22450
|
const _struct = _declared || (_planned > 15 ? 'stitched_acts' : 'single_clip');
|
|
21570
22451
|
_lenLine = `\nLength: ${_planned || '—'}s (${_struct === 'stitched_acts' ? 'stitched acts, each at most ONE clip of the render model' : _struct === 'carousel' ? 'carousel slides' : 'one continuous clip'}${_declared ? '' : ' — inferred, the plan declared no structure'})`
|
|
21571
22452
|
+ (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
|
|
21572
|
-
+ (_len && _planned && Math.abs(_planned - _len) > 1 ? ` — ⚠ this does NOT match the ${_len}s you asked for; tell the user before rendering, or re-plan` : '')
|
|
22453
|
+
+ (_len && _planned && Math.abs(_planned - _len) > 1 ? ` — ⚠ this does NOT match the ${_len}s you asked for; tell the user before rendering, or re-plan` : '')
|
|
22454
|
+
// NO LENGTH WAS NAMED, SO THE LENGTH IS A CHOICE TO CONFIRM, NEVER A SILENT ASSUMPTION (2026-11-03, the reference
|
|
22455
|
+
// product: it never assumes a length and offers 15s / 30s / Other). The reply says which length was chosen
|
|
22456
|
+
// and how to change it, so the agent tells the user in the same message that shows the plan; no extra turn.
|
|
22457
|
+
+ (!_askedLen && _planned && _struct !== 'carousel' ? `\nLength was not named, so this is the default (15s for one person, 30s for a two-person format). Tell the user it is ${_planned}s and that 15s, 30s or any other length (4-180s) is theirs to pick; to change it, call plan_ad again with durationSeconds.` : '');
|
|
21573
22458
|
}
|
|
21574
|
-
const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.presence_note ? '\n' + c.presence_note : ''}${c.concept_count > 1 ? `\
|
|
22459
|
+
const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.presence_note ? '\n' + c.presence_note : ''}${c.concept_count > 1 ? `\n${conceptsLines(c)}` : ''}${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}${c.for_fun ? '\nJust for fun: a plain picture of what they asked for, with no headline, button or ad copy. Render it as it is and do not call it an ad.' : `\nHeadline: ${c.copy?.[0]?.headline || ''}`}${insertsLine(c.inserts, 'are laid in by render_ad')}${c.inserts_note ? `\n${c.inserts_note}` : ''}${Array.isArray(inserts) && inserts.length && !(Array.isArray(c.inserts) && c.inserts.length) && !c.inserts_note ? (c.format === 'video' ? '\n⚠ None of the pictures could be kept on this plan; render_ad will not lay any in.' : '\nThe pictures were not used: this plan is an image, and cut-ins go into a video.') : ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
|
|
21575
22460
|
return ok(text, c);
|
|
21576
22461
|
}));
|
|
21577
22462
|
|
|
@@ -21674,28 +22559,33 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21674
22559
|
raw: z.boolean().optional().describe('RAW MODEL ACCESS: run the caller’s prompt on the named model with no Hermoso adjustments at all — the prompt reaches the provider byte-identical (no hex-to-colour-name rewrite, no prepended fidelity preamble) and NO saved-brand product photos are attached, so the model you name is the model that renders. Use it to drive the raw catalog; leave it off for an on-brand ad. Billing, the durable Library landing and per-model validation are unchanged.'),
|
|
21675
22560
|
aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9', '4:5'. Each model draws its own list (hermoso_capabilities prints it per model, e.g. Nano Banana 2 goes to 1:8 and 8:1); a ratio the chosen model cannot draw is refused before anything is charged"),
|
|
21676
22561
|
model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
|
|
22562
|
+
job: z.enum(['static_ad', 'product_photo', 'marketplace', 'thumbnail', 'character_sheet', 'vector']).optional().describe('what the picture is for. With no `model` named it renders on the engine judged best for that job (hermoso_capabilities `jobEngines.image` lists them): static_ad (the default when neither `model` nor `job` is given) = a finished image ad with its headline, copy and button on the picture; product_photo = a studio, lifestyle, flat-lay or spotlight photo of the real product; marketplace = a listing card; thumbnail = a video cover (make_thumbnail is the full thumbnail pipeline); character_sheet = a multi-view sheet of one person; vector = a logo, icon or flat illustration as an editable SVG (it takes no reference pictures, so the saved brand\'s photos stay off it). A named `model` always wins'),
|
|
21677
22563
|
imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
|
|
21678
22564
|
fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check; a re-print costs more, and dryRun:true returns the up-to price). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
|
|
21679
|
-
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: real logo file laid flat. in_scene:
|
|
22565
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: real logo file laid flat. in_scene: the scene leaves a blank patch on the object and the real file is printed onto it (perspective, light). Never drawn by the model. auto (default): from the prompt'),
|
|
21680
22566
|
mask: z.string().optional().describe('MASKED EDIT — change ONE region and keep the rest: a mask (path or URL, any size) for refImages[0]. TRANSPARENT = change, or on a mask with no transparency WHITE = change, black = keep; the reply says which it read. The mask guides rather than stencils, so the change can blend a little past its edge. Runs on gpt-image-2.5 (`refs.mask` in hermoso_capabilities): leave `model` empty or name it; another named model is refused, free. Needs refImages; keeps the source image\'s frame, so aspectRatio is not applied.'),
|
|
21681
22567
|
},
|
|
21682
22568
|
outputSchema: {
|
|
21683
22569
|
image: z.string().optional().describe('the served absolute URL of the finished image'),
|
|
21684
22570
|
model: z.string().optional().describe('the product-facing label of the model that rendered it'),
|
|
21685
22571
|
productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
|
|
21686
|
-
|
|
22572
|
+
logoPlaced: z.any().optional().describe('how the real logo file went on: {applied, mode: in_scene|fix|overlay, object?, fellBack?, notALogo?}; notALogo = the saved logo is a photo, so nothing was added (ask for the logo file)'),
|
|
21687
22573
|
labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
|
|
21688
22574
|
},
|
|
21689
22575
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21690
22576
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
|
|
21691
|
-
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement }) => {
|
|
22577
|
+
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement, job }) => {
|
|
21692
22578
|
const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
|
|
21693
22579
|
const maskRef = mask ? await toRef(mask) : undefined; // a local mask file travels the same way a local reference does
|
|
21694
22580
|
// `raw === true` only — a raw render is opt-in and must be stated properly, so a truthy stray value never
|
|
21695
22581
|
// silently turns off the brand pipeline on an on-brand ad (the same rule lib/raw-passthrough.mjs's predicate uses).
|
|
21696
22582
|
// A MASKED EDIT IS AN EDIT OF THE CALLER'S OWN IMAGE: the saved brand's product photos must not be hydrated in front
|
|
21697
22583
|
// of it, so a mask implies useBrand:false (the server also refuses a mask with no refImages, free).
|
|
21698
|
-
|
|
22584
|
+
// NO MODEL AND NO JOB = AN IMAGE AD (2026-11-03): this tool is what an agent reaches for to make an image ad, so an
|
|
22585
|
+
// unnamed render takes the static_ad job's engine (judged in the job-engine registry) rather than the catalog's
|
|
22586
|
+
// general first row. A masked edit keeps its own rule (the server routes a mask to the one engine that takes it).
|
|
22587
|
+
if (!job && !model && !maskRef) job = 'static_ad';
|
|
22588
|
+
const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}), ...(job ? { job } : {}) }; // fixLabel:false only when stated: the label check is ON by default. job: the image job's engine when no model is named (server: lib/image-job-engines.mjs)
|
|
21699
22589
|
// A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
|
|
21700
22590
|
// `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
|
|
21701
22591
|
// handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
|
|
@@ -21714,7 +22604,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21714
22604
|
const r = await awaitRenderJob({ id: q.jobId });
|
|
21715
22605
|
if (r.stillRendering) return { content: [{ type: 'text', text: `Image queued as job ${r.jobId}. It is rendering; read it with get_job.${q.productNote ? `\n${q.productNote}` : ''}` }], structuredContent: { jobId: r.jobId, stillRendering: true, ...(q.productNote ? { productNote: q.productNote } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
|
|
21716
22606
|
const img = await imageBlock(r.url);
|
|
21717
|
-
const _qNote =
|
|
22607
|
+
const _qNote = joinQueuedImageNote(q.productNote, r.raw); // the route's note (logo, lineup) + the worker's (crop, the product check); a logo the worker moved is said once, where it landed
|
|
21718
22608
|
return { content: [{ type: 'text', text: `${_imgHead(r.raw?.productCheck)}${r.url}${r.model ? ` (${r.model})` : ''}${switchNote({ raw: r.raw || {}, chargedCredits: r.chargedCredits ?? null })}${_qNote ? `\n${_qNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { image: r.url, model: r.model, jobId: r.jobId, creditsUsed: r.creditsUsed, ...(_qNote ? { productNote: _qNote } : {}), ...(r.raw?.productCheck ? { productCheck: r.raw.productCheck } : {}), ...(r.raw?.labelPass ? { labelPass: r.raw.labelPass } : {}), ...(r.raw?.logoCheck ? { logoCheck: r.raw.logoCheck } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
|
|
21719
22609
|
}
|
|
21720
22610
|
const d = await apiPost('/api/generate/image', _imgBody); // explicit boolean so the server's saved-brand hydration default is unambiguous
|
|
@@ -21736,7 +22626,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21736
22626
|
const staticItemsOut = z.array(z.any()).optional().describe('one entry per output: the image URL (or an error), what changed, and a textCheck flag when the rendered text may not match');
|
|
21737
22627
|
server.registerTool('edit_image', {
|
|
21738
22628
|
title: 'Edit an image',
|
|
21739
|
-
description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo is
|
|
22629
|
+
description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo FILE is put on when the instruction asks for the logo (never redrawn by the model): an ask only about the logo ('the logo is wrong', 'use my real logo', 'make the logo bigger') runs no image model, the wrong mark is filled in and the file goes in its place. Set removal:true when the edit STRIPS text, branding or an object, so nothing branded is put back. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. For a precise region, pass `mask` (see generate_image). One image edit's credits; returns the new image URL. For a new image from a prompt use generate_image; to rebuild a competitor's ad for your brand use clone_static.",
|
|
21740
22630
|
inputSchema: {
|
|
21741
22631
|
image: z.string().describe('the image to edit: URL, Library item URL, upload_file URL or local path'),
|
|
21742
22632
|
instruction: z.string().describe('the change to make, in plain words (pass the user’s own words for a removal or plain photo edit)'),
|
|
@@ -21782,7 +22672,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21782
22672
|
}));
|
|
21783
22673
|
server.registerTool('resize_ad', {
|
|
21784
22674
|
title: 'Resize a static ad for other placements',
|
|
21785
|
-
description: "Re-lay out ONE finished static ad for other placements: the same ad, product, copy (word for word), logo and style, recomposed natively for each canvas rather than cropped. Pass `image` and optionally `aspectRatios` from 1:1, 4:5, 9:16, 16:9, 3:4, 4:3 (default 1:1, 4:5 and 9:16; the ad's own ratio is skipped). Reads the ad's text first (3 credits) so every line survives, then one image edit per canvas. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. Priced before it runs. For VIDEO use reframe_video.",
|
|
22675
|
+
description: "Re-lay out ONE finished static ad for other placements: the same ad, product, copy (word for word), logo and style, recomposed natively for each canvas rather than cropped. Pass `image` and optionally `aspectRatios` from 1:1, 4:5, 9:16, 16:9, 3:4, 4:3 (default 1:1, 4:5 and 9:16; the ad's own ratio is skipped). Reads the ad's text first (3 credits) so every line survives, then one image edit per canvas. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. Priced before it runs. Several sizes AND versions (headlines, languages, offers) at once: repurpose_image. For VIDEO use reframe_video.",
|
|
21786
22676
|
inputSchema: {
|
|
21787
22677
|
image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
|
|
21788
22678
|
aspectRatios: z.array(z.enum(['1:1', '4:5', '9:16', '16:9', '3:4', '4:3'])).optional().describe('target canvases (default 1:1, 4:5, 9:16)'),
|
|
@@ -21844,7 +22734,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21844
22734
|
restrainedGrade: z.boolean().optional().describe('true for a calm / premium / muted look instead of the default punchy poster grade'),
|
|
21845
22735
|
logo: z.string().optional().describe('a brand logo URL or path to place into the composition'),
|
|
21846
22736
|
logo3d: z.boolean().optional().describe('first turn the flat logo into a volumetric 3D render (one extra billed image), then composite that'),
|
|
21847
|
-
logoPlacement: z.enum(['auto', 'overlay', 'in_scene']).optional().describe('overlay (exact, flat) | in_scene (
|
|
22737
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene']).optional().describe('overlay (exact, flat) | in_scene (the real file printed onto a blank panel in the shot; with logo3d, a 3D render of it, checked); auto: in_scene with logo3d'),
|
|
21848
22738
|
split: z.object({ mode: z.enum(['plain', 'before_after', 'versus', 'custom']).describe('layout: plain, before_after, versus or custom'), panels: z.array(z.string()).optional().describe('panel descriptions in order, left to right or top to bottom; sets the panel count (minimum 2)') }).passthrough().optional().describe('split/panel LAYOUT — only when the user asks for one ("split", "before/after", "versus screen"). "X vs Y" as a SCENE stays one unified frame'),
|
|
21849
22739
|
reference: z.object({}).passthrough().optional().describe("fields YOU extracted by eye from a reference thumbnail. Extract ALL of: brief (one dense sentence on the concept), subject (pose/action generically, NEVER a specific identity), elements, location, composition, background, split (boolean), split_count, person_count (0-3), emotion (one of the 11 presets or 'other'), emotion_detail (one vivid sentence covering eyes, brows, mouth, head angle). emotion + emotion_detail carry the reference's actual facial performance, which is the single biggest CTR lever on a face; split/split_count reproduce its panel structure. The reference image itself is never sent to the model"),
|
|
21850
22740
|
tweak: z.object({ kind: z.string().describe('emotion, background, background_color or rim_light, or another kind with the edit in words'), value: z.string().describe('new value for the kind: an emotion, a colour, or the edit in words') }).describe('surgical pixel-faithful edit of a FINISHED thumbnail (needs sourceImage): kind emotion / background / background_color / rim_light, or any other kind with the edit in words as value').optional(),
|
|
@@ -21881,29 +22771,79 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21881
22771
|
// ---------- raw playground: voice (TTS) + writing models ----------
|
|
21882
22772
|
server.group('create');
|
|
21883
22773
|
server.registerTool('generate_voice', {
|
|
21884
|
-
title: 'Generate voiceover',
|
|
21885
|
-
description: "RAW
|
|
21886
|
-
inputSchema: {
|
|
21887
|
-
text: z.string().describe('the script to speak (≤900 characters)'),
|
|
21888
|
-
engine: z.string().optional().describe("voice-engine id: 'seed-audio' (
|
|
21889
|
-
voice: z.string().optional().describe("a
|
|
22774
|
+
title: 'Generate voiceover or dialogue',
|
|
22775
|
+
description: "RAW speech as an MP3: one voice reading a script (text), or a DIALOGUE of two or more people (lines), each in their own voice. Not for adding audio to a video (render_ad and generate_video voice their own spots; change_voice re-voices a finished clip). ONE VOICE: text + engine (default 'seed-audio'; also 'eleven-v4', 'minimax-speech', 'kokoro'…) + voice, a preset from that engine (hermoso_capabilities -> voice engines); a name the engine does not have is REFUSED for free with its real list, and a few engines make their own voice (the reply says which voice spoke). DIALOGUE: lines [{speaker, voice, text}] in speaking order. Each speaker keeps ONE voice, no two speakers share one, a speaker with no voice gets one nobody has; up to 10 voices and 10,000 characters, refused for free past that. Default engine: the dialogue model (Eleven v3 Dialogue, voices Aria, George, Sarah, Roger, Laura, Charlie, Callum, River, Liam, Charlotte, Alice, Matilda, Will, Jessica, Eric, Chris, Brian, Daniel, Lily, Bill, or an ElevenLabs voice id; '[laughs]'-style cues work); or name any voice engine (e.g. 'eleven-v4') to read each line on it and join them in order. dryRun:true returns the exact credits and makes nothing; the price quoted is the charge. Paid, priced by characters (hermoso_capabilities lists credits per 1,000).",
|
|
22776
|
+
inputSchema: {
|
|
22777
|
+
text: z.string().optional().describe('ONE VOICE: the script to speak (≤900 characters)'),
|
|
22778
|
+
engine: z.string().optional().describe("voice-engine id: one voice defaults to 'seed-audio'; a dialogue defaults to the dialogue model ('eleven-dialogue'); or 'eleven-v4', 'minimax-speech', 'kokoro'… (hermoso_capabilities)"),
|
|
22779
|
+
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"),
|
|
22780
|
+
lines: z.array(z.object({
|
|
22781
|
+
speaker: z.string().optional().describe('who speaks; the same name keeps the same voice'),
|
|
22782
|
+
voice: z.string().optional().describe("that speaker's voice from the engine's list (or an ElevenLabs voice id); omit to be given one"),
|
|
22783
|
+
text: z.string().describe('what they say'),
|
|
22784
|
+
})).optional().describe('DIALOGUE: the turns in speaking order'),
|
|
22785
|
+
dryRun: z.boolean().optional().describe('true = the exact credits only, nothing made or charged'),
|
|
21890
22786
|
},
|
|
21891
22787
|
outputSchema: {
|
|
21892
|
-
audio: z.string().optional().describe('the served absolute URL of the MP3
|
|
21893
|
-
voice: z.string().nullable().optional().describe('the voice that ACTUALLY spoke — null on an engine that takes no voice preset (see voiceNote), never an echo of what was asked for'),
|
|
22788
|
+
audio: z.string().optional().describe('the served absolute URL of the MP3'),
|
|
22789
|
+
voice: z.string().nullable().optional().describe('ONE VOICE: the voice that ACTUALLY spoke — null on an engine that takes no voice preset (see voiceNote), never an echo of what was asked for'),
|
|
21894
22790
|
voiceSelectable: z.boolean().optional().describe('false when this engine generates its own voice and takes no preset'),
|
|
21895
22791
|
voiceNote: z.string().optional().describe('why the voice differs from what was asked, when it does — report it verbatim'),
|
|
22792
|
+
cast: z.array(z.any()).optional().describe('DIALOGUE: [{speaker, voice}], the voice each speaker spoke in'),
|
|
22793
|
+
lines: z.number().optional().describe('DIALOGUE: the number of turns'),
|
|
22794
|
+
characters: z.number().optional(),
|
|
22795
|
+
durationSeconds: z.number().nullable().optional(),
|
|
21896
22796
|
model: z.string().optional().describe('the voice engine label'),
|
|
22797
|
+
credits: z.number().optional().describe('dryRun: the exact credits it would charge'),
|
|
22798
|
+
dryRun: z.boolean().optional(),
|
|
21897
22799
|
creditsUsed: z.number().optional().describe('credits billed for this clip'),
|
|
21898
22800
|
},
|
|
21899
22801
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21900
|
-
}, wrap(async ({ text, engine, voice }) => {
|
|
21901
|
-
|
|
22802
|
+
}, wrap(async ({ text, engine, voice, lines, dryRun }) => {
|
|
22803
|
+
if (Array.isArray(lines) && lines.length) {
|
|
22804
|
+
const d = await apiPost('/api/generate/voice', { lines, ...(engine ? { engine } : {}), ...(dryRun ? { dryRun: true } : {}) });
|
|
22805
|
+
const cast = (d.cast || []).map((c) => `${c.speaker}: ${c.voice}`).join(', ');
|
|
22806
|
+
if (d.dryRun) return ok(`DIALOGUE QUOTE (nothing made, nothing charged): ${d.lines} lines, ${d.characters} characters, ${cast}, on ${d.model}. Price: ${d.credits} credits, exactly what it charges. Call again without dryRun to make it.`, d);
|
|
22807
|
+
return ok(`Dialogue ready (${d.durationSeconds ?? '?'}s, ${d.model}; ${cast}, ${d.creditsUsed} credits): ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
|
|
22808
|
+
}
|
|
22809
|
+
const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}), ...(dryRun ? { dryRun: true } : {}) });
|
|
22810
|
+
if (d.dryRun) return ok(`VOICE QUOTE (nothing made, nothing charged): ${d.characters} characters on ${d.model}. Price: ${d.credits} credits, exactly what it charges.`, d);
|
|
21902
22811
|
// REPORT WHAT SPOKE (GEN-2). `d.voice` is the DISPATCHED voice and is null on a fixed-voice engine — the server
|
|
21903
22812
|
// refuses an unknown voice for free rather than rendering in a different one and echoing the ask back.
|
|
21904
22813
|
return ok(`Voice clip ready — ${d.voice || 'the engine’s own voice'}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}${d.voiceNote ? `\n⚠ ${d.voiceNote}` : ''}`, { ...d, audio: abs(d.audio) });
|
|
21905
22814
|
}));
|
|
21906
22815
|
|
|
22816
|
+
// A SOUND EFFECT OR AMBIENCE MADE FROM WORDS (2026-11-03): POST /api/generate/sfx, the same engine as the Studio's generate_sfx.
|
|
22817
|
+
server.registerTool('generate_sfx', {
|
|
22818
|
+
title: 'Generate a sound effect',
|
|
22819
|
+
description: "MAKE a sound effect or ambience that does not exist yet, from a description, as an MP3: a whoosh, a door slam, a cash-register ding, rain on a window, a busy cafe. (find_sound FINDS an existing sound, a meme or trending TikTok sound; this MAKES one.) seconds = length, 0.5-30 (default 5); loop:true for a loopable bed. engine: omit for Seed Audio 1.0 (length and loop are asked for in words, so the length is approximate), or 'eleven-sfx' (ElevenLabs Sound Effects v3: exact length, a real loop flag, description ≤450 characters). Lay it under a video with post_edit (music.track takes its link). dryRun:true returns the exact credits and makes nothing; the price quoted is the charge. Paid, priced by length (hermoso_capabilities -> sound effects).",
|
|
22820
|
+
inputSchema: {
|
|
22821
|
+
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'"),
|
|
22822
|
+
seconds: z.number().optional().describe('length in seconds, 0.5-30 (default 5)'),
|
|
22823
|
+
loop: z.boolean().optional().describe('true for a loopable bed'),
|
|
22824
|
+
engine: z.string().optional().describe("omit for 'seed-audio' (Seed Audio 1.0), or 'eleven-sfx' (ElevenLabs Sound Effects v3)"),
|
|
22825
|
+
dryRun: z.boolean().optional().describe('true = the exact credits only, nothing made or charged'),
|
|
22826
|
+
},
|
|
22827
|
+
outputSchema: {
|
|
22828
|
+
audio: z.string().optional().describe('the served absolute URL of the MP3'),
|
|
22829
|
+
durationSeconds: z.number().nullable().optional().describe('the measured length of what came back'),
|
|
22830
|
+
seconds: z.number().optional().describe('the length asked for and priced'),
|
|
22831
|
+
loop: z.boolean().optional(),
|
|
22832
|
+
exactLength: z.boolean().optional().describe('false when the length was asked for in words (Seed Audio)'),
|
|
22833
|
+
loopNote: z.string().optional(),
|
|
22834
|
+
model: z.string().optional(),
|
|
22835
|
+
engine: z.string().optional(),
|
|
22836
|
+
credits: z.number().optional().describe('dryRun: the exact credits it would charge'),
|
|
22837
|
+
dryRun: z.boolean().optional(),
|
|
22838
|
+
creditsUsed: z.number().optional(),
|
|
22839
|
+
},
|
|
22840
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22841
|
+
}, wrap(async ({ describe, seconds, loop, engine, dryRun }) => {
|
|
22842
|
+
const d = await apiPost('/api/generate/sfx', { describe, ...(seconds ? { seconds } : {}), ...(loop ? { loop: true } : {}), ...(engine ? { engine } : {}), ...(dryRun ? { dryRun: true } : {}) });
|
|
22843
|
+
if (d.dryRun) return ok(`SOUND QUOTE (nothing made, nothing charged): ${d.seconds}s${d.loop ? ' loopable' : ''} on ${d.model}. Price: ${d.credits} credits, exactly what it charges.`, d);
|
|
22844
|
+
return ok(`Sound ready (${d.durationSeconds ?? '?'}s, ${d.model}, ${d.creditsUsed} credits): ${abs(d.audio)}${d.exactLength === false ? '\nThe length was asked for in words, so it may run a little long or short.' : ''}${d.loopNote ? `\n${d.loopNote}` : ''}`, { ...d, audio: abs(d.audio) });
|
|
22845
|
+
}));
|
|
22846
|
+
|
|
21907
22847
|
server.registerTool('generate_music', {
|
|
21908
22848
|
title: 'Generate music',
|
|
21909
22849
|
description: "RAW music: describe it (genre, mood, instruments, tempo), get an instrumental MP3. No length: a full track at a flat fee (explainerMusicCredits in hermoso_capabilities). With seconds: by the second up to about 38 s. A named model always runs.",
|
|
@@ -21944,7 +22884,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21944
22884
|
server.group('create');
|
|
21945
22885
|
server.registerTool('render_ad', {
|
|
21946
22886
|
title: 'Render ad video',
|
|
21947
|
-
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. 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. 1–3 min; poll get_job while it renders. Spends credits.',
|
|
22887
|
+
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. 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.',
|
|
21948
22888
|
inputSchema: {
|
|
21949
22889
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
21950
22890
|
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),
|
|
@@ -21952,7 +22892,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21952
22892
|
model: z.string().optional().describe('video model id from hermoso_capabilities (default: the engine the router picks for the plan: seedance-2.5 for every ad, a person talking to camera included). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
|
|
21953
22893
|
durationSeconds: z.number().optional().describe('total ad length in seconds, 4–180 (clamped). Omit to keep the plan’s own length, almost always right: this only RE-TIMES the authored board; to change the ad’s length, re-run plan_ad with durationSeconds. A length that fits ONE clip renders in one pass; longer is stitched from acts of the model’s clip maximum (15s on most, 30s on the longest-clip one), remainder last. dryRun:true shows the split free.'),
|
|
21954
22894
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
21955
|
-
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("
|
|
22895
|
+
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("Omit = the default tier, hermoso_capabilities defaultResolution (720p), the tier its `credits` prices are for. 1080p is ~2.25x, 4k more, 480p less: quote creditsByRes and pass the tier you quoted. A tier a model's `resolutions` lacks renders at its best one."),
|
|
21956
22896
|
captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE on every recipe — set true ONLY when the user asks for on-screen text or captions; no recipe turns them on by itself'),
|
|
21957
22897
|
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE on every recipe — set true ONLY when the user asks for an end card (a clone of a video that had none should not grow one)'),
|
|
21958
22898
|
music: z.union([z.boolean(), z.string()]).optional().describe("music bed: OFF unless asked. true = the plan's own music line; or the bed in words ('lo-fi jazz, brushed drums'); false = none"),
|
|
@@ -21966,11 +22906,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21966
22906
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
21967
22907
|
dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) and the estimated credits the real render reserves, WITHOUT submitting a render — free, nothing charged'),
|
|
21968
22908
|
allowGenericProduct: z.boolean().optional().describe('proceed even though this brand has NO product photo on file and the ad features a product — the packaging will be INVENTED. Only pass true after telling the user that and hearing they are fine with a generic stand-in'),
|
|
21969
|
-
|
|
22909
|
+
screens: z.array(z.string()).max(6).optional().describe("real screenshots of the app or site for the shots that film it on a screen (URLs, upload_file URLs or Library ids), laid over them as cards"),
|
|
22910
|
+
screenChoice: z.enum(['brand', 'site', 'without']).optional().describe("the user's answer to needsAppScreens: brand = their saved app screens, site = found on their website (saved to the brand), without = none, device kept turned away"),
|
|
22911
|
+
inserts: INSERTS_Z.optional().describe("the user's own screens laid over the finished video at these seconds (each item's look says how), sound running underneath (about 2 credits). The film always opens and ends on moving footage. Omit to use plan_ad's creative.inserts; [] renders without them. plan_ad with inserts writes the board around them."),
|
|
21970
22912
|
},
|
|
21971
22913
|
outputSchema: {
|
|
21972
22914
|
...JOB_OUT,
|
|
21973
22915
|
needsProductPhoto: z.boolean().optional().describe('true when nothing was rendered because the ad features a product this brand has no photo of'),
|
|
22916
|
+
needsAppScreens: z.boolean().optional().describe('true = nothing rendered: the app is on a screen with no screens to lay; ask, then pass screenChoice or screens'),
|
|
22917
|
+
appScreens: z.any().optional().describe('app-screen read-back: held {reason, choices} or {how, laid}'),
|
|
21974
22918
|
dryRun: z.boolean().optional().describe('true when this was a dry run (no job submitted, nothing charged)'),
|
|
21975
22919
|
jobType: z.string().optional().describe("the routing decision — 'video' (single pass) or 'stitch' (acts)"),
|
|
21976
22920
|
creator: z.any().optional().describe('the saved creator this render RESOLVED to — {id, name, source, consented}. The read-back, not what you typed'),
|
|
@@ -21989,12 +22933,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21989
22933
|
if (_len) a = { ...a, durationSeconds: _len };
|
|
21990
22934
|
const _clampNote = (_askedLen && _askedLen !== _len) ? `\n(${_askedLen}s is outside the supported 4–180s range — rendered at ${_len}s.)` : '';
|
|
21991
22935
|
if (Array.isArray(a.inserts)) a = { ...a, inserts: await insertsResolved(a.inserts) }; // the user's pictures, resolved before anything is billed
|
|
21992
|
-
|
|
22936
|
+
if (Array.isArray(a.screens)) a = { ...a, screens: (await insertsResolved(a.screens.map((image) => ({ image, start: 0, end: 0 })))).map((x) => x.image) }; // the app screens: URL, upload_file URL, Library id or a CLI path, refused by name if unreadable
|
|
22937
|
+
// needsAppScreens: an app on a device screen with nothing to lay there, held before anything was cast, painted or
|
|
22938
|
+
// billed (lib/app-screen-hold.mjs); a dry run too, since there is nothing to price until the user answers.
|
|
22939
|
+
const { input, jobType, notes, needsProductPhoto, needsAppScreens, appScreens, creator, ownRefNotice, quote, brandPhoto } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
|
|
22940
|
+
if (needsAppScreens) return ok(notes || '', { needsAppScreens: true, appScreens });
|
|
21993
22941
|
// THE CAST IS THE READ-BACK, NEVER THE ASK. `creator` is the row the SERVER resolved out of this workspace's own
|
|
21994
22942
|
// roster; a half-remembered name that matched nobody, matched two people, or belongs to an unconsented real
|
|
21995
22943
|
// person never reaches here at all (the assemble route refuses, free, before a job exists). So this line names
|
|
21996
22944
|
// who is actually in the ad, and it names them from the resolution — the same law the ads tree follows.
|
|
21997
|
-
const _castLine = (creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consentVia === 'api-implied' ? ', likeness consent confirmed by this call and recorded' : creator.consented ? ', likeness consent on file' : '') : ''}.` : '') + (ownRefNotice ? `\n⚠ ${ownRefNotice.text} ${ownRefNotice.fix}` : ''); // a creator's own photo too poor to cast is never cast silently (lowQualityRef) — say so and name the fix
|
|
22945
|
+
const _castLine = (await appScreensSaved(appScreens)) + (creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consentVia === 'api-implied' ? ', likeness consent confirmed by this call and recorded' : creator.consented ? ', likeness consent on file' : '') : ''}.` : '') + (ownRefNotice ? `\n⚠ ${ownRefNotice.text} ${ownRefNotice.fix}` : ''); // a creator's own photo too poor to cast is never cast silently (lowQualityRef) — say so and name the fix
|
|
21998
22946
|
// LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
|
|
21999
22947
|
// (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
|
|
22000
22948
|
// acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
|
|
@@ -22002,8 +22950,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22002
22950
|
// belted input the job would queue, so the credits printed here are what the render reserves, and the model/length
|
|
22003
22951
|
// are the ones it prices (the belt may re-route an auto pick). A refusal the real run would throw is said as one.
|
|
22004
22952
|
const _q = quote && typeof quote === 'object' ? quote : null;
|
|
22005
|
-
const _qLine = _q ? (Number.isFinite(+_q.credits) && _q.credits != null ? `\nESTIMATED COST: about ${_q.credits} credits
|
|
22006
|
-
if (a.dryRun) return ok(`DRY RUN — routing decision (no job submitted, nothing charged): jobType=${jobType || 'video'}, model=${_q?.model || input.model}, durationSeconds=${_q?.durationSeconds || input.durationSeconds}${Array.isArray(input.scenes) ? `, acts=[${(_q?.acts || input.scenes.map(s => Math.round(s.seconds * 10) / 10)).join(', ')}]s` : ' (single pass)'}${input.modelExplicit ? ', modelExplicit (ask-don’t-swap)' : ''}.${_qLine}${_clampNote}${_castLine}\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', ...(_q && _q.credits != null ? { credits: _q.credits } : {}), ...(_q ? { quote: _q } : {}), ...(creator ? { creator } : {}), input });
|
|
22953
|
+
const _qLine = _q ? (Number.isFinite(+_q.credits) && _q.credits != null ? `\nESTIMATED COST: about ${_q.priceCredits > _q.credits ? _q.priceCredits : _q.credits} credits for the whole job (${_q.label || _q.model}, ${_q.durationSeconds}s${_q.resolution ? ` at ${_q.resolution}` : ''}${_q.priceCredits > _q.credits ? `; ${_q.credits} reserved for the video and ${_q.priceCredits - _q.credits} to lay your screens in after it` : ', reserved when it renders'}). ${PRICE_ESTIMATE_LINE}` : _q.refused ? `\nThe real render would be refused before it starts: ${_q.refused}` : '') : '\nCOST: not quoted for this render; hermoso_capabilities lists per-model credit costs.';
|
|
22954
|
+
if (a.dryRun) return ok(`DRY RUN — routing decision (no job submitted, nothing charged): jobType=${jobType || 'video'}, model=${_q?.model || input.model}, durationSeconds=${_q?.durationSeconds || input.durationSeconds}${Array.isArray(input.scenes) ? `, acts=[${(_q?.acts || input.scenes.map(s => Math.round(s.seconds * 10) / 10)).join(', ')}]s` : ' (single pass)'}${input.modelExplicit ? ', modelExplicit (ask-don’t-swap)' : ''}.${_qLine}${_clampNote}${_castLine}\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', ...(_q && _q.credits != null ? { credits: _q.priceCredits > _q.credits ? _q.priceCredits : _q.credits } : {}), ...(_q ? { quote: _q } : {}), ...(creator ? { creator } : {}), appScreens, input });
|
|
22007
22955
|
// ASK BEFORE SPENDING (2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
|
|
22008
22956
|
// spent"). `notes` alone was not enough here: on the real path it only reaches the model AFTER renderJob has
|
|
22009
22957
|
// polled to completion, i.e. after the credits are gone. So when the ad features a product this brand has no
|
|
@@ -22025,7 +22973,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22025
22973
|
// FOUNDATIONS ONLY (foundations:'choose'): no video was rendered — hand back the pictures to pick from.
|
|
22026
22974
|
const _fd = renderPayload(r);
|
|
22027
22975
|
if (_fd && _fd.foundationsOnly) { const F = _fd.foundations || {}; return ok(`Foundations painted (${_fd.boardCreditsUsed || 0} credits, billed as images) [job ${r.jobId}]:\n${Object.entries(F).filter(([, v]) => typeof v === 'string').map(([k, v]) => `• ${k}: ${abs(v)}`).join('\n')}\n${_fd.note || ''}`, { ...r, foundations: F }); }
|
|
22028
|
-
return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_fd?.inserts?.laid ? insertsLine(_fd.inserts.windows) : ''}${_fd?.inserts?.failed && _fd.note ? `\nTELL THE USER: ${_fd.note}` : ''}${_clampNote}${switchNote(r)}${_castLine}${Array.isArray(_fd?.boards) && _fd.boards.length ? `\nStoryboard: ${_fd.boards.map((u) => abs(u)).join(' , ')}${_fd.boardCreditsUsed ? ` (${_fd.boardCreditsUsed} credits of image paints)` : ''}` : ''}\n${notes || ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}) });
|
|
22976
|
+
return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_fd?.inserts?.laid ? insertsLine(_fd.inserts.windows) : ''}${_fd?.inserts?.failed && _fd.note ? `\nTELL THE USER: ${_fd.note}` : ''}${_clampNote}${switchNote(r)}${_castLine}${Array.isArray(_fd?.boards) && _fd.boards.length ? `\nStoryboard: ${_fd.boards.map((u) => abs(u)).join(' , ')}${_fd.boardCreditsUsed ? ` (${_fd.boardCreditsUsed} credits of image paints)` : ''}` : ''}\n${notes || ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}), ...(appScreens ? { appScreens } : {}) });
|
|
22029
22977
|
}));
|
|
22030
22978
|
|
|
22031
22979
|
|
|
@@ -22191,7 +23139,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22191
23139
|
|
|
22192
23140
|
server.registerTool('post_edit', {
|
|
22193
23141
|
title: 'Post-production edit',
|
|
22194
|
-
description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg in seconds, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, music (a bed UNDER the clip, its own sound kept and ducked under: omit track = a free library track picked by mood, no attribution; or track = an audio link: an upload_file URL, a find_sound 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), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video, loudness matched). Presets are shortcuts: text/watermark take any x/y, grain any amount, join any ffmpeg transition, a bridge any sound link. '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 (found from the footage; 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. No voiceover bridge: have our clip's host say the connecting line. matchCut = where our clip starts. 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.",
|
|
23142
|
+
description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg in seconds, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, music (a bed UNDER the clip, its own sound kept and ducked under: 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), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video, loudness matched). Presets are shortcuts: text/watermark take any x/y, grain any amount, join any ffmpeg transition, a bridge any sound link. '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 (found from the footage; 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. No voiceover bridge: have our clip's host say the connecting line. matchCut = where our clip starts. 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.",
|
|
22195
23143
|
inputSchema: {
|
|
22196
23144
|
videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
|
|
22197
23145
|
ops: z.array(z.object({
|
|
@@ -22306,7 +23254,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22306
23254
|
reframe: REFRAME_OPT.optional().describe("every cut: 'auto' default"),
|
|
22307
23255
|
},
|
|
22308
23256
|
outputSchema: { ...JOB_OUT },
|
|
22309
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
23257
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22310
23258
|
}, wrap(async (a) => {
|
|
22311
23259
|
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}), ...(a.reframe != null ? { reframe: a.reframe } : {}) };
|
|
22312
23260
|
const r = await renderJob('postedit', { ...(a.videoUrl ? { videoUrl: a.videoUrl } : {}), ops: [op] }, 'MCP timeline');
|
|
@@ -22353,12 +23301,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22353
23301
|
|
|
22354
23302
|
server.registerTool('fix_beat', {
|
|
22355
23303
|
title: 'Fix a video beat',
|
|
22356
|
-
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.",
|
|
22357
|
-
inputSchema: {
|
|
22358
|
-
videoUrl: z.string().describe('the served URL of the master video to fix'),
|
|
22359
|
-
startSeconds: z.number().describe('window start in seconds'),
|
|
22360
|
-
endSeconds: z.number().describe('window end in seconds (window 1.5-8s)'),
|
|
22361
|
-
prompt: z.string().describe('what the replacement footage should show — describe the shot, matching the master\'s style'),
|
|
23304
|
+
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.",
|
|
23305
|
+
inputSchema: {
|
|
23306
|
+
videoUrl: z.string().optional().describe('the served URL of the master video to fix (not needed with `job`)'),
|
|
23307
|
+
startSeconds: z.number().optional().describe('window start in seconds (not needed with `job`)'),
|
|
23308
|
+
endSeconds: z.number().optional().describe('window end in seconds (window 1.5-8s; not needed with `job`)'),
|
|
23309
|
+
prompt: z.string().optional().describe('what the replacement footage should show — describe the shot, matching the master\'s style. With `job`: the change to that block\'s picture, in words'),
|
|
23310
|
+
job: z.string().optional().describe('a finished video-blocks film (explainer / faceless channel): its job id. Edits one block of it instead of splicing a window'),
|
|
23311
|
+
block: z.number().optional().describe('with `job`: which block, 1-based (block 3 = 20-30s of a film of 10s blocks)'),
|
|
23312
|
+
atSeconds: z.number().optional().describe('with `job`: the moment in the film instead of `block` ("the line at 0:12" = 12)'),
|
|
23313
|
+
line: z.string().optional().describe('with `job`: the new narration for that block, the exact words to say (said as written, never rewritten; refused for free when it cannot fit the block)'),
|
|
22362
23314
|
refImage: z.string().optional().describe('optional product/style anchor image URL'),
|
|
22363
23315
|
speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
|
|
22364
23316
|
dryRun: z.boolean().optional().describe('true = return the estimated credits this fix reserves and render nothing'),
|
|
@@ -22366,6 +23318,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22366
23318
|
outputSchema: { ...JOB_OUT },
|
|
22367
23319
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22368
23320
|
}, wrap(async (a) => {
|
|
23321
|
+
if (a.job) { // ONE BLOCK of a finished video-blocks film (2026-11-03): the server reads the film's own ledger
|
|
23322
|
+
if (a.block == null && a.atSeconds == null) throw new Error('Pass `block` (1-based) or `atSeconds` to say which block of that film to redo.');
|
|
23323
|
+
if (!a.prompt && !a.line) throw new Error('Say what to change: `prompt` for a different shot, `line` for new narration words, or both.');
|
|
23324
|
+
const _bin = { job: String(a.job).trim(), block: a.block, atSeconds: a.atSeconds, prompt: a.prompt, line: a.line };
|
|
23325
|
+
const what = `Redoing ${a.block != null ? `block ${a.block}` : `the block at ${a.atSeconds}s`} of ${_bin.job} (${[a.prompt ? 'a new shot' : '', a.line ? 'a new line' : ''].filter(Boolean).join(' and ')}; every other block, the music and the captions stay)`;
|
|
23326
|
+
if (a.dryRun) { const q = await quoteJob('fixbeat', _bin); return ok(quoteText(q, what).replace(PRICE_ESTIMATE_LINE, 'This is the exact charge: a block redo is charged what it quotes.'), { raw: q }); }
|
|
23327
|
+
const r = await renderJob('fixbeat', _bin, 'MCP block redo');
|
|
23328
|
+
const d = r?.raw || {};
|
|
23329
|
+
return okVideo(`Block ${d.block ?? '?'} redone (${(d.changed || []).join(' and ')}), the rest of the film kept: ${r.url} [job ${r.jobId}] — a new film; ${_bin.job} is unchanged. Edit it again with fix_beat {job:"${r.jobId}"}.${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}`, r);
|
|
23330
|
+
}
|
|
23331
|
+
if (a.videoUrl == null || a.startSeconds == null || a.endSeconds == null || !a.prompt) throw new Error('Pass videoUrl, startSeconds, endSeconds and prompt (or `job` and `block` for one block of a finished video-blocks film).');
|
|
22369
23332
|
const _in = { videoUrl: a.videoUrl, startSeconds: a.startSeconds, endSeconds: a.endSeconds, prompt: a.prompt, refImage: a.refImage, speechWindows: a.speechWindows };
|
|
22370
23333
|
if (a.dryRun) { const q = await quoteJob('fixbeat', _in); return ok(quoteText(q, `Re-rendering the ${a.startSeconds}-${a.endSeconds}s beat (only that window; the rest and all the audio stay)`), { raw: q }); }
|
|
22371
23334
|
const r = await renderJob('fixbeat', _in, 'MCP fix beat');
|
|
@@ -22440,11 +23403,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22440
23403
|
|
|
22441
23404
|
server.registerTool('make_explainer', {
|
|
22442
23405
|
title: 'Make an explainer video',
|
|
22443
|
-
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 — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE — a slow move on a still shimmers), then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds
|
|
23406
|
+
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 — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE — a slow move on a still shimmers), then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds subtitles timed to the narration (see both). The stills lane is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
|
|
22444
23407
|
inputSchema: {
|
|
22445
23408
|
topic: z.string().optional().describe('what the explainer should teach or explain — a topic or a short brief (host_episode: or pass `script`)'),
|
|
22446
23409
|
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."),
|
|
22447
|
-
format: z.enum(['explainer', 'faceless_channel', 'host_episode']).optional().describe("blocks lane: 'explainer' (default; Gemini Omni 720p, 16:9 or 9:16, runs exactly the length asked) or 'faceless_channel' (a YouTube/TikTok faceless channel video; MiniMax H3 at 2K, 16:9 by default, five hard cuts per 10s block, a whole
|
|
23410
|
+
format: z.enum(['explainer', 'faceless_channel', 'host_episode']).optional().describe("blocks lane: 'explainer' (default; Gemini Omni 720p, 16:9 or 9:16, runs exactly the length asked) or 'faceless_channel' (a YouTube/TikTok faceless channel video; MiniMax H3 at 2K, 16:9 by default, five hard cuts per 10s block, runs exactly the length asked: a last part-block is rendered whole and cut). Omit and a history / kids / fairytale channel is a faceless channel video. 'host_episode' = the AI host episode (see the top)."),
|
|
22448
23411
|
creator: z.string().optional().describe('host_episode: the host, a saved creator or a preset by name or id (list_creators)'),
|
|
22449
23412
|
hostImage: z.string().optional().describe("host_episode: a photo URL of the host instead (upload_file for a local file); a real person's face needs a paid plan"),
|
|
22450
23413
|
script: z.string().optional().describe('host_episode: the exact words, said verbatim and split at natural breaks into 4-30s pieces'),
|
|
@@ -22458,17 +23421,35 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22458
23421
|
style: z.string().optional().describe("visual style: 'cinematic' (default, photoreal); styled shortcuts editorial_collage, flat_vector, stickman, whiteboard, ink_marker, silhouette, storybook, paper_diorama, isometric, claymation, pixel_art, watercolor, fluffy_toy, low_poly, stylized_3d, studio_3d (the Kids default), mannequin; or ANY look described in words ('80s anime cel animation'), locked across every frame. Ask rather than pick silently; a styled look costs more."),
|
|
22459
23422
|
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'."),
|
|
22460
23423
|
voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
|
|
22461
|
-
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 CAPS, thin black outline, bottom safe band, no plate, no box.'),
|
|
22462
|
-
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;
|
|
23424
|
+
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.'),
|
|
23425
|
+
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, a tiny charge 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.'),
|
|
22463
23426
|
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."),
|
|
22464
23427
|
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."),
|
|
22465
23428
|
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
|
|
22466
23429
|
brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
|
|
22467
23430
|
dryRun: z.boolean().optional().describe('true = return the estimated credits this explainer reserves (its own pricing, stopped at the hold) and render nothing. Quote it before running one; try frameDensity lean or minimal when the balance is short.'),
|
|
23431
|
+
fromFilm: z.string().optional().describe("A PRESENTER ON A FINISHED FILM (only when the user asks for one): the job id of a finished video-blocks explainer or faceless-channel film. With `presenter`, that film comes back with a presenter speaking each block's line on screen; every block keeps its own picture and the film without the presenter is kept (cleanVideo). Never add a presenter unasked."),
|
|
23432
|
+
presenter: z.object({
|
|
23433
|
+
creator: z.string().optional().describe('a saved creator or a preset, by name or id (list_creators)'),
|
|
23434
|
+
image: z.string().optional().describe("or a photo / avatar of the presenter (upload_file for a local file); a real person's face follows the real-face rules"),
|
|
23435
|
+
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"),
|
|
23436
|
+
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"),
|
|
23437
|
+
scale: z.number().optional().describe('the share of the frame the presenter takes, 0.2-0.8 (default: the look\'s own size)'),
|
|
23438
|
+
voice: z.string().optional().describe("the presenter's voice (default: the film's own narrator)"),
|
|
23439
|
+
}).optional().describe('with fromFilm: who presents and how. A face the video model flags stops it, nothing charged, with the choices to offer.'),
|
|
22468
23440
|
},
|
|
22469
23441
|
outputSchema: { ...JOB_OUT },
|
|
22470
23442
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22471
23443
|
}, wrap(async (a) => {
|
|
23444
|
+
if (a.fromFilm || a.presenter) { // A PRESENTER ON A FINISHED FILM (2026-11-03, lib/block-presenter.mjs): opt-in, the user's person
|
|
23445
|
+
if (!a.fromFilm) throw new Error('A presenter is added to a FINISHED film: make the explainer first, then call again with fromFilm (its job id) and presenter.');
|
|
23446
|
+
if (!a.presenter || (!a.presenter.creator && !a.presenter.image)) throw new Error('Say who presents it: presenter.creator (a saved or preset creator) or presenter.image (a photo).');
|
|
23447
|
+
const _pin = { fromFilm: String(a.fromFilm).trim(), presenter: a.presenter };
|
|
23448
|
+
if (a.dryRun) { const q = await quoteJob('explainer', _pin); return ok(quoteText(q, `Adding a presenter (${a.presenter.look || 'corner'}) to ${_pin.fromFilm}: one talking clip per block, the rest of the film kept`), { raw: q }); }
|
|
23449
|
+
const r = await renderJob('explainer', _pin, 'MCP presenter');
|
|
23450
|
+
const d = r?.raw || {};
|
|
23451
|
+
return okVideo(`Presenter added (${d.presenter?.look || a.presenter.look || 'corner'}, on block${(d.presenter?.blocks || []).length > 1 ? 's' : ''} ${(d.presenter?.blocks || []).join(', ')}): ${r.url} [job ${r.jobId}]. The film without the presenter is kept: ${d.cleanVideo || '—'}${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}`, r);
|
|
23452
|
+
}
|
|
22472
23453
|
// THE AI HOST EPISODE: the same job type, its own fields, its own read-back (the host and draft are resolved server-side).
|
|
22473
23454
|
const _host = a.format === 'host_episode' || !!a.fromDraft;
|
|
22474
23455
|
const _hostIn = { format: 'host_episode', topic: a.topic, script: a.script, creator: a.creator, hostImage: a.hostImage, fromDraft: a.fromDraft, setting: a.setting, cameras: a.cameras, thumbnail: a.thumbnail, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, music: a.music, captions: a.captions };
|
|
@@ -22490,7 +23471,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22490
23471
|
const fin = `${d.music ? ` · ${d.music.source === 'composed' ? 'composed' : 'library'} ${d.music.mood} music bed` : ''}${d.upscaled ? ` · upscaled ${d.upscaled}×` : ''}`;
|
|
22491
23472
|
// The length is whatever ffprobe measured on the DELIVERED file, and `null` means it could not be measured — print
|
|
22492
23473
|
// the `—` placeholder rather than inventing a number from the ask (the read-back law: only a measurement ships).
|
|
22493
|
-
if (d.lane === 'blocks') return okVideo(`Explainer ready — ${d.blocks} video blocks of ${d.blockSeconds}s on ${d.videoModel === 'omni' ? 'Gemini Omni 720p' : 'MiniMax H3 2K'}${d.cutsPerBlock ? `, ${d.cutsPerBlock} cuts each` : ''}, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}${fin}: ${r.url} [job ${r.jobId}]${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}`, r);
|
|
23474
|
+
if (d.lane === 'blocks') return okVideo(`Explainer ready — ${d.blocks} video blocks of ${d.blockSeconds}s on ${d.videoModel === 'omni' ? 'Gemini Omni 720p' : 'MiniMax H3 2K'}${d.cutsPerBlock ? `, ${d.cutsPerBlock} cuts each` : ''}, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}${fin}: ${r.url} [job ${r.jobId}]${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}${d.editable ? `\nOne block can be redone later without re-making the film: fix_beat {job:"${r.jobId}", block:N, prompt (a different shot) and/or line (new words)}.` : ''}`, r);
|
|
22494
23475
|
return okVideo(`Explainer ready${d.sections ? ` — ${d.sections} sections, ${d.frames || d.sections} frames, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}` : ''}${d.lengthNote ? ` (${d.lengthNote})` : ''}${d.style ? ` in the ${String(d.style).replace(/_/g, ' ')} style${d.styleLocked ? '' : ' (style key unavailable — the look rides on the prompt only)'}` : ''}${fin}: ${r.url} [job ${r.jobId}]${d.musicNote ? `\n${d.musicNote}` : ''}${d.captionNote ? `\n⚠ ${d.captionNote}` : ''}${d.upscaleNote ? `\n⚠ ${d.upscaleNote}` : ''}`, r);
|
|
22495
23476
|
}));
|
|
22496
23477
|
|
|
@@ -22498,6 +23479,29 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22498
23479
|
// A content factory, in shape: reference + "make a video about…" + length + episodes. Our renderer is
|
|
22499
23480
|
// the faceless explainer job, one per episode; the planning (watch the reference once, write N distinct episodes and a
|
|
22500
23481
|
// style card) is POST /api/series/plan. A series multiplies cost, so it is QUOTED FIRST and runs only on confirm:true.
|
|
23482
|
+
// A FILM THAT STOPPED PARTWAY IS RESUMED, NOT RE-MADE (2026-11-03, lib/block-ledger.mjs).
|
|
23483
|
+
// A video-blocks explainer / faceless-channel film keeps a ledger of what it made (script, style key, narration, every
|
|
23484
|
+
// block's clip). When one stops — a block the model would not render, a restart, a deploy — this renders only what is
|
|
23485
|
+
// missing and re-assembles the SAME film. The server replays the film's own request; the caller passes only the id.
|
|
23486
|
+
server.registerTool('resume_job', {
|
|
23487
|
+
title: 'Resume a stopped film',
|
|
23488
|
+
description: "Finish a video-blocks explainer or faceless-channel film (make_explainer) that STOPPED partway: a block the video model would not render, or a server restart. What it already made is kept (the script, the style key, the narration and every block that rendered), so this renders ONLY the missing blocks and re-assembles the same film, with the same topic, look, voice, length and options. It reserves credits only for what is missing and never charges again for what was kept. get_job and list_jobs show which jobs can be resumed and which blocks are kept. Quote it with dryRun first and tell the user the number. A finished film is not resumed: to redo one block of a finished film, use fix_beat with its job id.",
|
|
23489
|
+
inputSchema: {
|
|
23490
|
+
jobId: z.string().describe('the id of the film that stopped (job_...), as get_job or list_jobs shows it'),
|
|
23491
|
+
dryRun: z.boolean().optional().describe('true = return the credits the resume reserves and render nothing'),
|
|
23492
|
+
},
|
|
23493
|
+
outputSchema: { ...JOB_OUT },
|
|
23494
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
23495
|
+
}, wrap(async (a) => {
|
|
23496
|
+
const id = String(a.jobId || '').trim();
|
|
23497
|
+
if (!id) throw new Error('Pass the jobId of the film that stopped (get_job / list_jobs show it).');
|
|
23498
|
+
if (a.dryRun) { const q = await quoteJob('explainer', { resumeFrom: id }); return ok(quoteText(q, `Resuming ${id} (only its missing blocks, then the same film re-assembled)`), { raw: q }); }
|
|
23499
|
+
const r = await renderJob('explainer', { resumeFrom: id }, 'MCP resume');
|
|
23500
|
+
const d = r?.raw || {};
|
|
23501
|
+
const kept = d.reused?.blocks?.length ? `kept block${d.reused.blocks.length > 1 ? 's' : ''} ${d.reused.blocks.join(', ')} without rendering ${d.reused.blocks.length > 1 ? 'them' : 'it'} again, ` : '';
|
|
23502
|
+
return okVideo(`Film resumed and finished: ${kept}rendered the rest, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}: ${r.url} [job ${r.jobId}]${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}`, r);
|
|
23503
|
+
}));
|
|
23504
|
+
|
|
22501
23505
|
const seriesEpisodeInput = (topic, plan, ep, n, a, vo) => {
|
|
22502
23506
|
const s = plan.style || {};
|
|
22503
23507
|
const how = [s.hookDevice ? `Open the way the reference opens: ${s.hookDevice}` : '', s.pacing ? `Pacing: ${s.pacing}` : '', s.narration ? `Narration: ${s.narration}` : ''].filter(Boolean).join('. ');
|
|
@@ -22647,12 +23651,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22647
23651
|
|
|
22648
23652
|
server.registerTool('product_sizzle', {
|
|
22649
23653
|
title: 'Product sizzle (music-led)',
|
|
22650
|
-
description: "Render an 18-30s music-led PRODUCT SIZZLE: ONE 15s Seedance 2.
|
|
23654
|
+
description: "Render an 18-30s music-led PRODUCT SIZZLE: ONE 15s Seedance 2.5 hero clip of the product, diced into fast cuts and intercut with typeset spec/CTA cards on a brand-coloured grain background, mixed to a music bed. Faceless by design — no people, no voiceover, no spoken lines; the cards carry every word, so nothing is left to a video model's spelling. Pass a real packshot as refImage or the label will not be yours. EXPENSIVE — the hero clip is the only paid leg and it is a full 15s Seedance 2.5 render: ≈870 credits at the DEFAULT 1080p, ≈360 at 720p, ≈170 at 480p; a 4k ask renders on Seedance 2.0, the only Seedance tier with 4k, ≈2,360 (call hermoso_capabilities for the live seedance-2.5 per-duration numbers; the dicing and the cards are free, and the music bed is included in these figures). Confirm the spend with the user before calling. For a talking/UGC ad use render_ad or generate_avatar; for a cheap deterministic format use make_template_ad.",
|
|
22651
23655
|
inputSchema: {
|
|
22652
23656
|
prompt: z.string().describe('what the sizzle should show — the product, the setting, the look'),
|
|
22653
23657
|
seconds: z.number().optional().describe('finished length, clamped to 18-30s (default 25). The PAID hero render is always 15s regardless — this only changes how the cuts and cards are packed'),
|
|
22654
23658
|
refImage: z.string().optional().describe('product packshot URL that anchors the real label — strongly recommended'),
|
|
22655
|
-
aspectRatio: z.string().optional().describe("'9:16' default; anything the
|
|
23659
|
+
aspectRatio: z.string().optional().describe("'9:16' default; anything the Seedance row that renders it does not list falls back to 9:16"),
|
|
22656
23660
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("hero-clip resolution and therefore the whole cost — DEFAULT '1080p' (≈1,040 credits); '720p' ≈470, '480p' ≈220, '4k' ≈4,130"),
|
|
22657
23661
|
specs: z.array(z.string()).optional().describe('up to 4 spec lines for the typeset cards, ≤26 chars each'),
|
|
22658
23662
|
cta: z.string().optional().describe('closing CTA line, ≤30 chars'),
|
|
@@ -22696,7 +23700,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22696
23700
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
22697
23701
|
model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick; its default per job (jobEngines in hermoso_capabilities, from watched side-by-side tests) is seedance-2.5 for product, lifestyle, a person talking to camera and 30s single takes (cheaper engines broke the product there). A model the user picks is kept; never switch it'),
|
|
22698
23702
|
faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_DESC),
|
|
22699
|
-
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("
|
|
23703
|
+
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("Omit = the default tier, hermoso_capabilities defaultResolution (720p), the tier its `credits` prices are for. 1080p is ~2.25x, 4k more, 480p less: quote creditsByRes and pass the tier you quoted. A tier a model's `resolutions` lacks renders at its best one."),
|
|
22700
23704
|
cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage (orbit = a quarter turn, the default; orbit_half / orbit_full = a half turn / a turntable). Only minimax-h3-max-camera renders one: with model omitted it is picked; any other named model is refused, nothing charged. Needs refImage.'),
|
|
22701
23705
|
cameraTrajectory: z.array(z.object({
|
|
22702
23706
|
time: z.number().min(0).max(1).describe('when this pose is reached, 0 = start of the clip, 1 = end'),
|
|
@@ -22892,7 +23896,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22892
23896
|
// long render is received, so every disclosure the direct reply carries has to be here too — the model
|
|
22893
23897
|
// substitution (`switchNote`) explicitly, the vision-QA verdict via `okVideo`'s own `qaLine`. Both resolve the
|
|
22894
23898
|
// payload through `renderPayload`, which is why handing them the JOB works at all.
|
|
22895
|
-
const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${(j.status === 'queued' || j.status === 'running') && j.queue?.line ? ` — ${j.queue.line}` : ''}${url ? ` → ${url}` : ''}${channelOutcomeLine(res)}${j.error ? ` — ${j.error}` : ''}${j.status === 'done' ? switchNote(j) : ''}`;
|
|
23899
|
+
const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${(j.status === 'queued' || j.status === 'running') && j.queue?.line ? ` — ${j.queue.line}` : ''}${url ? ` → ${url}` : ''}${channelOutcomeLine(res)}${j.error ? ` — ${j.error}` : ''}${j.status === 'done' ? switchNote(j) : ''}${resumeLine(j)}`;
|
|
22896
23900
|
// THE TEXT BLOCK MUST CARRY THE JOB, NOT JUST structuredContent (2026-08-24). ChatGPT hands a widget the
|
|
22897
23901
|
// content array WITHOUT structuredContent, so the render card — which polls this tool over the host bridge —
|
|
22898
23902
|
// received prose it could not read: 219 polls over 9 minutes, every one HTTP 200, against a job that finished
|
|
@@ -22976,7 +23980,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22976
23980
|
} catch {}
|
|
22977
23981
|
const d = await apiGet('/api/skills').catch(() => ({ skills: [] }));
|
|
22978
23982
|
let custom = await readStore('heist.skills.v1').catch((e) => { if (e?._signedOut) return []; throw e; }); if (!Array.isArray(custom)) custom = []; // the workspace's OWN skills (built-ins alone came from /api/skills); signed out, the built-ins still list
|
|
22979
|
-
const inApp = (d.skills || []).map(s => `${s.id} (${s.kind || s.group})`).join(', ');
|
|
23983
|
+
const inApp = (d.skills || []).map(s => `${s.id} (${s.kind || s.group})${s.askFirst ? ` [ask the user first: ${s.askFirst}]` : ''}`).join(', '); // askFirst (2026-11-03): character_sheet's unnamed outfit is asked, never guessed
|
|
22980
23984
|
const customLine = custom.map(s => `- ${s.name} (${s.id})`).join('\n');
|
|
22981
23985
|
const text = `Skill bundles (call get_skill with the name):\n${bundles.map(b => `- ${b.name}: ${b.description}`).join('\n') || '(none bundled)'}\n\nIn-app strategy skills + creative recipes (pass as plan_ad's recipe / create's skill): ${inApp}\n\nYour custom skills (save_skill / delete_skill):\n${customLine || '(none yet)'}`;
|
|
22982
23986
|
return ok(text, { bundles, inApp: d.skills || [], custom });
|
|
@@ -22984,7 +23988,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22984
23988
|
|
|
22985
23989
|
server.registerTool('get_skill', {
|
|
22986
23990
|
title: 'Get skill',
|
|
22987
|
-
description: 'Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.',
|
|
23991
|
+
description: 'Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills), or a built-in in-app skill by its id (e.g. youtube_script, retention_skeleton). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.',
|
|
22988
23992
|
inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
|
|
22989
23993
|
outputSchema: {
|
|
22990
23994
|
name: z.string().optional().describe('the loaded skill bundle name'),
|
|
@@ -22995,6 +23999,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22995
23999
|
const { readFile } = await import('node:fs/promises');
|
|
22996
24000
|
const tryRead = (n) => readFile(new URL(`../skills/${n}/SKILL.md`, import.meta.url), 'utf8').catch(() => null);
|
|
22997
24001
|
const md = await tryRead(safe) || await tryRead(safe.replace(/^hermoso-/, 'heist-')) || await tryRead(safe.replace(/^heist-/, 'hermoso-')); // bundle dirs may carry the legacy prefix
|
|
24002
|
+
// A BUILT-IN IN-APP SKILL BY ID (2026-11-03): the workflow skills (youtube_script, retention_skeleton, local_seo_audit…)
|
|
24003
|
+
// live in the app's Skills library, so every surface (the CLI too, whose package ships few bundles) loads them here.
|
|
24004
|
+
if (!md) {
|
|
24005
|
+
const want = String(name || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
24006
|
+
const d = await apiGet('/api/skills').catch(() => ({ skills: [] }));
|
|
24007
|
+
const sk = (d.skills || []).find((x) => x && x.builtin && x.kind === 'strategy' && String(x.id) === want);
|
|
24008
|
+
if (sk && sk.directive) return ok(`# ${sk.label} (built-in skill ${sk.id})\n\n${sk.desc ? sk.desc + '\n\n' : ''}${sk.directive}`, { name: sk.id });
|
|
24009
|
+
}
|
|
22998
24010
|
if (!md) return { content: [{ type: 'text', text: `No skill bundle named "${safe}" — call list_skills for the catalog.` }], isError: true };
|
|
22999
24011
|
return ok(brandSkillText(md.slice(0, 24000)), { name: safe });
|
|
23000
24012
|
}));
|
|
@@ -23105,7 +24117,7 @@ function memoryNoteVerdict(text) {
|
|
|
23105
24117
|
// list and adapters/sync-merge.js union-merges its collections by `id` and its ads by `key`, so the ♥ on every web ad
|
|
23106
24118
|
// card and the Studio chat's create_collection/save_to_collection write the SAME rows these read and write. Without
|
|
23107
24119
|
// them a headless caller could research all day and had nowhere to PUT what it found — the ads it surfaced lived and
|
|
23108
|
-
//
|
|
24120
|
+
// ended inside one conversation, while the web user's own board sat untouched beside them.
|
|
23109
24121
|
// These are typed writers on purpose (see the store_set note above): they mint ids, de-dupe on the ad `key`, respect
|
|
23110
24122
|
// the per-store caps and write the two-level tombstone a genuine delete needs.
|
|
23111
24123
|
server.registerTool('tidy_memory', {
|
|
@@ -23648,7 +24660,7 @@ function memoryNoteVerdict(text) {
|
|
|
23648
24660
|
// channel (each was its own OAuth run). There is nothing to "share" or tick — every connected account is usable —
|
|
23649
24661
|
// so `write` is null and set_connector_accounts refuses by name; add one by connecting again in the app, remove one
|
|
23650
24662
|
// with disconnect_connector({provider, account}). The listing is what post_to_* and schedule_post's `account(s)` take.
|
|
23651
|
-
...Object.fromEntries(['tiktok', 'x', 'youtube', 'threads', 'bluesky', 'telegram', '
|
|
24663
|
+
...Object.fromEntries(['tiktok', 'x', 'youtube', 'threads', 'bluesky', 'telegram', 'pinterest', 'instagram'].map((p) => [p, {
|
|
23652
24664
|
label: p === 'x' ? 'X' : p === 'youtube' ? 'YouTube' : p === 'tiktok' ? 'TikTok' : p.charAt(0).toUpperCase() + p.slice(1), read: `/api/connectors/${p}/accounts`, multiAccount: true, // instagram: Page-linked AND Instagram Login accounts, one list (2026-09-02)
|
|
23653
24665
|
identities: (d) => (d.accounts || []).map(a => ({ id: String(a.id), name: a.handle ? '@' + a.handle : (a.label || a.id), type: 'account', selected: true, detail: [a.label && a.handle && a.label !== a.handle ? a.label : '', a.live === false ? 'RECONNECT NEEDED' : '', d.primaryAccount === a.id ? 'primary' : ''].filter(Boolean).join(' · ') })),
|
|
23654
24666
|
write: null,
|
|
@@ -23861,11 +24873,11 @@ function memoryNoteVerdict(text) {
|
|
|
23861
24873
|
}));
|
|
23862
24874
|
server.registerTool('disconnect_connector', {
|
|
23863
24875
|
title: 'Disconnect a connected account',
|
|
23864
|
-
description: 'Disconnect a third-party account from this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, TikTok, LinkedIn, X, Reddit, Pinterest, Google Business, Microsoft Advertising/OneDrive, Slack, …). This always drops the stored credentials, so every tool for that provider stops working immediately and posts/campaigns already published are NOT affected. IT NEVER REVOKES THE GRANT AT THE PROVIDER: the authorisation stays in place, so the same account keeps working in any other brand it is connected to, until the user removes Hermoso in that provider\'s own settings. The unconfirmed call names where to do that (list_connectors carries revokesAtProvider:"local") — relay it rather than promising a revoke. RECONNECTING NEEDS A BROWSER (the provider\'s consent screen) — an agent cannot undo this. Name the provider to the user, then call with confirm:true. Use list_connectors for the exact provider ids.',
|
|
24876
|
+
description: 'Disconnect a third-party account from this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, TikTok, LinkedIn, X, Reddit Ads, Pinterest, Google Business, Microsoft Advertising/OneDrive, Slack, …). This always drops the stored credentials, so every tool for that provider stops working immediately and posts/campaigns already published are NOT affected. IT NEVER REVOKES THE GRANT AT THE PROVIDER: the authorisation stays in place, so the same account keeps working in any other brand it is connected to, until the user removes Hermoso in that provider\'s own settings. The unconfirmed call names where to do that (list_connectors carries revokesAtProvider:"local") — relay it rather than promising a revoke. RECONNECTING NEEDS A BROWSER (the provider\'s consent screen) — an agent cannot undo this. Name the provider to the user, then call with confirm:true. Use list_connectors for the exact provider ids.',
|
|
23865
24877
|
inputSchema: {
|
|
23866
24878
|
provider: z.string().describe('provider id exactly as list_connectors reports it, e.g. "meta", "google_ads", "youtube", "linkedin"'),
|
|
23867
24879
|
confirm: z.boolean().optional().describe('REQUIRED true — reconnecting a sign-in account afterwards needs the user\'s browser; a paste-a-key account is reconnected with connect_connector'),
|
|
23868
|
-
account: z.string().optional().describe('on a channel with several connected accounts (TikTok, X, YouTube, Threads, Bluesky, Telegram,
|
|
24880
|
+
account: z.string().optional().describe('on a channel with several connected accounts (TikTok, X, YouTube, Threads, Bluesky, Telegram, Pinterest): remove ONLY this account (@handle or id from list_connector_accounts) and keep the others'),
|
|
23869
24881
|
},
|
|
23870
24882
|
outputSchema: { ok: z.boolean().optional(), provider: z.string().optional(), disconnected: z.boolean().optional(), revokesAtProvider: z.string().nullable().optional() },
|
|
23871
24883
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
@@ -23929,6 +24941,13 @@ function memoryNoteVerdict(text) {
|
|
|
23929
24941
|
return refuse(`${(target && target.label) || p} connects through its own sign-in screen (OAuth), which needs a browser, so it cannot be connected from a chat and nothing was sent. Hand your human this one-click link: ${url} (it opens Hermoso${url.includes('&brand=') ? ' on this brand' : ''}, signs them in if needed, and starts the connection), then retry.`);
|
|
23930
24942
|
}
|
|
23931
24943
|
const given = (a.fields && typeof a.fields === 'object' && !Array.isArray(a.fields)) ? a.fields : {};
|
|
24944
|
+
// THE SLACK + DISCORD APPS (2026-11-03): no fields = install the app. Installing is a consent screen in the user's own
|
|
24945
|
+
// browser (Slack's / Discord's), so the agent hands over the one link that opens Hermoso and starts it there.
|
|
24946
|
+
if ((provider === 'slack' || provider === 'discord') && !Object.keys(given).length) {
|
|
24947
|
+
let url = `https://app.hermoso.ai/?connect=${provider}`;
|
|
24948
|
+
try { const tpl = (await apiGet('/api/connectors')).connectLink; if (typeof tpl === 'string' && tpl.includes('{provider}')) url = tpl.replace('{provider}', provider); } catch {}
|
|
24949
|
+
return ok(`Adding the Hermoso ${spec.label} app needs ${spec.label}'s own permission screen, in your human's browser. Hand them this one-click link: ${url} — it opens Hermoso${url.includes('&brand=') ? ' on this brand' : ''} and starts "Add to ${spec.label}"${provider === 'discord' ? ' (they pick the server in Discord\u2019s own picker)' : ''}. Then call list_${provider}_channels and ask them which channel posts should go to (set_${provider}_channel).`, { ok: true, provider, connected: false, step: 'install', url });
|
|
24950
|
+
}
|
|
23932
24951
|
const unknown = Object.keys(given).filter(n => !Object.prototype.hasOwnProperty.call(spec.fields, n));
|
|
23933
24952
|
if (unknown.length) return refuse(`${spec.label} does not take ${unknown.map(n => `"${n}"`).join(', ')}, so nothing was sent. Its fields are ${keyConnectFieldList(spec)} (* = required): ${spec.how}.`);
|
|
23934
24953
|
const vals = {};
|
|
@@ -24073,13 +25092,13 @@ function memoryNoteVerdict(text) {
|
|
|
24073
25092
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
24074
25093
|
}, wrap(async () => {
|
|
24075
25094
|
const d = await apiGet('/api/jobs');
|
|
24076
|
-
const lines = (d.jobs || []).slice(0, 12).map(j => `${j.id} ${j.type} ${j.status}`).join('\n');
|
|
25095
|
+
const lines = (d.jobs || []).slice(0, 12).map(j => `${j.id} ${j.type} ${j.status}${j.status === 'error' && j.resume?.resumable ? ` (resumable: ${j.resume.kept?.length || 0} of ${j.resume.blocks} blocks kept, resume_job)` : ''}`).join('\n');
|
|
24077
25096
|
return ok(`${d.running} running. Recent:\n${lines}`, d);
|
|
24078
25097
|
}));
|
|
24079
25098
|
|
|
24080
25099
|
// ---------- error triage (read-only, free) ----------
|
|
24081
25100
|
server.group('core');
|
|
24082
|
-
// WHY AN AGENT NEEDS THESE.
|
|
25101
|
+
// WHY AN AGENT NEEDS THESE. A merchant who hits a broken experience should not have to tell us by email.
|
|
24083
25102
|
// These two make the question answerable without a browser — "what has been failing in my workspace?" — and, for
|
|
24084
25103
|
// an operator whose client is configured with the admin key, across the whole fleet.
|
|
24085
25104
|
server.registerTool('list_errors', {
|
|
@@ -24663,10 +25682,51 @@ function memoryNoteVerdict(text) {
|
|
|
24663
25682
|
for (const p of d.platforms) for (const t of (d.byPlatform?.[p] || [])) lines.push(`• [${p}] ${t.handle ? '@' + t.handle : ''}${t.plays ? ` ${fmt(t.plays)} views` : t.likes ? ` ${fmt(t.likes)} likes` : ''}: ${String(t.desc || '').slice(0, 120)}${t.link ? ` — ${t.link}` : ''}`);
|
|
24664
25683
|
return ok(`${d.note}\n${lines.join('\n')}`, d);
|
|
24665
25684
|
}));
|
|
25685
|
+
// TIKTOK IDEAS (2026-11-03): POST /api/tiktok/ideas, the same engine as the Studio's card. One bounded 30-day pull,
|
|
25686
|
+
// ideas each backed by linked posts, a price quoted first (dryRun) that is exactly the charge; `pick` hands one off
|
|
25687
|
+
// (free) with the claims the ad may make. Nothing renders here.
|
|
25688
|
+
server.registerTool('tiktok_ideas', {
|
|
25689
|
+
title: 'TikTok post ideas from what is working now',
|
|
25690
|
+
description: "What to post on TikTok, from posts that are working THIS MONTH in the brand's niche: up to 6 content ideas (hook, format, caption, why, an act-within window of 7d or 30d, a quality tier), EACH backed by linked TikTok posts with their views, likes and engagement. One bounded pull of the last 30 days: up to 4 keyword searches + up to 5 hashtag pulls, then up to 8 reads of what the strongest posts SAY in their first seconds; only posts past 50,000 views back an idea, and an idea the catalog cannot back is dropped. Works with nothing connected. PAID and priced first: dryRun:true returns the exact credits (lookups + writing) with nothing run; the run charges exactly that (the writing part is not charged if no post clears the bar). THE PICK: call again with pick (one idea object from `ideas`) and to ('render_ad' or 'make_series'), free: it returns the next call's arguments (plan_ad then render_ad, or make_series) with the CLAIMS the ad may make, taken only from the brand's own facts. Nothing renders until you make that call. Omit keywords/hashtags to derive them from product/category or the saved brand.",
|
|
25691
|
+
inputSchema: {
|
|
25692
|
+
product: z.string().optional().describe('the product (a name from list_products, or words); omit to use the saved brand'),
|
|
25693
|
+
category: z.string().optional().describe('the niche in words, e.g. "matcha", "running shoes"'),
|
|
25694
|
+
market: z.string().optional().describe('2-letter country the posts are read from (default US)'),
|
|
25695
|
+
goal: z.string().optional().describe('what the posts should do: sales, followers, awareness…'),
|
|
25696
|
+
keywords: z.array(z.string()).max(4).optional().describe('your own search words (up to 4), instead of the derived ones'),
|
|
25697
|
+
hashtags: z.array(z.string()).max(5).optional().describe('your own hashtags (up to 5, # optional)'),
|
|
25698
|
+
reads: z.number().int().min(0).max(8).optional().describe('how many of the strongest posts to read for their spoken hook, 0-8 (default 4)'),
|
|
25699
|
+
dryRun: z.boolean().optional().describe('true = price only, nothing looked up or charged'),
|
|
25700
|
+
pick: z.object({}).passthrough().optional().describe('THE PICK: one idea object exactly as this tool returned it in `ideas`'),
|
|
25701
|
+
to: z.enum(['render_ad', 'make_series']).optional().describe("where the pick goes: render_ad (plan_ad then render_ad, the default) or make_series"),
|
|
25702
|
+
claims: z.array(z.string()).optional().describe('with pick: extra claims the USER stated about their product (added to the brand facts)'),
|
|
25703
|
+
brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile'),
|
|
25704
|
+
},
|
|
25705
|
+
outputSchema: {
|
|
25706
|
+
niche: z.string().optional(), market: z.string().optional(), keywords: z.array(z.string()).optional(), hashtags: z.array(z.string()).optional(),
|
|
25707
|
+
ideas: z.array(z.any()).optional().describe('[{id, title, hook, format, caption, why, window, quality, seconds, evidence:[{url, handle, plays, likes, comments, shares, er, viewsPerDay, ageDays, tier}]}]'),
|
|
25708
|
+
catalog: z.array(z.any()).optional().describe('the proven posts the ideas were written from, strongest first, with links and numbers'),
|
|
25709
|
+
quote: z.any().optional().describe('{lookups, lookupCredits, writeCredits, credits}'),
|
|
25710
|
+
credits: z.number().optional().describe('dryRun: the exact price; a run: what it charged'),
|
|
25711
|
+
handoff: z.any().optional().describe('pick only: {tool, then?, args, brief, claims}'),
|
|
25712
|
+
claims: z.array(z.string()).optional().describe('pick only: the claims the ad may make'),
|
|
25713
|
+
dryRun: z.boolean().optional(), note: z.string().optional(),
|
|
25714
|
+
},
|
|
25715
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
25716
|
+
}, wrap(async ({ brandId, ...a }) => {
|
|
25717
|
+
if (brandId) await activeBrand(brandId);
|
|
25718
|
+
const d = await apiPost('/api/tiktok/ideas', a);
|
|
25719
|
+
if (d?.picked) return ok(`${d.note}\nNext: ${d.handoff.tool}(${JSON.stringify(d.handoff.args)})${d.handoff.then ? ` → ${d.handoff.then}` : ''}\nClaims the ad may make: ${(d.claims || []).length ? d.claims.map((c, i) => `${i + 1}) ${c}`).join(' ') : 'none on file (name and show the product only)'}`, d);
|
|
25720
|
+
if (d?.dryRun) return ok(`TIKTOK IDEAS PRICE (nothing looked up, nothing charged): ${d.credits} credits.\n${d.note}\nKeywords: ${(d.keywords || []).join(', ') || '—'} · hashtags: ${(d.hashtags || []).map((t) => '#' + t).join(' ') || '—'} · market ${d.market}. Call again without dryRun to run it.`, d);
|
|
25721
|
+
const fmt = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}K` : String(n || 0));
|
|
25722
|
+
const lines = (d?.ideas || []).map((it, i) => `${i + 1}. ${it.title} [${it.format} · act within ${it.window} · ${it.quality}]\n Hook: "${it.hook}"\n Caption: ${it.caption}\n Why: ${it.why}\n Backed by: ${(it.evidence || []).map((e) => `${e.url} (${fmt(e.plays)} views, ${Math.round((e.er || 0) * 1000) / 10}% eng, ${e.ageDays}d)`).join('; ')}`);
|
|
25723
|
+
return ok(`${d?.note || ''}\n\n${lines.join('\n')}\n\nTo make one: call tiktok_ideas with pick set to that idea object (from structuredContent.ideas) and to 'render_ad' or 'make_series'.`, d);
|
|
25724
|
+
}));
|
|
25725
|
+
|
|
24666
25726
|
server.registerTool('search_tiktok', {
|
|
24667
25727
|
_meta: openaiMeta(AD_SPY_URI, 'Searching TikTok videos…', 'Found TikTok videos'),
|
|
24668
25728
|
title: 'Search TikTok',
|
|
24669
|
-
description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends about a credit.",
|
|
25729
|
+
description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research; for what to POST, tiktok_ideas. Spends about a credit.",
|
|
24670
25730
|
inputSchema: {
|
|
24671
25731
|
query: z.string().describe('keyword or hashtag (no # needed)'),
|
|
24672
25732
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
@@ -24952,9 +26012,10 @@ function memoryNoteVerdict(text) {
|
|
|
24952
26012
|
|
|
24953
26013
|
server.registerTool('list_library', {
|
|
24954
26014
|
title: 'List library',
|
|
24955
|
-
description: "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
|
|
26015
|
+
description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
|
|
24956
26016
|
inputSchema: {
|
|
24957
|
-
kind: z.enum(['image', 'video', 'all']).optional().describe("filter by asset kind (default 'all')"),
|
|
26017
|
+
kind: z.enum(['image', 'video', 'document', 'all']).optional().describe("filter by asset kind (default 'all')"),
|
|
26018
|
+
uploads: z.boolean().optional(),
|
|
24958
26019
|
limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
|
|
24959
26020
|
},
|
|
24960
26021
|
outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
|
|
@@ -24965,10 +26026,11 @@ function memoryNoteVerdict(text) {
|
|
|
24965
26026
|
const kind = a.kind && a.kind !== 'all' ? a.kind : null;
|
|
24966
26027
|
const lim = Math.min(60, Math.max(1, +a.limit || 20));
|
|
24967
26028
|
// id: L0, L1… over the whole Library newest first (a kind filter does not renumber), the id plan_ad / render_ad `inserts` take
|
|
24968
|
-
const
|
|
24969
|
-
|
|
24970
|
-
|
|
24971
|
-
|
|
26029
|
+
const up = a.uploads === true;
|
|
26030
|
+
const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i })).filter(({ x }) => (!kind || x.kind === kind) && (!up || x.source === 'upload')).slice(0, lim)
|
|
26031
|
+
.map(({ x, id }) => ({ id, url: abs(x.url), kind: x.kind || '', model: x.model || '', ...(x.source === 'upload' ? { source: 'upload', ...(x.name ? { name: String(x.name).slice(0, 120) } : {}) } : {}), ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
|
|
26032
|
+
if (!assets.length) return ok(up ? 'Nothing uploaded to this workspace\'s Library yet — upload_file keeps an image, video or PDF there.' : 'The Library is empty for this workspace — render something or upload_file a file first.', { assets: [] });
|
|
26033
|
+
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
24972
26034
|
}));
|
|
24973
26035
|
|
|
24974
26036
|
// DELETE FROM THE LIBRARY (2026-10-23). A customer was told it could not be done and sent to support. The server
|
|
@@ -25055,10 +26117,49 @@ function memoryNoteVerdict(text) {
|
|
|
25055
26117
|
return r;
|
|
25056
26118
|
}));
|
|
25057
26119
|
|
|
26120
|
+
// A VIDEO INTO WRITING (2026-11-03, the YouTube creator kit): POST /api/transcript/repurpose, the same engine as the
|
|
26121
|
+
// Studio's tool. Priced by the video's length before anything is spent; without confirm it only quotes, free.
|
|
26122
|
+
server.registerTool('repurpose_transcript', {
|
|
26123
|
+
title: 'Repurpose a video transcript',
|
|
26124
|
+
description: "Turn a VIDEO into WRITTEN pieces: a summary, YouTube chapters (first at 0:00, at least 3, each 10s+), chapter summaries, an X thread (every post under 280 characters), a blog post, timestamped verbatim quotes, or any piece in your own words (pass the words as a format). The video: a YouTube link (read from its captions), a video file URL, a Library id, an upload_file URL or a social post link (read by watching it); or the text itself, when you already have it from analyze_video (which is the tool that just reads a video back). Several formats share ONE read. Priced by the video's length before anything is spent: without confirm it returns the exact credits and spends nothing; confirm:true writes and charges exactly that. Only the paid minutes are read. Every piece is written only from what the video says. Built-in skills youtube_script and retention_skeleton (get_skill) write long-form scripts.",
|
|
26125
|
+
inputSchema: {
|
|
26126
|
+
video: z.string().optional().describe('a YouTube link, a video file URL, a Library id (L0…), an upload_file URL or a social post link'),
|
|
26127
|
+
transcript: z.string().optional().describe('the transcript text, when you already have it; then nothing is read'),
|
|
26128
|
+
formats: z.array(z.string()).max(6).optional().describe("summary, chapters, chapter_summaries, thread, blog, quotes, or any piece in your own words; default summary"),
|
|
26129
|
+
instructions: z.string().optional().describe("your direction for every piece: audience, tone, length, a call to action"),
|
|
26130
|
+
language: z.string().optional().describe('the language to write in, when not English'),
|
|
26131
|
+
seconds: z.number().optional().describe('the video length in seconds, when you know it: it prices exactly that, and anything past it is not read'),
|
|
26132
|
+
read: z.enum(['auto', 'watch']).optional().describe("'watch' reads a YouTube video by watching it rather than from its captions (one with no captions)"),
|
|
26133
|
+
model: z.string().optional().describe('a Claude writing-model id from hermoso_capabilities; omit for the default'),
|
|
26134
|
+
includeTranscript: z.boolean().optional().describe('also return the transcript that was read'),
|
|
26135
|
+
confirm: z.boolean().optional().describe('true only after the user agreed to the quoted price'),
|
|
26136
|
+
},
|
|
26137
|
+
outputSchema: {
|
|
26138
|
+
needsConfirm: z.boolean().optional().describe('true = this was a price only, nothing spent'),
|
|
26139
|
+
quote: z.any().optional().describe('{credits, minutes, seconds, lengthBasis, read, readCredits, formats:[{id,label,credits}], model}'),
|
|
26140
|
+
formats: z.array(z.any()).optional().describe('[{id, label, text, warning?, error?}]'),
|
|
26141
|
+
transcript: z.string().optional(), creditsUsed: z.number().optional(), minutes: z.number().optional(), notes: z.array(z.string()).optional(),
|
|
26142
|
+
},
|
|
26143
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
26144
|
+
}, wrap(async (a) => {
|
|
26145
|
+
let vid = a.video ? String(a.video).trim() : undefined;
|
|
26146
|
+
const lid = vid && /^L(\d{1,3})$/i.exec(vid);
|
|
26147
|
+
if (lid) { const all = await readStore('heist.assets.v1').catch(() => null); const it = (Array.isArray(all) ? all.filter((x) => x && x.url) : [])[+lid[1]]; if (!it || (it.kind && it.kind !== 'video')) throw Object.assign(new Error(`${vid} is not a video in this workspace's Library (list_library numbers them L0, L1… newest first). Nothing was charged.`), { status: 400, _userInput: true }); vid = String(it.url); }
|
|
26148
|
+
else if (vid && !/^https?:\/\//i.test(vid) && !/^\/generated\//.test(vid)) vid = await toRef(vid); // a local file on the CLI is uploaded first
|
|
26149
|
+
const d = await apiPost('/api/transcript/repurpose', { ...a, video: vid, confirm: a.confirm === true });
|
|
26150
|
+
const notes = Array.isArray(d?.notes) ? d.notes : [];
|
|
26151
|
+
if (d?.needsConfirm) {
|
|
26152
|
+
const q = d.quote || {};
|
|
26153
|
+
return ok(`PRICE (nothing spent): ${q.credits} credits for ${(q.formats || []).map((f) => `${f.label} (${f.credits})`).join(', ')} from ${q.minutes} minute${q.minutes === 1 ? '' : 's'} of video, ${q.read === 'captions' ? `read from its captions (${q.readCredits})` : q.read === 'watch' ? `read by watching it (${q.readCredits})` : 'from the transcript given'}, written by ${q.model}.${notes.length ? '\n' + notes.join('\n') : ''}\nAsk the user; call again with confirm:true to write it for exactly ${q.credits} credits.`, d);
|
|
26154
|
+
}
|
|
26155
|
+
const out = (d?.formats || []).map((f) => `=== ${f.label} ===\n${f.text || `(not written: ${f.error})`}${f.warning ? `\n[check: ${f.warning}]` : ''}`).join('\n\n');
|
|
26156
|
+
return ok(`${out}\n\n— ${d.creditsUsed} credits (the quote), ${d.minutes} min read${notes.length ? '\n' + notes.join('\n') : ''}`, d);
|
|
26157
|
+
}));
|
|
26158
|
+
|
|
25058
26159
|
// A SOUND FOR AN EDIT + THE INSERT CLIP THAT CARRIES IT (2026-09-24): found per edit, never a library.
|
|
25059
26160
|
server.registerTool('find_sound', {
|
|
25060
26161
|
title: 'Find a sound',
|
|
25061
|
-
description: "A sound for an edit by name ('the FAAAA sound'), by the moment ('bad news reaction') or by link (TikTok sound, meme-sound page, post, audio file): a durable mp3 and where it starts and lands. Named/described sounds come from what TikTok uses now; pick takes another candidate.",
|
|
26162
|
+
description: "A sound for an edit by name ('the FAAAA sound'), by the moment ('bad news reaction') or by link (TikTok sound, meme-sound page, post, audio file): a durable mp3 and where it starts and lands. Named/described sounds come from what TikTok uses now; pick takes another candidate. A sound that does not exist yet is generate_sfx.",
|
|
25062
26163
|
inputSchema: { query: z.string().optional().describe("sound by name, moment or meaning, e.g. 'bad news reaction'"), url: z.string().optional().describe('link to a TikTok sound, a post, a meme-sound page or an audio file'), pick: z.number().optional().describe('candidate number from the previous list to take instead of the first') },
|
|
25063
26164
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
25064
26165
|
}, wrap(async (a) => {
|
|
@@ -25102,7 +26203,7 @@ function memoryNoteVerdict(text) {
|
|
|
25102
26203
|
|
|
25103
26204
|
server.registerTool('reframe_video', {
|
|
25104
26205
|
title: 'Reframe video',
|
|
25105
|
-
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master -> 9:16 vertical): the whole original frame is kept and the new canvas around it is generated, with the sound and timing unchanged. Clips up to 300 seconds. Paid render; returns the served URL of the reframed video.",
|
|
26206
|
+
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master -> 9:16 vertical): the whole original frame is kept and the new canvas around it is generated, with the sound and timing unchanged. Clips up to 300 seconds. Paid render; returns the served URL of the reframed video. Every placement at once (a free blur-pad by default): repurpose_video. A presenter on any video: add_presenter.",
|
|
25106
26207
|
inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
|
|
25107
26208
|
outputSchema: { ...JOB_OUT },
|
|
25108
26209
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -25111,6 +26212,105 @@ function memoryNoteVerdict(text) {
|
|
|
25111
26212
|
return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
|
|
25112
26213
|
}));
|
|
25113
26214
|
|
|
26215
|
+
// ONE ASSET IN, EVERY PLACEMENT OUT, AND A PRESENTER ON ANY VIDEO (2026-11-03, server workRepurposeVideo /
|
|
26216
|
+
// workRepurposeImage / workVideoPresenter). Each is ONE queued job; dryRun returns the exact price, the sum of every
|
|
26217
|
+
// leg's own price, which is what the job charges. A media argument may be an https URL, an upload_file URL, a Library
|
|
26218
|
+
// id (L0, L1…) or, on the CLI, a local path (uploaded first).
|
|
26219
|
+
const mediaRef = async (raw, kind) => {
|
|
26220
|
+
const s = String(raw || '').trim();
|
|
26221
|
+
if (!s) throw new Error(`Pass the ${kind}.`);
|
|
26222
|
+
const lid = /^L(\d{1,3})$/i.exec(s);
|
|
26223
|
+
if (lid) {
|
|
26224
|
+
const all = await readStore('heist.assets.v1').catch(() => null);
|
|
26225
|
+
const a = (Array.isArray(all) ? all.filter((x) => x && x.url) : [])[+lid[1]];
|
|
26226
|
+
if (!a || (a.kind && a.kind !== kind)) throw new Error(`${s} is not a${kind === 'image' ? 'n image' : ' video'} in this workspace's Library (list_library numbers them L0, L1… newest first). Nothing was made or charged.`);
|
|
26227
|
+
return abs(String(a.url));
|
|
26228
|
+
}
|
|
26229
|
+
if (/^https?:\/\//i.test(s)) return s;
|
|
26230
|
+
if (/^\/generated\//.test(s)) return abs(s);
|
|
26231
|
+
if (kind === 'image') return toRef(s);
|
|
26232
|
+
if (isRemote()) throw new Error(`\`${s.slice(0, 80)}\` is not a link. Pass an https or upload_file URL, or a Library id (L0…). Nothing was made or charged.`);
|
|
26233
|
+
const { readFile } = await import('node:fs/promises');
|
|
26234
|
+
const up = await apiUpload('/api/upload', await readFile(s), { contentType: 'video/mp4', fileName: s.split(/[\\/]/).pop() });
|
|
26235
|
+
return abs(up?.url || up?.data?.url || '');
|
|
26236
|
+
};
|
|
26237
|
+
const filesText = (files) => (files || []).map((f) => `• ${f.name} → ${abs(f.url)}${f.lostLines && f.lostLines.length ? ` (text check: lost ${f.lostLines.map((l) => `“${l.line || l}”`).join(', ')})` : ''}`).join('\n');
|
|
26238
|
+
server.registerTool('repurpose_video', {
|
|
26239
|
+
title: 'Repurpose a video for every placement',
|
|
26240
|
+
description: "ONE video in, EVERY placement out, in one job, as a named file list (<name>_<placement>_<WxH>[_<lang>].mp4) with a manifest. Placements: 'everywhere' (default: 9:16, 4:5, 1:1, 16:9), any W:H, a pixel size (1200x628) or a platform word (tiktok, reels, stories, feed, square, youtube, linkedin, pinterest). Each is reframed by a BLUR-PAD by default (the whole frame over a blurred copy of itself; free, no AI), reframe 'crop' (fill, edges cut; free) or 'ai' (the new canvas generated around the frame; paid, for a canvas the engine makes, else padded and said). A placement may be {placement, reframe} to mix. Optional captions (word-timed, ~4 words a cue, placed for each canvas) and languages (a lip-synced dub per language; every placement of each). A pack of pads and crops only is billed 0. dryRun:true = the exact price (the sum of every leg's own price), which is what it charges. For ONE canvas use post_edit op pad.",
|
|
26241
|
+
inputSchema: {
|
|
26242
|
+
video: z.string().describe('the video: an https / upload_file URL, a Library id (L0…), or a local path on the CLI'),
|
|
26243
|
+
placements: z.array(z.union([z.string(), z.object({ placement: z.string().optional(), width: z.number().optional(), height: z.number().optional(), reframe: z.enum(['pad', 'crop', 'ai']).optional(), label: z.string().optional() })])).max(8).optional().describe("default ['everywhere']; a canvas, a WxH size, a platform word, or {placement, reframe}"),
|
|
26244
|
+
reframe: z.enum(['pad', 'crop', 'ai']).optional().describe("default 'pad' (free); 'ai' is paid and only when the user asks"),
|
|
26245
|
+
pad: z.object({ fill: z.string().optional(), blur: z.number().optional(), dim: z.number().optional(), position: z.union([z.string(), z.number()]).optional() }).optional().describe("the pad's look: fill 'blur' (default) or a #rrggbb colour, blur 0-1, dim 0-0.6, position top/center/bottom or 0-1"),
|
|
26246
|
+
captions: z.union([z.boolean(), z.string(), z.record(z.any())]).optional().describe('true = word-timed captions on every file (only when the user asks); a textStyle preset name or object sets the look'),
|
|
26247
|
+
wordsPerCue: z.number().int().optional().describe('captions: 1-8 words a cue (default 4)'),
|
|
26248
|
+
languages: z.array(z.string()).max(4).optional().describe("dub into these languages (paid), e.g. ['Spanish','de']; the original is kept too"),
|
|
26249
|
+
name: z.string().optional().describe('the asset name the files are named after'),
|
|
26250
|
+
dryRun: z.boolean().optional().describe('price only: nothing made, nothing charged'),
|
|
26251
|
+
},
|
|
26252
|
+
outputSchema: { ...JOB_OUT, files: z.array(z.any()).optional().describe('[{name, url, placement, aspectRatio, width, height, language, strategy, captions}]'), manifest: z.any().optional(), notes: z.array(z.string()).optional() },
|
|
26253
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
26254
|
+
}, wrap(async (a) => {
|
|
26255
|
+
const _in = { video: await mediaRef(a.video, 'video'), placements: a.placements, reframe: a.reframe, pad: a.pad, captions: a.captions, wordsPerCue: a.wordsPerCue, languages: a.languages, name: a.name };
|
|
26256
|
+
if (a.dryRun) { const q = await quoteJob('repurpose', _in); return ok(q && q.credits != null ? `DRY RUN (nothing made, nothing charged): this pack of ${q.files ?? '?'} file${q.files === 1 ? '' : 's'} costs exactly ${q.credits} credits${q.credits === 0 ? ' (blur-pads and crops only: free)' : ''}, the sum of every leg's own price, and that is what it charges. Call again without dryRun to make it.` : quoteText(q, 'This pack'), { raw: q }); }
|
|
26257
|
+
const r = await renderJob('repurpose', _in, 'MCP repurpose');
|
|
26258
|
+
if (r.stillRendering) return okVideo('', r);
|
|
26259
|
+
const d = r?.raw || {};
|
|
26260
|
+
return ok(`Repurposed into ${(d.files || []).length} file${(d.files || []).length === 1 ? '' : 's'} [job ${r.jobId}]:\n${filesText(d.files)}${(d.notes || []).length ? `\nNOTE: ${d.notes.join(' ')}` : ''}`, { ...r, files: (d.files || []).map((f) => ({ ...f, url: abs(f.url) })), manifest: d.manifest, ...(d.notes ? { notes: d.notes } : {}) });
|
|
26261
|
+
}));
|
|
26262
|
+
server.registerTool('repurpose_image', {
|
|
26263
|
+
title: 'Repurpose a static ad into variants × formats',
|
|
26264
|
+
description: "ONE finished static ad in, VARIANTS × FORMATS out, in one job, as a named file list (<name>_<variant>_<format>_<WxH>.png) with a manifest. The ad's text is read ONCE as the source of truth; each variant's change is made ONCE on the master; every format is derived from that master; every output is text-checked line by line and any line it lost is named (never re-rendered). Variants: {headline}, {language}, {swap:[{from,to}]} (exact words: a price, an offer, a CTA) or {edit} (any change in words), each with an optional {name}; the original is always included. Formats: default 1:1, 4:5, 9:16, 16:9, or 'everywhere', any canvas or a platform word (snapped to the nearest canvas an image model makes). At most 24 renders. dryRun:true = the exact price, which is what it charges. One resize alone is resize_ad.",
|
|
26265
|
+
inputSchema: {
|
|
26266
|
+
image: z.string().describe('the ad: an https / upload_file URL, a Library id (L0…), or a local path on the CLI'),
|
|
26267
|
+
formats: z.array(z.string()).max(8).optional().describe("default ['1:1','4:5','9:16','16:9']"),
|
|
26268
|
+
variants: z.array(z.union([z.string(), z.object({ name: z.string().optional(), headline: z.string().optional(), language: z.string().optional(), swap: z.array(z.object({ from: z.string(), to: z.string() })).optional(), edit: z.string().optional() })])).max(6).optional().describe('the versions besides the original; a bare string is an edit in words'),
|
|
26269
|
+
name: z.string().optional().describe('the asset name the files are named after'),
|
|
26270
|
+
fixLabel: z.boolean().optional().describe("false = no product-label check (default on when the ad shows the brand's saved product)"),
|
|
26271
|
+
dryRun: z.boolean().optional().describe('price only: nothing made, nothing charged'),
|
|
26272
|
+
},
|
|
26273
|
+
outputSchema: { ...JOB_OUT, files: z.array(z.any()).optional().describe('[{name, url, variant, format, aspectRatio, lostLines}]'), manifest: z.any().optional(), notes: z.array(z.string()).optional() },
|
|
26274
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
26275
|
+
}, wrap(async (a) => {
|
|
26276
|
+
const _in = { image: await mediaRef(a.image, 'image'), formats: a.formats, variants: a.variants, name: a.name, ...(a.fixLabel === false ? { fixLabel: false } : {}) };
|
|
26277
|
+
if (a.dryRun) { const q = await quoteJob('repurposeimage', _in); return ok(q && q.credits != null ? `DRY RUN (nothing made, nothing charged): this image pack (${q.renders ?? '?'} render${q.renders === 1 ? '' : 's'}, each text-checked, plus the one read of the ad) costs exactly ${q.credits} credits, and that is what it charges. Call again without dryRun to make it.` : quoteText(q, 'This image pack'), { raw: q }); }
|
|
26278
|
+
const r = await renderJob('repurposeimage', _in, 'MCP image pack');
|
|
26279
|
+
if (r.stillRendering) return ok(stillMsg(r), r);
|
|
26280
|
+
const d = r?.raw || {};
|
|
26281
|
+
const lost = (d.files || []).filter((f) => (f.lostLines || []).length).length;
|
|
26282
|
+
return ok(`Made ${(d.files || []).length} file${(d.files || []).length === 1 ? '' : 's'} [job ${r.jobId}]${lost ? `; ${lost} came back with a line missing (named below; look before using them)` : '; every line checked'}:\n${filesText(d.files)}${(d.notes || []).length ? `\nNOTE: ${d.notes.join(' ')}` : ''}`, { ...r, files: (d.files || []).map((f) => ({ ...f, url: abs(f.url) })), manifest: d.manifest, ...(d.notes ? { notes: d.notes } : {}) });
|
|
26283
|
+
}));
|
|
26284
|
+
server.registerTool('add_presenter', {
|
|
26285
|
+
title: 'Add a presenter to any video',
|
|
26286
|
+
description: "Put a PRESENTER on ANY video, only when the user asks for one: their pick of person (`creator`: a saved or preset creator from list_creators; or `image`: a photo they gave; never invent one), filmed on a green screen saying the words, cut out and laid over the video in a bottom corner (default), a round badge, or split screen, lip-synced, block by block across the whole video (about 10 s and about 25 spoken words a block). The words are `script` (verbatim; refused free if it cannot fit) or, without one, written to fit from what the video says and shows (`brief` steers them). The video's own sound stays under the presenter unless baseAudio 'off'; the video without the presenter is kept. A face the video model refuses STOPS it with nothing charged: offer another creator, another photo, or the video as it is. dryRun:true = the exact price, which is what it charges. For a finished make_explainer film, make_explainer with fromFilm.",
|
|
26287
|
+
inputSchema: {
|
|
26288
|
+
video: z.string().describe('the video: an https / upload_file URL, a Library id (L0…), or a local path on the CLI; up to 2 minutes'),
|
|
26289
|
+
creator: z.string().optional().describe('the presenter: a saved or preset creator, by name or id'),
|
|
26290
|
+
image: z.string().optional().describe("or a photo of the presenter (a real person's face follows the real-face rules)"),
|
|
26291
|
+
script: z.string().optional().describe('the exact words to say, used verbatim and split at sentence ends'),
|
|
26292
|
+
brief: z.string().optional().describe('without a script: what they should talk about'),
|
|
26293
|
+
look: z.enum(['corner', 'badge', 'split']).optional(),
|
|
26294
|
+
position: z.string().optional().describe("'br' (default), 'bl', 'tr', 'tl'"),
|
|
26295
|
+
scale: z.number().optional().describe('0.2-0.8 of the frame'),
|
|
26296
|
+
voice: z.string().optional().describe('a voice name, only when asked (default: their own voice)'),
|
|
26297
|
+
baseAudio: z.enum(['under', 'off']).optional().describe("the video's own sound: 'under' the presenter (default) or 'off'"),
|
|
26298
|
+
captions: z.boolean().optional().describe('subtitles of what they say; only when asked'),
|
|
26299
|
+
dryRun: z.boolean().optional().describe('price only: nothing made, nothing charged'),
|
|
26300
|
+
},
|
|
26301
|
+
outputSchema: { ...JOB_OUT },
|
|
26302
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
26303
|
+
}, wrap(async (a) => {
|
|
26304
|
+
if (!a.creator && !a.image) throw new Error('Who presents it? Pass creator (a saved or preset creator, list_creators) or image (a photo the user gave). Never pick one for them.');
|
|
26305
|
+
const presenter = { ...(a.creator ? { creator: a.creator } : {}), ...(a.image ? { image: await mediaRef(a.image, 'image') } : {}), ...(a.look ? { look: a.look } : {}), ...(a.position ? { position: a.position } : {}), ...(a.scale != null ? { scale: a.scale } : {}), ...(a.voice ? { voice: a.voice } : {}) };
|
|
26306
|
+
const _in = { fromVideo: await mediaRef(a.video, 'video'), presenter, ...(a.script ? { script: a.script } : {}), ...(a.brief ? { brief: a.brief } : {}), ...(a.baseAudio ? { baseAudio: a.baseAudio } : {}), ...(a.captions === true ? { captions: true } : {}) };
|
|
26307
|
+
if (a.dryRun) { const q = await quoteJob('explainer', _in); return ok(q && q.credits != null ? `DRY RUN (nothing made, nothing charged): adding a presenter (${a.look || 'corner'}) to that video costs exactly ${q.credits} credits, and that is what it charges. Call again without dryRun to make it.` : quoteText(q, 'This presenter'), { raw: q }); }
|
|
26308
|
+
const r = await renderJob('explainer', _in, 'MCP presenter');
|
|
26309
|
+
if (r.stillRendering) return okVideo('', r);
|
|
26310
|
+
const d = r?.raw || {};
|
|
26311
|
+
return okVideo(`Presenter added (${d.presenter?.look || a.look || 'corner'}, on block${(d.presenter?.blocks || []).length > 1 ? 's' : ''} ${(d.presenter?.blocks || []).join(', ')}): ${r.url} [job ${r.jobId}]. The video without the presenter is kept: ${abs(d.cleanVideo) || '—'}${Array.isArray(d.script) ? `\nThey say (${d.scriptSource === 'yours' ? 'your words' : 'written for this video'}): ${d.script.map((l, i) => `${i + 1}. ${l}`).join(' ')}` : ''}${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}`, r);
|
|
26312
|
+
}));
|
|
26313
|
+
|
|
25114
26314
|
// INSERT A NEW SCENE (2026-10-26, server workInsertScene + lib/insert-scene.mjs): H3 Max insert-video adds a NEW scene
|
|
25115
26315
|
// between two points of a clip and returns the whole video. Every vendor rule is checked before anything is charged.
|
|
25116
26316
|
server.registerTool('insert_scene', {
|
|
@@ -25149,7 +26349,7 @@ function memoryNoteVerdict(text) {
|
|
|
25149
26349
|
// id and by the delivered URL); dryRun is the job's own quote. Not a draft → one plain sentence, upscale_video instead.
|
|
25150
26350
|
server.registerTool('finish_draft', {
|
|
25151
26351
|
title: 'Finish a draft in 1080p',
|
|
25152
|
-
description: "FINISH a 480p Seedance 2.5 DRAFT in 1080p: the model regenerates THAT take (same scene, shots, motion, sound) at full resolution. Every 480p Seedance 2.5 render (generate_video / render_ad at resolution 480p) is a draft, finishable for 7 days; its result carries `draft` {id, expiresAt, finishCredits}. Pass that id, or the draft video URL.
|
|
26352
|
+
description: "FINISH a 480p Seedance 2.5 DRAFT in 1080p: the model regenerates THAT take (same scene, shots, motion, sound) at full resolution. Every 480p Seedance 2.5 render (generate_video / render_ad at resolution 480p) is a draft, finishable for 7 days; its result carries `draft` {id, expiresAt, finishCredits}. Pass that id, or the draft video URL. It is the full-resolution route for a draft (upscale_video is for a video that is not a draft); priced like a 1080p render of its length. Paid: call with dryRun first, state the credits, run only when the user agrees. Captions, end card or music added after the draft are not re-applied. Not a draft or expired: a plain refusal; use upscale_video or a fresh 1080p render.",
|
|
25153
26353
|
inputSchema: {
|
|
25154
26354
|
draft: z.string().optional().describe('the draft id from the render result (draft.id)'),
|
|
25155
26355
|
video: z.string().optional().describe('or: the URL of the 480p draft video'),
|
|
@@ -25200,7 +26400,7 @@ function memoryNoteVerdict(text) {
|
|
|
25200
26400
|
|
|
25201
26401
|
server.registerTool('edit_video', {
|
|
25202
26402
|
title: 'Edit a video clip',
|
|
25203
|
-
description: "SWAP AN OBJECT IN A VIDEO ('replace the cup with our can' + the product photo as elements) or EDIT an existing clip from a plain instruction (video-to-video): the motion, timing, framing and cut stay, the named thing changes. 'change only the mug to red', 'make it nighttime', 'restyle it as claymation'. Your words are wrapped so the model keeps everything else identical, changes only what you named and repeats that lock; lighting is kept unless the change needs new light (set lighting to force either); literal:true sends your words as written. One change per call holds best. previewFirstFrame:true edits ONE still first (one image edit; previewAt picks the second) and quotes the clip; nothing else runs until you call again, ideally with previewStill. The reply scores how well the shot held outside the change (free) and flags an edit that touched more than asked. NOT for cuts/trims/end cards (post_edit), a new video (generate_video / render_ad),
|
|
26403
|
+
description: "SWAP AN OBJECT IN A VIDEO ('replace the cup with our can' + the product photo as elements) or EDIT an existing clip from a plain instruction (video-to-video): the motion, timing, framing and cut stay, the named thing changes. 'change only the mug to red', 'make it nighttime', 'restyle it as claymation'. Your words are wrapped so the model keeps everything else identical, changes only what you named and repeats that lock; lighting is kept unless the change needs new light (set lighting to force either); literal:true sends your words as written. One change per call holds best. previewFirstFrame:true edits ONE still first (one image edit; previewAt picks the second) and quotes the clip; nothing else runs until you call again, ideally with previewStill. The reply scores how well the shot held outside the change (free) and flags an edit that touched more than asked. NOT for cuts/trims/end cards (post_edit), a new video (generate_video / render_ad), translating what is SPOKEN (dub_video) or a saved creator's face (recast_motion). Translating or rewriting the on-screen words of a video laid out from a design (a template ad, a custom design) is textChanges with every line at once: one re-make of the design, about 2 credits. Best on 3-15s clips.",
|
|
25204
26404
|
inputSchema: {
|
|
25205
26405
|
video: z.string().describe('the source video URL (a render, job result or list_library)'),
|
|
25206
26406
|
instruction: z.string().describe('the change, in the user’s own words'),
|
|
@@ -25215,13 +26415,14 @@ function memoryNoteVerdict(text) {
|
|
|
25215
26415
|
interactionId: z.string().optional().describe('OPTIONAL: the interactionId an earlier Gemini Omni render or edit returned; the edit continues that clip on the same Omni model. If it cannot run, the video editor edits it and the reply says so.'),
|
|
25216
26416
|
engine: z.enum(['auto', 'seedance', 'kling']).optional().describe("default auto: Seedance 2.5 Edit without a real-looking person, else Kling 4.0 Edit"),
|
|
25217
26417
|
faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_CLIP_DESC),
|
|
26418
|
+
textChanges: z.array(z.object({ from: z.string(), to: z.string() })).optional().describe('EVERY change of on-screen words at once ([{from, to}], e.g. each line and its translation). A video laid out from a design is made again with all of them in ONE render, about 2 credits, never a video edit'),
|
|
25218
26419
|
},
|
|
25219
26420
|
outputSchema: { ...JOB_OUT },
|
|
25220
26421
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
25221
|
-
}, wrap(async ({ video, instruction, keepAudio, lighting, reference, literal, previewFirstFrame, previewStill, previewAt, elements, interactionId, engine, faceRoute }) => {
|
|
26422
|
+
}, wrap(async ({ video, instruction, keepAudio, lighting, reference, literal, previewFirstFrame, previewStill, previewAt, elements, interactionId, engine, faceRoute, textChanges }) => {
|
|
25222
26423
|
const prompt = String(instruction || '').trim();
|
|
25223
26424
|
if (!prompt) return { content: [{ type: 'text', text: 'Say what to change — edit_video needs an instruction.' }], isError: true };
|
|
25224
|
-
if (previewFirstFrame !== true && !previewStill) { const _rm = await designRemakeFirst(video, prompt); if (_rm) return _rm; } // words in a design: re-made
|
|
26425
|
+
if (previewFirstFrame !== true && !previewStill) { const _rm = await designRemakeFirst(video, prompt, textChanges); if (_rm) return _rm; } // words in a design: re-made
|
|
25225
26426
|
const _ref = reference ? await toRef(reference) : undefined;
|
|
25226
26427
|
const els = (Array.isArray(elements) ? elements : []).filter(e => e && e.frontal).slice(0, 4);
|
|
25227
26428
|
const _iid = String(interactionId || '').trim();
|
|
@@ -25376,7 +26577,7 @@ function memoryNoteVerdict(text) {
|
|
|
25376
26577
|
|
|
25377
26578
|
server.registerTool('recast_motion', {
|
|
25378
26579
|
title: 'Recast motion',
|
|
25379
|
-
description: "Copy the motion of any video (motion transfer): re-perform a clip's motion, camera and timing with YOUR character, product, clothes or place; sound kept. image (+ images, @Image1, @Image2… in prompt) = who and what. engine auto (default): a person clip + ONE photo of a person (saved, real or AI), 4-30s -> seedance (Seedance 2.5 through our face library, 720p: the clip's scene, beats and sound kept, the WHOLE person swapped) when the clip is one of our renders or faceRoute is set; otherwise
|
|
26580
|
+
description: "Copy the motion of any video (motion transfer): re-perform a clip's motion, camera and timing with YOUR character, product, clothes or place; sound kept. image (+ images, @Image1, @Image2… in prompt) = who and what. engine auto (default): a person clip + ONE photo of a person (saved, real or AI), 4-30s -> seedance (Seedance 2.5 through our face library, 720p: the clip's scene, beats and sound kept, the WHOLE person swapped) when the clip is one of our renders or faceRoute is set; otherwise wan (Wan 3.0, scene kept, 3-15s, 720p), past 15s kling-edit (Kling 4.0 Edit, 3-30s, 1080p by asking); several photos (one per person) -> h3 (H3 Max Recast, ≤4 photos, 5-30s); no person in the clip -> seedance. By name: kling-edit; wan = Wan 3.0 (≤15s, keeps the set); wan-prime; h3; kling = Kling Motion Control (ONE picture, its background becomes the set). voice/language: the new performer gets a new voice (same timing) or speaks another language (lip-synced), in the same job and price. Billed per output second, ~5 min per 5s; dryRun quotes.",
|
|
25380
26581
|
inputSchema: {
|
|
25381
26582
|
image: z.string().optional().describe('who performs it: the actor/character image URL (@Image1). Required on kling. No one yet: generate_image a portrait of an AI person from words (no reference photo) and pass its url, or a saved / preset creator’s portrait (list_creators). ' + LIKENESS_TERMS),
|
|
25382
26583
|
images: z.array(z.string()).max(8).optional().describe('seedance/wan: more pictures, @Image2…: a character, product, outfit or place'),
|
|
@@ -25411,7 +26612,7 @@ function memoryNoteVerdict(text) {
|
|
|
25411
26612
|
// the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
|
|
25412
26613
|
server.registerTool('recast_hook', {
|
|
25413
26614
|
title: 'Recast a viral hook with your subject',
|
|
25414
|
-
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, YouTube; one lookup) 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 at most;
|
|
26615
|
+
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, YouTube; one lookup) 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 at most; more go 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; the reply names the cut. 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 own 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 it. Then get_job until done, and post_edit with videoUrl = the result and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
|
|
25415
26616
|
inputSchema: {
|
|
25416
26617
|
hook: z.string().describe('the viral hook: a post link or a video URL'),
|
|
25417
26618
|
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)"),
|
|
@@ -25450,8 +26651,8 @@ function memoryNoteVerdict(text) {
|
|
|
25450
26651
|
|
|
25451
26652
|
// THE FREE FACE CHECK (2026-09-24): a local face detector on our own CPU (lib/face-detect.mjs), no model call, 0 credits.
|
|
25452
26653
|
server.registerTool('face_check', {
|
|
25453
|
-
title: 'Check a
|
|
25454
|
-
description: "
|
|
26654
|
+
title: 'Check a picture or video for a face',
|
|
26655
|
+
description: "Before any paid render: does a picture or video contain a face, and which video models refuse it (they cannot take a real person's face; a render there fails after it starts) or were measured to take one, plus the model a face-bearing render is routed to. A local detector, no model call, 0 credits. It cannot tell a photo from a drawing, so a drawn face counts. Takes a picture or video URL, a Library item or a public post link. A file or Library item is free; a TikTok / Reel / Facebook / X post link is opened through one paid lookup (1 credit), refused when the account has no credits.",
|
|
25455
26656
|
inputSchema: { url: z.string().describe('the picture or video to check') },
|
|
25456
26657
|
outputSchema: { face: z.string().optional(), best: z.number().nullable().optional(), refuse: z.array(z.any()).optional(), accept: z.array(z.any()).optional(), route: z.any().optional(), summary: z.string().optional() },
|
|
25457
26658
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -25504,7 +26705,7 @@ function memoryNoteVerdict(text) {
|
|
|
25504
26705
|
plan: z.array(z.any()).optional().describe('the `plan` (or `remaining`) a previous call returned: render exactly those ads, no new planning'),
|
|
25505
26706
|
batchId: z.string().optional().describe('the batchId a previous call returned, so the rest lands in the same batch'),
|
|
25506
26707
|
dryRun: z.boolean().optional().describe('true = plan and quote only, nothing rendered'),
|
|
25507
|
-
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay (default) | in_scene (
|
|
26708
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay (default) | in_scene (real file printed onto a blank surface) | none | auto'),
|
|
25508
26709
|
},
|
|
25509
26710
|
outputSchema: {
|
|
25510
26711
|
batchId: z.string().optional(),
|
|
@@ -25536,6 +26737,125 @@ function memoryNoteVerdict(text) {
|
|
|
25536
26737
|
return { content: [{ type: 'text', text }], structuredContent: { batchId: d?.batchId, plan: thePlan, jobs, ...(remaining.length ? { remaining } : {}), perAdCredits: per, totalCredits: total, ...(notes.length ? { notes } : {}) } };
|
|
25537
26738
|
}));
|
|
25538
26739
|
|
|
26740
|
+
// A MARKETPLACE LISTING SET IN ONE CALL (2026-11-03): POST /api/listing-set, the same engine as the Studio's card and
|
|
26741
|
+
// /v1/tools. No planning model: the shots are fixed rules (or the caller's words) over the product's real photo, so the
|
|
26742
|
+
// quote is each image job's own hold and that is the charge. dryRun quotes; `plan` handed back renders exactly it.
|
|
26743
|
+
const LISTING_SHOT_IDS = ['main', 'lifestyle', 'detail', 'scale', 'in_use', 'multi_angle', 'whats_in_box', 'back_of_pack'];
|
|
26744
|
+
server.registerTool('make_listing_set', {
|
|
26745
|
+
title: 'Make a marketplace listing set',
|
|
26746
|
+
description: "Make a marketplace LISTING SET for ONE product in one call (Amazon, Shopify, Etsy, any store page): the MAIN image on pure white (the product alone filling about 85% of the frame, no text, no props, no added logo), then the secondary images a listing carries — a lifestyle shot, a detail close-up with NO infographic, a scale shot that shows its true size, and the product in use — every one rendered from the product's real photo and held to one visual system (the same light, scale and palette). More shots on request: multi_angle, whats_in_box, back_of_pack, or any shot in your own words. The MAIN renders FIRST and every other image is drawn from it (each waits for it in the queue), so the set matches; pass an approved main as mainImage to redo the rest from it. aplus:true adds Amazon's 7 A+ modules (a 21:9 brand hero and brand promise, 3:2 modules 2-6), each also delivered at its Amazon module's exact pixels, with its text fields suggested only from the product's own data. Priced up front: dryRun:true returns the plan and the exact credits; the charge is that number. Each image is its own queued image job (poll with get_job); they land in the Library. For a batch of ADS use make_static_ads; for one product photo in a scene use plan_ad + generate_image.",
|
|
26747
|
+
inputSchema: {
|
|
26748
|
+
product: z.string().optional().describe('the product name or id from list_products: its catalog photo and its own facts are used'),
|
|
26749
|
+
image: z.string().optional().describe('the product photo when it is not in the catalog: an https or upload_file URL, a Library id (L0, L1…) or a local path on the CLI'),
|
|
26750
|
+
title: z.string().optional().describe('the product name, when it is not in the catalog'),
|
|
26751
|
+
description: z.string().optional().describe('what the product is, when it is not in the catalog'),
|
|
26752
|
+
inBox: z.array(z.string()).optional().describe("what ships in the box (for a whats_in_box shot); nothing else is ever drawn in it"),
|
|
26753
|
+
shots: z.array(z.string()).max(9).optional().describe(`the images, in order: built-in ${LISTING_SHOT_IDS.join(', ')}, or any shot in your own words. Default: main, lifestyle, detail, scale, in_use. main always comes first when asked`),
|
|
26754
|
+
aspectRatio: z.enum(['1:1', '4:5', '3:4', '4:3', '16:9', '9:16', '2:3', '3:2']).optional().describe("the canvas for the whole set; default 1:1 (the marketplaces' square)"),
|
|
26755
|
+
brief: z.string().optional().describe("the seller's direction for the whole set: a mood, a setting, an audience"),
|
|
26756
|
+
model: z.string().optional().describe('image model id from hermoso_capabilities; omit for the default'),
|
|
26757
|
+
brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile (passing it pins it like use_brand)'),
|
|
26758
|
+
aplus: z.boolean().optional().describe("also Amazon's 7 A+ modules: 1 brand hero and 7 brand promise at 21:9 (Premium full-width 1464x600 + mobile 600x450), 2-6 at 3:2 (key benefits, how to use, materials and details, lifestyle, what you get; Premium image-with-text 800x600, Basic header 970x600). With shots:[] only the A+ modules"),
|
|
26759
|
+
copy: z.any().optional().describe("the seller's own words for A+ text fields, by module id then field, e.g. {aplus_hero:{headline:'…'}}; each is clipped to Amazon's limit. Without it the fields are suggested only from the product's own data, never invented"),
|
|
26760
|
+
copyIn: z.enum(['fields', 'image']).optional().describe("A+ words: 'fields' (default) = suggested for Amazon's text fields, none painted; 'image' = the words painted on the picture"),
|
|
26761
|
+
mainImage: z.string().optional().describe('an APPROVED main image (URL or Library id) to draw every other image from instead of rendering a new main'),
|
|
26762
|
+
mainJob: z.string().optional().describe('the mainJob id a previous call returned, when rendering its `remaining`: they are drawn from that main'),
|
|
26763
|
+
plan: z.array(z.any()).optional().describe('the `plan` (or `remaining`) a previous call returned: render exactly those images, no new plan, with the same image'),
|
|
26764
|
+
dryRun: z.boolean().optional().describe('true = plan and quote only, nothing rendered or charged'),
|
|
26765
|
+
},
|
|
26766
|
+
outputSchema: {
|
|
26767
|
+
setId: z.string().optional(),
|
|
26768
|
+
product: z.string().optional(),
|
|
26769
|
+
image: z.string().optional().describe('the product photo every image is rendered from'),
|
|
26770
|
+
plan: z.array(z.any()).optional().describe('[{index, shot, label, aspectRatio, prompt, credits}]'),
|
|
26771
|
+
jobs: z.array(z.any()).optional().describe('one queued image job per shot: {id, shot, label, aspectRatio, credits}'),
|
|
26772
|
+
remaining: z.array(z.any()).optional().describe('the part of the plan not queued this call — pass it back as `plan`'),
|
|
26773
|
+
perImageCredits: z.array(z.number()).optional(),
|
|
26774
|
+
totalCredits: z.number().optional().describe('the exact credits the set charges'),
|
|
26775
|
+
chain: z.string().optional().describe("'main first' | 'approved main' | 'main job' | 'none': how the images are drawn from the main"),
|
|
26776
|
+
mainJob: z.string().optional().describe('the main image job every other image waits for and is drawn from'),
|
|
26777
|
+
dryRun: z.boolean().optional(),
|
|
26778
|
+
notes: z.array(z.string()).optional(),
|
|
26779
|
+
},
|
|
26780
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
26781
|
+
}, wrap(async ({ product, image, title, description, inBox, shots, aspectRatio, brief, model, brandId, aplus, copy, copyIn, mainImage, mainJob, plan, dryRun }) => {
|
|
26782
|
+
if (brandId) await activeBrand(brandId);
|
|
26783
|
+
const img = image ? (await insertsResolved([{ image, start: 0, end: 0 }]))[0]?.image : undefined;
|
|
26784
|
+
const mainImg = mainImage ? (await insertsResolved([{ image: mainImage, start: 0, end: 0 }]))[0]?.image : undefined;
|
|
26785
|
+
const d = await apiPost('/api/listing-set', { product, image: img, title, description, inBox, shots, aspectRatio, brief, model, aplus: aplus === true, copy, copyIn, mainImage: mainImg, mainJob, plan, dryRun: !!dryRun });
|
|
26786
|
+
// A PATH OF OUR OWN IS HANDED BACK ABSOLUTE (2026-11-03, found by the paid certification): the dry run used to print
|
|
26787
|
+
// `/generated/…`, and passing that back as `image` read it as a local file on the CLI (ENOENT), so the very plan it
|
|
26788
|
+
// returned could not be rendered from the caller's side.
|
|
26789
|
+
if (d && typeof d === 'object') { if (typeof d.image === 'string' && d.image) d.image = abs(d.image); if (typeof d.mainImage === 'string' && d.mainImage) d.mainImage = abs(d.mainImage); }
|
|
26790
|
+
const thePlan = Array.isArray(d?.plan) ? d.plan : [];
|
|
26791
|
+
const notes = Array.isArray(d?.notes) ? d.notes : [];
|
|
26792
|
+
const chainLine = d?.chain === 'main first' ? 'The main image renders first and every other image is drawn from it.' : d?.chain === 'approved main' ? 'Every image is drawn from the approved main image.' : d?.chain === 'main job' ? 'Every image is drawn from the main image job.' : '';
|
|
26793
|
+
const lines = thePlan.map((x) => `${x.index}. ${x.label || x.shot} (${x.aspectRatio}${x.amazon?.desktop ? ` → Amazon ${x.amazon.module} ${x.amazon.desktop}${x.amazon.mobile ? ` + mobile ${x.amazon.mobile}` : ''}` : ''}) · ${x.credits} credits${x.amazon?.text ? `\n text fields: ${Object.entries(x.amazon.text).map(([f, v]) => `${f} (max ${v.limit}): ${v.text ? `"${v.text}"` : '(yours to write)'}`).join('; ')}` : ''}`);
|
|
26794
|
+
if (d?.dryRun) return { content: [{ type: 'text', text: `LISTING SET PLAN (nothing rendered, nothing charged): ${thePlan.length} image${thePlan.length === 1 ? '' : 's'} for ${d.product || 'the product'}, set ${d.setId}.\n${lines.join('\n')}\n${chainLine ? chainLine + '\n' : ''}Price: ${d.totalCredits} credits, exactly what it charges.${notes.length ? '\n' + notes.join('\n') : ''}\nTo render exactly these, call make_listing_set again with plan set to this plan and image set to ${d.image || 'the same photo'}${d.mainImage ? ` and mainImage ${d.mainImage}` : ''}.` }], structuredContent: { setId: d.setId, product: d.product, image: d.image, plan: thePlan, perImageCredits: d.perImageCredits, totalCredits: d.totalCredits, chain: d.chain, ...(d.mainImage ? { mainImage: d.mainImage } : {}), dryRun: true, ...(notes.length ? { notes } : {}) } };
|
|
26795
|
+
const jobs = Array.isArray(d?.jobs) ? d.jobs : [];
|
|
26796
|
+
const remaining = Array.isArray(d?.remaining) ? d.remaining : [];
|
|
26797
|
+
const text = `Queued ${jobs.length} listing image${jobs.length === 1 ? '' : 's'} for ${d?.product || 'the product'} (set ${d?.setId}, ${d?.totalCredits} credits in all${remaining.length ? ' for the whole set' : ''}).\n${jobs.map((j, i) => `${i + 1}. ${j.label || j.shot} ${j.aspectRatio} → job ${j.id}`).join('\n')}${chainLine ? '\n' + chainLine + (d?.mainJob ? ` The others wait in the queue for job ${d.mainJob}; if it fails they are not made and nothing is charged for them.` : '') : ''}${remaining.length ? `\n${remaining.length} more not queued yet: call make_listing_set again with plan set to \`remaining\`${d?.mainJob ? ` and mainJob ${d.mainJob}` : ''} once some of these finish.` : ''}${notes.length ? '\n' + notes.join('\n') : ''}\nPoll each with get_job; each lands in the Library${thePlan.some((x) => x.aplus) ? ", and each A+ module's result carries `exports`: the same picture at its Amazon module's exact pixels" : ''}.`;
|
|
26798
|
+
return { content: [{ type: 'text', text }], structuredContent: { setId: d?.setId, product: d?.product, image: d?.image, plan: thePlan, jobs, ...(remaining.length ? { remaining } : {}), perImageCredits: d?.perImageCredits, totalCredits: d?.totalCredits, chain: d?.chain, ...(d?.mainJob ? { mainJob: d.mainJob } : {}), ...(notes.length ? { notes } : {}) } };
|
|
26799
|
+
}));
|
|
26800
|
+
|
|
26801
|
+
// RE-ANGLE A VIDEO (2026-11-03): POST /api/video/reangle, the same engine as the Studio's card. Two steps, both priced
|
|
26802
|
+
// before anything is spent: the takes at 720p (beats × angles × takes), each anchored by a still of the new camera, then
|
|
26803
|
+
// the picked take of each beat delivered as itself at 1080p (upscaled, never re-rendered).
|
|
26804
|
+
server.registerTool('reangle_video', {
|
|
26805
|
+
title: 'Re-shoot a video from new camera angles',
|
|
26806
|
+
description: "RE-SHOOT an existing video from a NEW CAMERA ANGLE while keeping who and what is in it, the set, the action, the timing and the colour grade: top shot, worms_eye, three_quarter_left / three_quarter_right, left_profile / right_profile, over_the_shoulder, back, macro, wide, or ANY angle in your own words. The clip is split at its hard cuts and every beat is re-shot on its own, from that beat's footage; for a named shot each take first draws a still of the beat's first frame from the new camera and opens on it. STEP 1 (video + angles + takes 1-4, default 2): renders every beat × angle × take at 720p, so you or the user can PICK the best take of each beat by looking at it (get_job, then video_frames): a take can still drift back toward the source's camera, so check every take. STEP 2 (winners = the job id of the picked take for each beat): delivers each pick AS ITSELF at 1080p (upscaled, not re-rendered, so the angle is the one picked) and joins them into one video with the source's own sound. dryRun:true on either step returns the plan and the exact credits of both steps, nothing rendered or charged: ALWAYS dryRun first and tell the user beats × angles × takes and both prices. A clip showing a real person's face is usually refused by the video model for free, with choices. Results land in the Library.",
|
|
26807
|
+
inputSchema: {
|
|
26808
|
+
video: z.string().optional().describe('the clip to re-shoot (step 1): a render or Library URL, an upload_file URL or a direct video link, up to 60 s'),
|
|
26809
|
+
angles: z.array(z.string()).optional().describe("step 1: 1-4 camera angles — top, worms_eye, three_quarter_left, three_quarter_right, left_profile, right_profile, over_the_shoulder, back, macro, wide — or any angle in your own words"),
|
|
26810
|
+
takes: z.number().optional().describe('step 1: takes per angle per beat, 1-4 (default 2)'),
|
|
26811
|
+
prompt: z.string().optional().describe('extra direction for every take, in your words (it never changes what is in the clip)'),
|
|
26812
|
+
split: z.boolean().optional().describe('false = re-shoot the whole clip as one beat instead of splitting it at its hard cuts'),
|
|
26813
|
+
beats: z.array(z.object({ start: z.number(), end: z.number() })).optional().describe('your own beats in seconds instead of the cut map (each at least 1.8 s), e.g. only the shots with no face'),
|
|
26814
|
+
winners: z.array(z.string()).optional().describe('step 2: the job id of the picked take for EACH beat, from step 1'),
|
|
26815
|
+
plan: z.array(z.any()).optional().describe('the `plan` a step-1 dryRun returned: render exactly those beats (pass `source` and `reangleId` with it)'),
|
|
26816
|
+
source: z.string().optional().describe('with plan: the `source` the dryRun returned'),
|
|
26817
|
+
reangleId: z.string().optional().describe('with plan: the `reangleId` the dryRun returned'),
|
|
26818
|
+
dryRun: z.boolean().optional().describe('true = the plan and the exact price only, nothing rendered or charged'),
|
|
26819
|
+
},
|
|
26820
|
+
outputSchema: {
|
|
26821
|
+
reangleId: z.string().optional(),
|
|
26822
|
+
phase: z.string().optional().describe("'takes' (step 1) or 'final' (step 2)"),
|
|
26823
|
+
plan: z.array(z.any()).optional().describe('the beats: [{beat, start, end, seconds, renderSeconds, video, takeCredits, finalCredits}]'),
|
|
26824
|
+
source: z.string().optional(),
|
|
26825
|
+
angles: z.array(z.any()).optional(),
|
|
26826
|
+
takes: z.number().optional(),
|
|
26827
|
+
renders: z.number().optional().describe('how many takes step 1 renders'),
|
|
26828
|
+
takesCredits: z.number().optional().describe('the exact credits of step 1'),
|
|
26829
|
+
finalCredits: z.number().nullable().optional().describe('the credits of step 2 (the picks upscaled to 1080p); its own dryRun prices the real takes exactly'),
|
|
26830
|
+
totalCredits: z.number().optional().describe('step 2: the exact credits of the 1080p finish'),
|
|
26831
|
+
jobs: z.array(z.any()).optional().describe('step 1: one queued job per take: {id, beat, angle, take, credits}'),
|
|
26832
|
+
job: z.any().optional().describe('step 2: the queued 1080p job {id, credits}'),
|
|
26833
|
+
winners: z.array(z.any()).optional(),
|
|
26834
|
+
dryRun: z.boolean().optional(),
|
|
26835
|
+
notes: z.array(z.string()).optional(),
|
|
26836
|
+
},
|
|
26837
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
26838
|
+
}, wrap(async ({ video, angles, takes, prompt, split, beats, winners, plan, source, reangleId, dryRun }) => {
|
|
26839
|
+
const d = await apiPost('/api/video/reangle', { video, angles, takes, prompt, split, beats, winners, plan, source, reangleId, dryRun: !!dryRun });
|
|
26840
|
+
const notes = Array.isArray(d?.notes) ? d.notes : [];
|
|
26841
|
+
const nl = notes.length ? '\n' + notes.join('\n') : '';
|
|
26842
|
+
if (d?.phase === 'final') {
|
|
26843
|
+
const w = (d.winners || []).map((x) => `beat ${x.beat}: ${x.angle} take ${x.take} (job ${x.job})`).join('; ');
|
|
26844
|
+
if (d.dryRun) return { content: [{ type: 'text', text: `RE-ANGLE 1080p FINISH (nothing rendered, nothing charged): ${d.beats} beat${d.beats === 1 ? '' : 's'}, ${w}.\nPrice: ${d.totalCredits} credits. ${PRICE_ESTIMATE_LINE}${nl}\nTo run it, call reangle_video again with the same winners and no dryRun.` }], structuredContent: { reangleId: d.reangleId, phase: 'final', winners: d.winners, totalCredits: d.totalCredits, dryRun: true, ...(notes.length ? { notes } : {}) } };
|
|
26845
|
+
return { content: [{ type: 'text', text: `Delivering the picks at ${d.resolution} (upscaled, not re-rendered) and joining them (${d.totalCredits} credits): ${w}. Job ${d.job?.id}: poll it with get_job; the finished video lands in the Library.${nl}` }], structuredContent: { reangleId: d.reangleId, phase: 'final', winners: d.winners, totalCredits: d.totalCredits, job: d.job, ...(notes.length ? { notes } : {}) } };
|
|
26846
|
+
}
|
|
26847
|
+
const rows = Array.isArray(d?.plan) ? d.plan : [];
|
|
26848
|
+
const ang = (d?.angles || []).map((a) => a.label).join(', ');
|
|
26849
|
+
const head = `${d?.beats} beat${d?.beats === 1 ? '' : 's'} × ${(d?.angles || []).length} angle${(d?.angles || []).length === 1 ? '' : 's'} (${ang}) × ${d?.takes} take${d?.takes === 1 ? '' : 's'} = ${d?.renders} takes at 720p`;
|
|
26850
|
+
const lines = rows.map((r) => `beat ${r.beat}: ${r.start}-${r.end}s (${r.seconds}s, rendered at ${r.renderSeconds}s) · ${r.takeCredits} credits a take · ${r.finalCredits ?? '?'} at 1080p`).join('\n');
|
|
26851
|
+
const prices = `Step 1 (the takes): ${d?.takesCredits} credits. Step 2 (the pick of each beat at 1080p, after you choose): ${d?.finalCredits ?? '?'} credits.`;
|
|
26852
|
+
const sc = { reangleId: d?.reangleId, phase: 'takes', plan: rows, source: d?.source, angles: d?.angles, takes: d?.takes, renders: d?.renders, takesCredits: d?.takesCredits, finalCredits: d?.finalCredits, ...(notes.length ? { notes } : {}) };
|
|
26853
|
+
if (d?.dryRun) return { content: [{ type: 'text', text: `RE-ANGLE PLAN (nothing rendered, nothing charged): ${head}.\n${lines}\n${prices} ${PRICE_ESTIMATE_LINE}${nl}\nTo render exactly these, call reangle_video again with plan, source (${d?.source}) and reangleId (${d?.reangleId}) from this reply, the same angles and takes, and no dryRun.` }], structuredContent: { ...sc, dryRun: true } };
|
|
26854
|
+
const jobs = Array.isArray(d?.jobs) ? d.jobs : [];
|
|
26855
|
+
const remaining = Array.isArray(d?.remaining) ? d.remaining : [];
|
|
26856
|
+
return { content: [{ type: 'text', text: `Rendering ${head} (${d?.takesCredits} credits for the takes):\n${jobs.map((j) => `beat ${j.beat} · ${j.angle} · take ${j.take} → job ${j.id}`).join('\n')}${remaining.length ? `\n${remaining.length} more not queued yet (the queue is full): run step 1 again for them once some finish.` : ''}${nl}\nPoll each with get_job. Then LOOK at every take (video_frames) or show them to the user, pick ONE per beat, and call reangle_video with winners = those job ids, one per beat (dryRun first: ${d?.finalCredits ?? '?'} credits at 1080p).` }], structuredContent: { ...sc, jobs, ...(remaining.length ? { remaining } : {}) } };
|
|
26857
|
+
}));
|
|
26858
|
+
|
|
25539
26859
|
// THE PRODUCT CATALOG (2026-10-06): a cache of each product's live page — title, link, price, currency, description,
|
|
25540
26860
|
// photos and its own selling points, with when it was last read there. Free (plain page reads, no model). The page
|
|
25541
26861
|
// always wins: a re-read overwrites what we held (except what the user typed), and a failed read is said, never
|
|
@@ -25546,7 +26866,7 @@ function memoryNoteVerdict(text) {
|
|
|
25546
26866
|
title: 'List the product catalog',
|
|
25547
26867
|
description: "The brand's product catalog: each product's id, title, page link, price (and whether it is current), photos, its own selling points and audience, and when it was last checked against its live page. Free. `refresh:'stale'` re-reads rows older than a day first, `refresh:'all'` re-reads every page. Use the ids with make_static_ads.",
|
|
25548
26868
|
inputSchema: { refresh: z.enum(['stale', 'all']).optional().describe("re-read product pages before listing: 'stale' (older than a day) or 'all'"), brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile') },
|
|
25549
|
-
annotations: { readOnlyHint:
|
|
26869
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
25550
26870
|
}, wrap(async ({ refresh, brandId }) => {
|
|
25551
26871
|
if (brandId) await activeBrand(brandId);
|
|
25552
26872
|
const d = await apiGet(`/api/products${refresh ? `?refresh=${refresh}` : ''}`);
|
|
@@ -25566,7 +26886,7 @@ function memoryNoteVerdict(text) {
|
|
|
25566
26886
|
audience: z.string().optional().describe('who this product is for'),
|
|
25567
26887
|
brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile'),
|
|
25568
26888
|
},
|
|
25569
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
26889
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
25570
26890
|
}, wrap(async ({ brandId, ...a }) => {
|
|
25571
26891
|
if (brandId) await activeBrand(brandId);
|
|
25572
26892
|
const d = await apiPost('/api/products', a);
|
|
@@ -25577,7 +26897,7 @@ function memoryNoteVerdict(text) {
|
|
|
25577
26897
|
title: 'Re-check products against their pages',
|
|
25578
26898
|
description: "Re-read product pages now (all products with a page, or `ids`) and update the catalog: the live page wins, a failed read is recorded and said. `sync:true` instead imports the whole store catalog (the brand's connected Shopify store, else its website's public catalog), merging into existing rows. Free.",
|
|
25579
26899
|
inputSchema: { ids: z.array(z.string()).optional().describe('product ids to re-read; omit to re-read every product that has a page'), sync: z.boolean().optional().describe('import the store catalog instead of re-reading pages'), brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile') },
|
|
25580
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
26900
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
25581
26901
|
}, wrap(async ({ ids, sync, brandId }) => {
|
|
25582
26902
|
if (brandId) await activeBrand(brandId);
|
|
25583
26903
|
const d = sync ? await apiPost('/api/products/sync', {}) : await apiPost('/api/products/refresh', ids && ids.length ? { ids } : {});
|
|
@@ -25588,7 +26908,7 @@ function memoryNoteVerdict(text) {
|
|
|
25588
26908
|
title: 'Edit a product',
|
|
25589
26909
|
description: "Edit a catalog product's title, description, selling points, audience or page link. What you set here is kept through later page re-reads. Free.",
|
|
25590
26910
|
inputSchema: { id: z.string().describe('catalog product id, as add_product returned it'), title: z.string().optional().describe('product name'), description: z.string().optional().describe('product description'), sellingPoints: z.array(z.string()).optional().describe('selling points; replaces the saved list'), audience: z.string().optional().describe('who this product is for'), url: z.string().optional().describe('product page link'), brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile') },
|
|
25591
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
26911
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
25592
26912
|
}, wrap(async ({ id, brandId, ...a }) => {
|
|
25593
26913
|
if (brandId) await activeBrand(brandId);
|
|
25594
26914
|
const d = await apiPost(`/api/products/${encodeURIComponent(id)}`, a);
|
|
@@ -25829,7 +27149,7 @@ function memoryNoteVerdict(text) {
|
|
|
25829
27149
|
// Brand tab; nothing is billed, so nothing is booked.
|
|
25830
27150
|
server.registerTool('fetch_app_screens', {
|
|
25831
27151
|
title: 'Fetch App Store screens',
|
|
25832
|
-
description: "Pull an APP brand's REAL store screenshots (Google Play or App Store) into the workspace brand, so a screen-hungry native format can use them. Use when the brand has 0–1 app screens on file and you want make_template_ad(template:'app-ui-tour'), or the user asks to 'pull my app's screenshots'. A store link or package id goes in storeUrl and is read exactly; else pass appName (defaults to the saved brand's name). FREE — a keyless store lookup. It needs a CONFIDENT match: an ambiguous or unknown app returns 0 screens and saves nothing, which you should relay plainly rather than retrying with guesses. On success the screens are saved to the brand (durable URLs) and are immediately usable.",
|
|
27152
|
+
description: "Pull an APP brand's REAL store screenshots (Google Play or App Store) into the workspace brand, so a screen-hungry native format can use them. Use when the brand has 0–1 app screens on file and you want make_template_ad(template:'app-ui-tour'), or the user asks to 'pull my app's screenshots'. A store link or package id goes in storeUrl and is read exactly; else pass appName (defaults to the saved brand's name). A web app with no store listing gets the screenshots of the product its OWN website shows (pictures the site labels as its interface). Call it before telling the user you have no screenshots of their app. FREE — a keyless store lookup or one read of the brand's homepage. It needs a CONFIDENT match: an ambiguous or unknown app returns 0 screens and saves nothing, which you should relay plainly rather than retrying with guesses. On success the screens are saved to the brand (durable URLs) and are immediately usable.",
|
|
25833
27153
|
inputSchema: {
|
|
25834
27154
|
appName: z.string().optional().describe("the app's name to look up on the App Store — defaults to the saved brand's name"),
|
|
25835
27155
|
storeUrl: z.string().optional().describe('a Google Play / App Store link or package id'),
|
|
@@ -25897,7 +27217,7 @@ function memoryNoteVerdict(text) {
|
|
|
25897
27217
|
description: "List every post Hermoso has recorded publishing for this brand, newest first, across all channels — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. The WHOLE history, no cap: pass the reply's nextCursor as `cursor` for older posts. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author intended) or 'backfilled' (reconstructed from the platform afterwards). A dash for engagement means the platform reported no number — NOT zero. Read-only, 0 credits.",
|
|
25898
27218
|
inputSchema: {
|
|
25899
27219
|
...MANAGE_BRAND,
|
|
25900
|
-
channel: z.string().optional().describe('filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok,
|
|
27220
|
+
channel: z.string().optional().describe('filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky, google_business'),
|
|
25901
27221
|
limit: z.number().optional().describe('max posts (default 50, max 200), newest first'),
|
|
25902
27222
|
cursor: z.string().optional().describe('nextCursor from a previous reply: the next, older page'),
|
|
25903
27223
|
},
|
|
@@ -25919,7 +27239,7 @@ function memoryNoteVerdict(text) {
|
|
|
25919
27239
|
title: 'The hook + setting libraries, and which hooks are working',
|
|
25920
27240
|
description: "The curated menu of VISUAL scroll-stop HOOKS (how an ad opens) and SETTINGS (where it is staged) that plan_ad and render_ad accept, PLUS this brand's measured traction per hook. Call it before planning an ad to pick a hook deliberately instead of letting the model improvise one, and call it after publishing to see which ones are actually landing. Three things it will not do: it never recommends a hook from thin data — a verdict is SUPPRESSED below 5 measured posts and the reason is stated; it never compares across channels; and it reports hooks you have NEVER TRIED as a fact, not as advice, because 'you haven't tried this' is an observation and 'you should' would be a verdict drawn from zero data. A hook marked unusable in this brief says WHY (an on-screen-text hook cannot ride an authentic/UGC render, which carries zero on-screen text). Read-only, 0 credits.",
|
|
25921
27241
|
inputSchema: {
|
|
25922
|
-
channel: z.string().optional().describe('restrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok,
|
|
27242
|
+
channel: z.string().optional().describe('restrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky)'),
|
|
25923
27243
|
authentic: z.boolean().optional().describe('true if the planned ad is an authentic/UGC/creator-register render — on-screen-text hooks are then reported unusable, with the reason'),
|
|
25924
27244
|
category: z.string().optional().describe("the product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting our Location x Tier matrix puts that category in, with the reason"),
|
|
25925
27245
|
tier: z.enum(['luxury', 'premium', 'drugstore']).optional().describe('product tier, used with category — changes the FINISH of the room, never the room. Default premium.'),
|
|
@@ -26037,7 +27357,7 @@ function memoryNoteVerdict(text) {
|
|
|
26037
27357
|
title: 'What to fix next, post by post',
|
|
26038
27358
|
description: "WHAT TO FIX NEXT, post by post — and the tool that fixes it. post_performance tells you which hook is AHEAD; this tells you what is WRONG with a given post and where the next edit goes. Under-distributed is A HOOK PROBLEM (change the opening: mine_angles, then list_hooks, then plan_variations). Seen but not held is A RETENTION PROBLEM (plan_variations to rebuild the middle against the same hook). Seen, held, and still not converting is AN OFFER PROBLEM. FOUR REFUSALS, AND YOU SHOULD REPEAT THEM RATHER THAN PAPER OVER THEM: (1) a post younger than ~24h is TOO EARLY and is never called a failure — it has not had its run; (2) a metric the platform does not publish is UNMEASURED, never zero — Facebook has published no post reach since 2026-06-15, Reddit publishes no impressions, and Google Business publishes nothing per-post at all; (3) below 5 measured posts on a channel there is no baseline of the brand's own, and the ONLY fallback is a published short-video hook floor that is NOT our measured number and does not transfer off TikTok/Instagram/YouTube — it is attributed in the output and you should attribute it too; (4) it does not always find a problem, and 'nothing here needs fixing' is a real answer rather than a failure to look. Hermoso cannot see conversions for an organic post — no channel reports installs or purchases against a post id — so the offer rung runs ONLY when the user tells you they are not converting and you pass converting:false. Print `summary` verbatim. Read-only, 0 credits.",
|
|
26039
27359
|
inputSchema: {
|
|
26040
|
-
channel: z.string().optional().describe('restrict to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok,
|
|
27360
|
+
channel: z.string().optional().describe('restrict to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky'),
|
|
26041
27361
|
limit: z.number().optional().describe('how many recent posts to diagnose (default 25, max 200). The baseline is always built from EVERY post recorded for the brand, never only these, so a bad month can never become its own definition of normal.'),
|
|
26042
27362
|
converting: z.boolean().optional().describe('pass false ONLY when the user has told you these posts are getting seen and are not converting — it re-reads the ones that are earning their reach as an offer problem instead of a win. Omit when you do not know; we cannot measure it.'),
|
|
26043
27363
|
},
|
|
@@ -26070,7 +27390,7 @@ function memoryNoteVerdict(text) {
|
|
|
26070
27390
|
|
|
26071
27391
|
server.registerTool('backfill_posts', {
|
|
26072
27392
|
title: 'Import a channel’s past posts',
|
|
26073
|
-
description: "Import this brand's PAST posts from a channel into the performance record, so 'which hook works' can draw on history rather than only on what was published since Hermoso started recording. Supports facebook, instagram, threads, youtube, tiktok, pinterest and bluesky; the others say plainly why they cannot (LinkedIn
|
|
27393
|
+
description: "Import this brand's PAST posts from a channel into the performance record, so 'which hook works' can draw on history rather than only on what was published since Hermoso started recording. Supports facebook, instagram, threads, youtube, tiktok, pinterest and bluesky; the others say plainly why they cannot (LinkedIn has no enumerate-my-posts endpoint on our grant, X bills per read so it is excluded from bulk import, Google Business has had no per-post insights since 2023, and Telegram's Bot API cannot read a chat's past messages at all — nothing published before Hermoso is recoverable through a bot token). BOUNDED, RESUMABLE AND QUOTED: it runs as a DRY RUN by default and tells you how many posts it found and what reading them will cost — pass confirm:true to import, and pass the returned cursor to continue. AN IMPORTED POST IS WEAKER EVIDENCE THAN A RECORDED ONE and is labelled 'backfilled': its hook is recovered ONLY where the post matches a Hermoso creation by asset or caption. A post made outside Hermoso stays UNATTRIBUTED — it counts toward channel and format totals but never votes on which hook works. Never guess a hook from a caption. Free.",
|
|
26074
27394
|
inputSchema: {
|
|
26075
27395
|
channel: z.enum(['facebook', 'instagram', 'threads', 'youtube', 'tiktok', 'pinterest', 'bluesky']).describe('which channel to import from'),
|
|
26076
27396
|
confirm: z.boolean().optional().describe('actually import — omit for a dry run that only quotes the cost'),
|
|
@@ -26086,6 +27406,7 @@ function memoryNoteVerdict(text) {
|
|
|
26086
27406
|
}));
|
|
26087
27407
|
registerWidgetHostAliases(rawServer, ctx, opts);
|
|
26088
27408
|
registerWidgetHostSplits(rawServer, ctx, opts);
|
|
27409
|
+
enforceWidgetCap(ctx, opts);
|
|
26089
27410
|
registerChatGPTExtensions(rawServer, ctx, opts);
|
|
26090
27411
|
installHeldToolCalls(rawServer, ctx);
|
|
26091
27412
|
}
|