hermoso 0.1.532 → 0.1.533
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 +12 -5
- package/mcp/chatgpt-roster.mjs +3 -2
- package/mcp/client.mjs +5 -1
- package/mcp/http.mjs +37 -14
- package/mcp/tool-cost.mjs +3 -0
- package/mcp/tool-hints.mjs +111 -6
- package/mcp/tools.mjs +958 -136
- package/package.json +2 -2
- package/skills/hermoso-edit-timeline/SKILL.md +3 -3
package/mcp/tools.mjs
CHANGED
|
@@ -35,11 +35,11 @@ import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-co
|
|
|
35
35
|
import { spendTool, SPEND_ARG_TEXT, CONTEXT_ARG_TEXT, takeSpendMeta, argsHash, jobKeyRoot, redactContext, contextHeaderValue, quoteFromLegs, quoteText as spendQuoteText, expectedMismatchText, priceMatches } from './spend-guard.mjs';
|
|
36
36
|
import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
|
|
37
37
|
// THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
|
|
38
|
-
import { withHints, hintsOf, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint, finishedRenderKind, withWidgetResultNote } from './tool-hints.mjs';
|
|
38
|
+
import { withHints, hintsOf, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint, finishedRenderKind, withWidgetResultNote, buyDoorsOf, buyDoorsText, buyDoorsHints, withInfoTier, ACCOUNT_INFO_URL, stripPlanPromo, withoutPlanPitch, PLAN_PITCH_RE } from './tool-hints.mjs';
|
|
39
39
|
// THE CHATGPT EXTENSIONS APP (home, Library panel, concept picker, brief, setup, settings, @-mentions), registered only
|
|
40
40
|
// behind HERMOSO_CHATGPT_EXT=1 + `?ext=1` on a widget host — see registerChatGPTExtensions() below and
|
|
41
41
|
// ./openai-extensions.mjs. Same twin-safe specifier rule.
|
|
42
|
-
import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY } from './openai-extensions.mjs';
|
|
42
|
+
import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY, EXT_TOOL_NAMES } from './openai-extensions.mjs';
|
|
43
43
|
// STATIC ADS AT SCALE (2026-10-06): the pure catalog/plan/prompt helpers behind make_static_ads. Same twin-safe specifier rule.
|
|
44
44
|
import { STATIC_BATCH, normalizeRatios, renderList } from './static-batch.mjs';
|
|
45
45
|
import { findClientLocalMedia, clientLocalMediaError, MEDIA_PARAM_NOTE } from './media-ref.mjs'; // ONE rule: a media reference is a URL, never a path on the caller's machine (2026-11-05)
|
|
@@ -124,6 +124,16 @@ const creatorLine = (c) => {
|
|
|
124
124
|
const FACE_ROUTE_CLIP_DESC = "'face_lane' = the user's \"I own the rights to this face\" for a real person in the source clip (paid plans). Only on the user's say-so, never on your own.";
|
|
125
125
|
const FACE_ROUTE_DESC = "'face_lane' records the user's statement \"I own the rights to this face\" for the real person in their reference (paid plans; the likeness terms apply). Set it only when the user picked that option after a render said it could not use that face, and send the same request again; it then renders through Hermoso's face library at the normal price. Never set it on your own. The other options are a different `model` or one of the user's saved `creator`s.";
|
|
126
126
|
const LIKENESS_TERMS = 'Using a real person’s face, likeness or voice confirms you are them or have their consent, take full, unlimited responsibility for its use and will cover any claim against Hermoso (hermoso.ai/terms). Paid plan only; recorded.';
|
|
127
|
+
// AUTOMATED AD RULES (Meta + Google Ads): the lane's note first, then the rows a client that shows only text would
|
|
128
|
+
// otherwise never see — each rule's verdict and scopeToken, each run's per-object lines. Mirrors server.js adRuleToolText.
|
|
129
|
+
function adRuleText(d = {}) {
|
|
130
|
+
const out = [d.note || ''];
|
|
131
|
+
if (Array.isArray(d.rules) && d.rules.length) out.push(...d.rules.slice(0, 40).map((r) => `· "${r.name}" (${r.ruleId}) — ${r.status}${r.canSpend ? ` ⚠ CAN RAISE SPEND: ${(r.armingActions || []).join(', ')}` : ' — cannot start or raise spend'}${r.unbounded ? ' — reach grows' : ''} — scopeToken ${r.scopeToken} — ${r.summary}`));
|
|
132
|
+
if (Array.isArray(d.lines) && d.lines.length) out.push(...d.lines.slice(0, 40));
|
|
133
|
+
if (Array.isArray(d.runs) && d.runs.length) out.push(...d.runs.slice(0, 20).map((r) => `· ${r.at || ''} ${r.mode ? `${r.mode} ` : ''}${r.outcome ? `${r.outcome} ` : ''}${r.runId ? `(${r.runId}) ` : ''}${r.note || (r.changedNothing ? 'changed nothing' : `${(r.results || []).length} result(s)`)}`));
|
|
134
|
+
if (d.scopeToken && !Array.isArray(d.rules)) out.push(`scopeToken: ${d.scopeToken}`);
|
|
135
|
+
return out.filter(Boolean).join('\n');
|
|
136
|
+
}
|
|
127
137
|
function rowLines(rows, max = 40) {
|
|
128
138
|
const all = Array.isArray(rows) ? rows : [];
|
|
129
139
|
if (!all.length) return ' (no rows)';
|
|
@@ -158,10 +168,13 @@ const channelOutcomeLine = (res) => {
|
|
|
158
168
|
const stillMsg = (r, widget = hostRendersWidgets()) => widget
|
|
159
169
|
? `NOT FINISHED. Job ${r.jobId} is still rendering and there is no video yet. The Hermoso card above is already polling and will show the finished video in place the moment it lands, so do NOT call get_job and do NOT re-render. Reply with ONE short line saying it is rendering and will appear in the card above. Do NOT say it is done, ready, rendered or finished, and do NOT describe the video — you have not seen it.`
|
|
160
170
|
: `Still rendering — job ${r.jobId}. This is NORMAL: video renders take 1–3 minutes and each get_job call waits up to ~45s, so it can take several calls. Keep calling get_job with this id until status is done or error — do NOT ask the user whether to keep waiting, and do NOT re-fire the render on another model (that double-charges). Only surface a problem after ~6 minutes of polling. Until get_job reports done you have NO video: do NOT say it is finished and do NOT describe it.`;
|
|
161
|
-
// A TIMELINE EDIT'S
|
|
171
|
+
// A TIMELINE EDIT'S SEAM REVIEW, one sentence for both places it can arrive: edit_timeline's own reply when the render
|
|
162
172
|
// finished inside the wait, and get_job's when it did not (a prod timeline takes ~3 minutes, past the hosted 45 s wait,
|
|
163
|
-
// so on a hosted MCP the review ONLY ever arrives through get_job). Empty for every other job.
|
|
164
|
-
|
|
173
|
+
// so on a hosted MCP the review ONLY ever arrives through get_job). Empty for every other job. ON REQUEST ONLY
|
|
174
|
+
// (2026-11-12, owner rules: no automatic post-render review or retake, no flaw notes): it exists only when the caller
|
|
175
|
+
// passed review:true, and it never tells the caller to fix and re-run on its own. A requested device missing from the
|
|
176
|
+
// file (verdict null) is a delivery fact, said as one.
|
|
177
|
+
const timelineReviewText = (rv) => !rv ? '' : !rv.verdict ? `\nNOT IN THE RENDER (part of what was asked for, read back from the file): ${(rv.issues || []).map((x) => x.problem).join(' | ')}. Tell the user which part is missing and offer a re-run; do not re-run on your own.` : `\nSEAM REVIEW, as requested (${rv.verdict}${rv.score != null ? `, ${rv.score}/10` : ''}${rv.linked === false ? ', NOT LINKED: the two clips read as unrelated' : rv.linked ? ', linked' : ''}): ${(rv.issues || []).map((x) => `seam ${x.seam}: ${x.problem} → ${x.fix}`).join(' | ') || 'no issues'}. Share it with the user and let them decide on a re-run (each re-run is a new edit); do not re-run on your own.`;
|
|
165
178
|
// THE SEAM MATCH + INTRO BUDGET of a timeline or join (lib/seam-match.mjs, post-edit.mjs introBudget): the measured
|
|
166
179
|
// before/after per cut and the surviving window of a clip placed after a hook. Empty for every other result.
|
|
167
180
|
const seamsText = (d) => {
|
|
@@ -285,7 +298,7 @@ export const CAPABILITY_MAP = [
|
|
|
285
298
|
'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).',
|
|
286
299
|
'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.',
|
|
287
300
|
'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).',
|
|
288
|
-
'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) · create_tiktok_ads_business_center (when that login administers NO Business Center, create one first in the user’s company name; same two steps, every field asked of the user, never guessed) · 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): 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) · BOOST AN EXISTING LINKEDIN POST (a personal post, or a Company Page post with organization; an opt-in on the LinkedIn connection whose refusal carries the enable link): linkedin_boost_status (can it be boosted / is it already) · boost_linkedin_post (free preview without confirm; confirm:true creates it and LAUNCHES spend, there is no paused state) · end_linkedin_boost · linkedin_boost_analytics · 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 is connected from Hermoso (Connectors ▸ Whop) or by pressing Open Hermoso in the whop’s dashboard; 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) · get_microsoft_ads_account_properties / set_microsoft_ads_account_properties (account-level settings: tracking template, final URL suffix, MSCLKID auto-tagging, view-through conversions, business attributes, AI asset enhancement opt-out and the AI-detected content watermark policy, read back after every change). 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.',
|
|
301
|
+
'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) · meta_catalog_suggestions (which catalog Meta would show under a new ad) · 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) · prereview_tiktok_ads_creative (the same review verdict BEFORE any ad exists, per video, image, ad text or landing page, for one country) · 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) · manage_tiktok_ads_negative_keywords (searches an ad must not show for: the whole ad account, a campaign or an ad group) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · preview_tiktok_smart_ad (see a Smart+ ad as it renders on TikTok, existing or not yet created, with its disclaimer and add-on card) · 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) · create_tiktok_ads_business_center (when that login administers NO Business Center, create one first in the user’s company name; same two steps, every field asked of the user, never guessed) · 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): 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) · BOOST AN EXISTING LINKEDIN POST (a personal post, or a Company Page post with organization; an opt-in on the LinkedIn connection whose refusal carries the enable link): linkedin_boost_status (can it be boosted / is it already) · boost_linkedin_post (free preview without confirm; confirm:true creates it and LAUNCHES spend, there is no paused state) · end_linkedin_boost · linkedin_boost_analytics · 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 (read only: Reddit retired creating lead forms) · 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 is connected from Hermoso (Connectors ▸ Whop) or by pressing Open Hermoso in the whop’s dashboard; 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) · get_microsoft_ads_account_properties / set_microsoft_ads_account_properties (account-level settings: tracking template, final URL suffix, MSCLKID auto-tagging, view-through conversions, business attributes, AI asset enhancement opt-out and the AI-detected content watermark policy, read back after every change). 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.',
|
|
289
302
|
'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.',
|
|
290
303
|
].join('\n');
|
|
291
304
|
|
|
@@ -316,12 +329,12 @@ export const MCP_INSTRUCTIONS = [
|
|
|
316
329
|
// ACT-DO-NOT-SURVEY last and the 2,048-byte cut landed inside it — 'DO NOT SURVE' — so the rule that exists to
|
|
317
330
|
// stop an agent answering a render request with a model catalog was itself the thing truncated. Whatever gets
|
|
318
331
|
// clipped from the tail must cost tool NAMES, which the roster still carries, never a rule, which it does not.
|
|
319
|
-
'Hermoso is marketing on autopilot, driven over MCP. FIVE INDEPENDENT AREAS
|
|
320
|
-
'ACT ON THE REQUEST, DO NOT SURVEY IT: asked to make something, make it
|
|
321
|
-
'• RESEARCH
|
|
332
|
+
'Hermoso is marketing on autopilot, driven over MCP. FIVE INDEPENDENT AREAS: none is a step in a pipeline, and no tool needs you to have used another one first:',
|
|
333
|
+
'ACT ON THE REQUEST, DO NOT SURVEY IT: asked to make something, make it; omit `model` and a sound default runs. hermoso_capabilities (free) is for a model id, credit cost or duration, never the answer to a create request.',
|
|
334
|
+
'• RESEARCH winning ads: 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.',
|
|
322
335
|
'• 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.',
|
|
323
336
|
'• 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).',
|
|
324
|
-
'• 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
|
|
337
|
+
'• 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 in Settings ▸ Connectors.',
|
|
325
338
|
// 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.
|
|
326
339
|
// THE LAST LINE OF THE HEAD, AND THE ONE THAT MAKES THE SHORT ROSTER SAFE (2026-09-17). Your tool LIST is now
|
|
327
340
|
// core-first; every name in the bullets above is held out of it on SIZE and answers a call anyway. Say that
|
|
@@ -391,7 +404,7 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
|
|
|
391
404
|
'• 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.',
|
|
392
405
|
'• ANALYTICS: meta_insights, google_ads_report and the other per-platform reports, analytics_report, search_console_performance.',
|
|
393
406
|
'• PROFILE: get_brand, draft_brand, update_brand, list_connectors, list_brands, use_brand, hermoso_credits.',
|
|
394
|
-
'Not every tool is in your starting list: find_tools({query}) finds one and call_tool({name,args}) runs it.
|
|
407
|
+
'Not every tool is in your starting list: find_tools({query}) finds one and call_tool({name,args}) runs it.',
|
|
395
408
|
'Confirm with the user before anything is published, deleted or set to spend. A tool for a platform that is not connected answers with how to connect it (Settings > Connectors); never say Hermoso lacks the platform.',
|
|
396
409
|
].join('\n');
|
|
397
410
|
// ── CHATGPT GETS ITS OWN INSTRUCTIONS (2026-09-30) ──────────────────────────────────────────────────────────────
|
|
@@ -407,17 +420,17 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
|
|
|
407
420
|
// • under ~2 KB, because ChatGPT's connector truncates server instructions near there.
|
|
408
421
|
// Every other host keeps MCP_INSTRUCTIONS unchanged.
|
|
409
422
|
export const MCP_INSTRUCTIONS_WIDGET = [
|
|
410
|
-
"Hermoso runs a brand's marketing: research competitors' ads,
|
|
411
|
-
'Act on the request: asked to make something, make it
|
|
423
|
+
"Hermoso runs a brand's marketing: research competitors' ads, make image and video ads, post and schedule to their own accounts, run paid campaigns and read results. Each area works on its own.",
|
|
424
|
+
'Act on the request: asked to make something, make it; render tools pick a sound default model (hermoso_capabilities lists model ids and costs).',
|
|
412
425
|
'• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
|
|
413
|
-
'• 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
|
|
414
|
-
"• 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 (
|
|
426
|
+
'• 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.',
|
|
427
|
+
"• 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 (Hermoso app, Settings > Connectors); list_library lists past creations.",
|
|
415
428
|
'• PUBLISH: post_to_* (post_to_meta, post_to_tiktok...) post now; list_inbox and reply_to_inbox_item answer comments and messages.',
|
|
416
429
|
'• PAID ADS (Meta, Google, LinkedIn, TikTok, Reddit, Snapchat, ChatGPT): create_* builds campaigns paused; set_*_status and set_*_budget control spend.',
|
|
417
430
|
'• RESULTS: post_performance, analyze_campaigns. WORKSPACE: list_brands, use_brand, create_brand, hermoso_credits, report_bug, request_feature.',
|
|
418
|
-
"CREDITS: AI and research tools use
|
|
431
|
+
"CREDITS: AI and research tools use the user's Hermoso credits; the reply says how many. If too low, say so; the account is managed at hermoso.ai. Never start a purchase.",
|
|
419
432
|
'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).',
|
|
420
|
-
'SHOWING RESULTS: the widget already shows each finished image and video. Never repeat one as a markdown image or image link (
|
|
433
|
+
'SHOWING RESULTS: the widget already shows each finished image and video. Never repeat one as a markdown image or image link (a broken tile); describe it, link only if asked.',
|
|
421
434
|
].join('\n');
|
|
422
435
|
// ── EACH FOCUSED APP GETS ITS OWN INSTRUCTIONS (2026-11-06) ─────────────────────────────────────────────────────────
|
|
423
436
|
// Same rules as MCP_INSTRUCTIONS_WIDGET: name only tools the app lists (tools/app-scope-check.mjs runs the real
|
|
@@ -768,6 +781,19 @@ export async function finishToolKey(claim, st, r, err) {
|
|
|
768
781
|
} else await toolCtx.run({ tool: claim.tool }, () => apiPost('/api/tool-idempotency/release', { tool: claim.tool, key: claim.key, hash: claim.hash }));
|
|
769
782
|
} catch { /* bookkeeping: the answer below is the call's own, and an unsettled claim ages out as stale */ }
|
|
770
783
|
}
|
|
784
|
+
// A widget host (ChatGPT) never hears a plan pitch or a purchase tool's name, whatever produced the reply (tool-hints.mjs
|
|
785
|
+
// withoutPlanPitch); every other host gets the reply untouched, by reference.
|
|
786
|
+
const noPitchOnWidget = (r) => (hostRendersWidgets() ? withoutPlanPitch(r) : r);
|
|
787
|
+
// A CONNECTION THAT STOPPED WORKING IS SAID BY THE TOOLS AN AGENT ALREADY CALLS (2026-11-12, lib/connector-notice.mjs).
|
|
788
|
+
// The server writes the sentence (`agentNote` on GET /api/connectors/attention: the account, the platform's own words, the
|
|
789
|
+
// steps, the reconnect link and the scheduled posts it holds up), so the twins, the web banner and the Studio agent say one
|
|
790
|
+
// thing. A failed read says nothing: an unreadable state is never relayed as a problem.
|
|
791
|
+
async function brokenConnectorNotes(pick = null) {
|
|
792
|
+
try {
|
|
793
|
+
const a = await apiGet('/api/connectors/attention');
|
|
794
|
+
return (Array.isArray(a?.issues) ? a.issues : []).filter((i) => i && i.state === 'reconnect' && typeof i.agentNote === 'string' && i.agentNote && (!pick || pick(i)));
|
|
795
|
+
} catch { return []; }
|
|
796
|
+
}
|
|
771
797
|
const wrap = (fn) => {
|
|
772
798
|
// A NAMED closure, so registerTools' proxy can stamp the tool name onto the very function it registers and this
|
|
773
799
|
// body can read it back. Reading a name off `fn` instead would be wrong: `fn` is the inner handler and the thing
|
|
@@ -797,10 +823,10 @@ const wrap = (fn) => {
|
|
|
797
823
|
if (_sm) {
|
|
798
824
|
args = withoutArgKeys(args, ['get_cost', 'expected_credits', 'idempotency_key']);
|
|
799
825
|
const _early = await spendPreflight(_tool, _sm, args, extra, _st, fn);
|
|
800
|
-
if (_early) { try { recordToolOutcome(_tool, { ok: !_early.isError, ms: Date.now() - _t0 }); } catch {} return _early; }
|
|
826
|
+
if (_early) { try { recordToolOutcome(_tool, { ok: !_early.isError, ms: Date.now() - _t0 }); } catch {} return noPitchOnWidget(_early); }
|
|
801
827
|
}
|
|
802
828
|
const _claim = _sm?.key ? await claimToolKey(_tool, _sm.key, args, _st) : null;
|
|
803
|
-
if (_claim?.answer) return _claim.answer;
|
|
829
|
+
if (_claim?.answer) return noPitchOnWidget(_claim.answer);
|
|
804
830
|
let r;
|
|
805
831
|
try { r = await toolCtx.run(_st, () => fn(args, extra)); }
|
|
806
832
|
catch (e) { if (_claim) await finishToolKey(_claim, _st, null, e); throw e; }
|
|
@@ -812,9 +838,9 @@ const wrap = (fn) => {
|
|
|
812
838
|
const _txt = Array.isArray(r?.content) ? r.content.find((b) => b && b.type === 'text' && typeof b.text === 'string') : null;
|
|
813
839
|
if (_c > 0 && !_st.chargeSaid && _txt && !CHARGED_RE.test(_txt.text)) _txt.text += r?.structuredContent?.stillRendering ? `\nCharged ${_c} credit${_c === 1 ? '' : 's'} so far; the render's own charge is reported by get_job when it finishes.` : chargeSentence(_c);
|
|
814
840
|
} catch {}
|
|
815
|
-
// The FIRST successful reply of a
|
|
816
|
-
// next ask (firstRunNote). Spent once per session whatever the tool was, so a session that is already
|
|
817
|
-
// is never told to.
|
|
841
|
+
// The FIRST successful reply of a session (any host since 2026-11-12), on an account that has made nothing yet, offers
|
|
842
|
+
// one concrete next ask (firstRunNote). Spent once per session whatever the tool was, so a session that is already
|
|
843
|
+
// creating is never told to.
|
|
818
844
|
try {
|
|
819
845
|
const _fr = mcpCtx.getStore()?.firstRun;
|
|
820
846
|
if (_fr && !_fr.said && r && !r.isError) {
|
|
@@ -823,12 +849,12 @@ const wrap = (fn) => {
|
|
|
823
849
|
if (_n) {
|
|
824
850
|
const _last = [...r.content].reverse().find((b) => b && b.type === 'text' && typeof b.text === 'string');
|
|
825
851
|
if (_last) _last.text += `\n\n${_n.text}`; else r.content.push({ type: 'text', text: _n.text });
|
|
826
|
-
return withHints(r, [_n.hint]);
|
|
852
|
+
return noPitchOnWidget(withHints(r, [_n.hint]));
|
|
827
853
|
}
|
|
828
854
|
}
|
|
829
855
|
} catch {}
|
|
830
856
|
const _noteBrand = noteBrandOf(_tool, args); // the brand this call acted on, so both notes are about it
|
|
831
|
-
return await withWeekPlanNote(_tool, await withConnectPrompt(_tool, withWidgetResultNote(_tool, r, hostRendersWidgets()), apiPost, _noteBrand), apiPost, _noteBrand);
|
|
857
|
+
return noPitchOnWidget(await withWeekPlanNote(_tool, await withConnectPrompt(_tool, withWidgetResultNote(_tool, r, hostRendersWidgets()), apiPost, _noteBrand), apiPost, _noteBrand));
|
|
832
858
|
}
|
|
833
859
|
catch (e) {
|
|
834
860
|
try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
|
|
@@ -854,16 +880,23 @@ const wrap = (fn) => {
|
|
|
854
880
|
// forbid selling credits or promoting upgrades, and it is not offered buy_credits/upgrade_plan at all. Read at CALL
|
|
855
881
|
// time from the session's client, the same way a render decides whether to inline an image (hostRendersWidgets).
|
|
856
882
|
const _widget = hostRendersWidgets();
|
|
857
|
-
|
|
883
|
+
// THE REFUSAL CARRIES THE PRICED DOORS (2026-11-12, tool-hints.mjs buyDoorsText): a plan and a pack sized to the ask, each
|
|
884
|
+
// with its price and a checkout link, in place of the paragraph on how buy_credits works. Never on a widget host.
|
|
885
|
+
const _buy = buyDoorsOf(e, _widget);
|
|
886
|
+
if (_buy) { try { apiPost('/api/analytics/funnel', { step: 'buy_doors', meta: { surface: 'agent_refusal' } }).catch(() => {}); } catch {} } // the doors were shown to an agent: counted once per account per 10 minutes, never awaited
|
|
887
|
+
if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice, { widgetHost: _widget, buy: _buy }); _hints.push(...videoChoiceHints(_tool, e.videoChoice, { widgetHost: _widget, buy: _buy })); }
|
|
858
888
|
// FREE CREDITS WAITING (2026-10-25): the server's sentence already names the one fix (open the app once, signed in) and
|
|
859
889
|
// drops its top-up advice, so it passes through as written on every host, and neither buy_credits nor the pricing page is named.
|
|
860
890
|
else if (e?.welcomeClaim && e.welcomeClaim.url) _hints.push(welcomeClaimHint(e.welcomeClaim));
|
|
861
|
-
else if (_widget && /not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) { msg = neutralCreditsText(msg); _hints.push(...creditsLowHints()); }
|
|
891
|
+
else if (_widget && /not enough credits|out of credits|insufficient credits|needs (a paid plan|the Pro plan)/i.test(msg)) { msg = neutralCreditsText(msg, e?.buy && typeof e.buy === 'object' ? { need: e.buy.need, balance: e.buy.balance } : {}); _hints.push(...creditsLowHints()); }
|
|
892
|
+
else if (_buy) { _hints.push(...buyDoorsHints(_buy, _tool)); msg += `\n${buyDoorsText(_buy, _tool)}`; }
|
|
862
893
|
else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.${/no markup on models/i.test(msg) ? ' Starter, Pro and Studio make credits go twice as far: upgrade_plan moves the user to one of them, with no markup on models (a person pays on Stripe).' : ''}`;
|
|
863
894
|
// connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
|
|
864
895
|
// in-agent) — 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
|
|
865
896
|
else {
|
|
866
897
|
msg += notConnectedHint(e, msg);
|
|
898
|
+
// A 401 ON A CONNECTION THAT STOPPED WORKING says why and what to do, not only "connect it" (2026-11-12).
|
|
899
|
+
if (Number(e?.status) === 401 && e?.connector) { const _bn = await brokenConnectorNotes((i) => i.provider === String(e.connector)); if (_bn.length) msg += `\n${_bn[0].agentNote}`; }
|
|
867
900
|
// Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
|
|
868
901
|
// META'S SECURITY HOLD (code 31/3858385): the server's sentence already carries the steps; this names the move
|
|
869
902
|
// so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
|
|
@@ -896,7 +929,20 @@ const wrap = (fn) => {
|
|
|
896
929
|
// so its ambiguous-publish advice keeps the marker rather than dropping it.
|
|
897
930
|
// `remedy` (2026-10-25): the server's structured next step, whole, plus its `code` — Meta's security hold (code +
|
|
898
931
|
// steps[]) or a publish outcome (posted:false | 'unknown'). /v1 makes `code` its error code and returns `remedy`.
|
|
899
|
-
|
|
932
|
+
// NO PLAN PITCH ON A WIDGET HOST, IN ANY BRANCH ABOVE (2026-11-12, tool-hints.mjs PLAN_PITCH_RE): the server appends its
|
|
933
|
+
// monthly-plan sentence (withPlanUpsell) to refusals this catch passes through as written, and OpenAI's app guidelines
|
|
934
|
+
// forbid promoting upgrades. The structured choice keeps the image and draft options and drops the top-up (a buy_credits
|
|
935
|
+
// checkout); hints that name a purchase are dropped by noPitchOnWidget.
|
|
936
|
+
const _vcOpts = e?.videoChoice?.options && typeof e.videoChoice.options === 'object' ? (_widget ? (({ topup, ...rest }) => rest)(e.videoChoice.options) : e.videoChoice.options) : null;
|
|
937
|
+
// THE INFO LINK CARRIES THE ACCOUNT'S PLAN TIER ON A WIDGET HOST (2026-11-12, tool-hints.mjs accountInfoUrl): read once,
|
|
938
|
+
// only when this refusal names the pricing page, with a short wait; a failed read keeps the plain link (every plan shown).
|
|
939
|
+
if (_widget && (msg.includes(ACCOUNT_INFO_URL) || _hints.some((h) => h && String(h.do || '').includes(ACCOUNT_INFO_URL)))) {
|
|
940
|
+
let _tier = '';
|
|
941
|
+
try { const c = await Promise.race([apiGet('/api/credits'), new Promise((r) => setTimeout(() => r(null), 2500))]); _tier = String((c && (c.planFamily || c.data?.planFamily)) || ''); } catch {}
|
|
942
|
+
const _wt = withInfoTier(msg, _hints, _tier); msg = _wt.text; _hints.splice(0, _hints.length, ..._wt.hints);
|
|
943
|
+
}
|
|
944
|
+
if (_widget) msg = stripPlanPromo(msg, PLAN_PITCH_RE);
|
|
945
|
+
return noPitchOnWidget(withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, ...(Number(e?.status) === 429 && e?.retryAfter > 0 ? { retryAfterSeconds: Number(e.retryAfter) } : {}), connector: e?.connector ? String(e.connector) : '', ...(_vcOpts ? { options: _vcOpts } : {}), ...(e?.remedy && typeof e.remedy === 'object' && typeof e.remedy.code === 'string' ? { code: e.remedy.code, remedy: e.remedy } : {}) } } }, _hints)); // `options` = the video choice's three ways forward, structured, so /v1 answers its 402 with them
|
|
900
946
|
}
|
|
901
947
|
};
|
|
902
948
|
return outer;
|
|
@@ -2296,7 +2342,7 @@ export const INBOX_SOURCES = [
|
|
|
2296
2342
|
// meta_dm reads Messenger, instagram_dm reads Instagram (direct login account, or the Page-linked one).
|
|
2297
2343
|
{ source: 'meta_dm', provider: 'meta', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Meta direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' }, readAll: '/api/meta/dm-inbox', readAllQuery: { platform: 'MESSENGER' } },
|
|
2298
2344
|
{ source: 'instagram_dm', provider: 'instagram', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Instagram direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' }, readAll: '/api/meta/dm-inbox', readAllQuery: { platform: 'INSTAGRAM' }, query: { platform: 'INSTAGRAM' } }, // `query` rides on the per-thread read too: without it a direct (Instagram Login) thread id was read on the Page path and refused, measured 2026-10-25. A DIRECT (Instagram Login) account's inbox — same routes, resolved through igDmCtx by `account` (2026-09-02)
|
|
2299
|
-
{ source: 'meta_webhook', provider: 'meta', read: '/api/meta/webhooks/events', reply: null, kind: 'pushed', label: 'Pushed Meta event' },
|
|
2345
|
+
{ source: 'meta_webhook', provider: 'meta', read: '/api/meta/webhooks/events', reply: null, kind: 'pushed', label: 'Pushed Meta event', query: { noWaMessages: '1' } }, // WhatsApp conversations are the `whatsapp` row's (2026-11-12), so they are not listed twice
|
|
2300
2346
|
{ source: 'threads_mention', provider: 'threads', read: '/api/threads/mentions', reply: '/api/threads/reply', kind: 'mention', label: 'Threads mention' },
|
|
2301
2347
|
{ 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' } },
|
|
2302
2348
|
{ source: 'google_business', provider: 'google_business', read: '/api/google-business/reviews', reply: '/api/google-business/review-reply', kind: 'review', label: 'Google review' },
|
|
@@ -2327,6 +2373,13 @@ export const INBOX_SOURCES = [
|
|
|
2327
2373
|
// chat, because a single call spans every chat that wrote in and the reply target is a property of each ROW, not
|
|
2328
2374
|
// of the envelope. Its `reply` is the ordinary publish route: on Telegram a reply IS a message to a chat id.
|
|
2329
2375
|
{ source: 'telegram_dm', provider: 'telegram', read: '/api/telegram/messages', reply: '/api/telegram/post', kind: 'dm', label: 'Telegram message' },
|
|
2376
|
+
// WHATSAPP, added 2026-11-12 (customer report: Meta's own onboarding task told a customer to "reply to this message in
|
|
2377
|
+
// Live Chat" on Hermoso, and nothing in Hermoso showed it). The Cloud API has NO endpoint that returns past messages, so
|
|
2378
|
+
// this reads the 48-hour per-account buffer the webhook fills (lib/whatsapp-inbox.mjs). Without a conversationId it lists
|
|
2379
|
+
// every conversation, already one row each; with one it reads that conversation and folds it like any DM. The native id
|
|
2380
|
+
// is `<phone number id>|<person>` (their phone, or their business-scoped user id), which is everything a reply needs.
|
|
2381
|
+
// ANSWERABLE ONLY INSIDE THE 24-HOUR WINDOW: the reply route refuses for free, in plain words, once it has closed.
|
|
2382
|
+
{ source: 'whatsapp', provider: 'whatsapp', read: '/api/whatsapp/inbox/thread', reply: '/api/whatsapp/inbox/reply', kind: 'dm', label: 'WhatsApp message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'id', from: 'list_inbox (the whatsapp item id after "whatsapp:")' }, readAll: '/api/whatsapp/inbox' },
|
|
2330
2383
|
// INTERCOM, added 2026-10-27 — the brand's own SUPPORT inbox, which is where its customers talk most and which a list
|
|
2331
2384
|
// titled "everything people said to this brand" left out. Reads the OPEN conversations (the existing
|
|
2332
2385
|
// list_intercom_conversations route, one row per conversation, so nothing is folded). The reply route carries the
|
|
@@ -2516,6 +2569,9 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2516
2569
|
...(src.kind === 'dm' && row.story && typeof row.story === 'object' ? { story: row.story } : {}),
|
|
2517
2570
|
...(src.kind === 'dm' && row.share && typeof row.share === 'object' ? { share: row.share } : {}),
|
|
2518
2571
|
...(src.kind === 'dm' && typeof row.conversationId === 'string' && row.conversationId ? { conversationId: row.conversationId } : {}),
|
|
2572
|
+
// WHATSAPP: whether a free-form reply is still allowed, in words, and the last few messages of the conversation so an
|
|
2573
|
+
// agent answers with the context in hand (the Cloud API keeps no history, so this is the only place it exists).
|
|
2574
|
+
...(source === 'whatsapp' ? { windowOpen: row.windowOpen ?? null, windowClosesAt: row.windowClosesAt || null, ...(typeof row.windowNote === 'string' ? { windowNote: row.windowNote } : {}), ...(row.phoneNumberId ? { phoneNumberId: String(row.phoneNumberId) } : {}), ...(Array.isArray(row.recent) ? { recent: row.recent } : {}) } : {}),
|
|
2519
2575
|
};
|
|
2520
2576
|
}
|
|
2521
2577
|
|
|
@@ -2772,8 +2828,16 @@ export const WIDGET_HOST_CONNECTOR_TOOLS = Object.freeze(new Set([...WIDGET_HOST
|
|
|
2772
2828
|
// per request, so OpenAI's scan sees a stable list; enforceWidgetCap() is only a guard for a registry that grew past the
|
|
2773
2829
|
// cap since the last ranking (it drops the lowest-ranked families, the same for every session of a deploy).
|
|
2774
2830
|
// CHATGPT_ROSTER=curated is the kill switch back to the 2026-10-25 curated list, with no code change.
|
|
2775
|
-
export const CHATGPT_TOOL_CAP =
|
|
2776
|
-
export const CHATGPT_HARD_TOOL_LIMIT = 1000; // ChatGPT's own refusal point
|
|
2831
|
+
export const CHATGPT_TOOL_CAP = 1000; // 960 -> 1000 (2026-11-11, owner: "if the chatgpt limit is 1,000 tools shouldnt we just go by that limit?"): ChatGPT refuses only a list that EXCEEDS 1,000 (424 "tools/list pagination exceeded 1000 tools"), and enforceWidgetCap drops the lowest-ranked family at runtime before the list could ever pass the cap, so headroom bought nothing; x_ads and discord join
|
|
2832
|
+
export const CHATGPT_HARD_TOOL_LIMIT = 1000; // ChatGPT's own refusal point: a list LONGER than this is refused, so the cap may equal it
|
|
2833
|
+
// THE EXTENSION TOOLS ARE ON THE LIST TOO, SO THE FAMILIES GET THE CAP MINUS THEIR ROOM (2026-11-12, measured on prod).
|
|
2834
|
+
// The cap counted the core and the families only; the ChatGPT extensions app (registerChatGPTExtensions, on for the
|
|
2835
|
+
// published plugin since 2026-11-11 through HERMOSO_CHATGPT_EXT_PUBLISHED) registers EXT_TOOL_NAMES after the cap ran.
|
|
2836
|
+
// With the cap at 960 that was 944 + 16; with the cap at ChatGPT's own 1,000, a live tools/list as openai-mcp on the
|
|
2837
|
+
// plain URL answered 1,006 tools, which ChatGPT refuses whole. The room is reserved whether or not this process has
|
|
2838
|
+
// the extension flags on, so the committed ranking (tools/chatgpt-roster-rank.mjs) and every session agree.
|
|
2839
|
+
export const CHATGPT_EXT_TOOL_ROOM = EXT_TOOL_NAMES.length;
|
|
2840
|
+
export const CHATGPT_FAMILY_BUDGET = CHATGPT_TOOL_CAP - CHATGPT_EXT_TOOL_ROOM;
|
|
2777
2841
|
export const chatgptRosterMode = (env = process.env) => (String(env?.CHATGPT_ROSTER ?? '').trim().toLowerCase() === 'curated' ? 'curated' : 'ranked');
|
|
2778
2842
|
// The groups the ANONYMOUS discovery roster lists (mcp/http.mjs serveAnonDiscovery) when no `?tools=` was stated. A
|
|
2779
2843
|
// widget host in ranked mode gets every group (the families decide), so the scanner's anonymous read and the session agree.
|
|
@@ -2804,7 +2868,7 @@ export function widgetRosterCount(names) {
|
|
|
2804
2868
|
}
|
|
2805
2869
|
// Families in ranked order, kept while they fit under the cap. Everything after the first that does not fit is dropped,
|
|
2806
2870
|
// so a cut always removes the lowest-ranked families and never a middle one.
|
|
2807
|
-
export function capFamilies(names, ordered = CHATGPT_ROSTER_FAMILIES, cap =
|
|
2871
|
+
export function capFamilies(names, ordered = CHATGPT_ROSTER_FAMILIES, cap = CHATGPT_FAMILY_BUDGET) {
|
|
2808
2872
|
const { core, byFamily } = widgetRosterCount(names);
|
|
2809
2873
|
let total = core; const kept = []; const dropped = []; let full = false;
|
|
2810
2874
|
for (const fam of ordered) {
|
|
@@ -2868,6 +2932,23 @@ export function parseToolScope(raw) {
|
|
|
2868
2932
|
return { groups: asked };
|
|
2869
2933
|
}
|
|
2870
2934
|
|
|
2935
|
+
// THE URL OUR OWN APP GAVE CHATGPT USERS IS THE PLAIN URL ON CHATGPT (2026-11-12, a paying customer's report).
|
|
2936
|
+
// From 2026-09-01 the in-app ChatGPT setup step handed out `/mcp?tools=core,research,create,channels,channel_admin,
|
|
2937
|
+
// files,workspace` (public/app.js CONNECT_TIME_TOOLS): every group except `ads` and `analytics`, which were left out
|
|
2938
|
+
// on size "and a user who wants those can append them". Nobody appends a group to a URL they were told to copy. On
|
|
2939
|
+
// 2026-11-03 the owner ruled that ChatGPT lists everything it accepts, and the plain URL does; a connection made
|
|
2940
|
+
// with the old URL kept its seven groups. ChatGPT is not offered find_tools / call_tool (no generic executor), so on
|
|
2941
|
+
// that connection every paid-ads and analytics tool did not exist: meta_page_insights was listed, its description
|
|
2942
|
+
// said "use meta_insights for paid ad performance", and meta_insights could not be reached at all. Measured on prod
|
|
2943
|
+
// that day: 515 tools on the old URL as openai-mcp, none of meta_insights, list_meta_ads, google_ads_report,
|
|
2944
|
+
// analyze_campaigns or analytics_report; one customer's ChatGPT and Codex sessions were all of that URL's traffic.
|
|
2945
|
+
// So on a widget host in ranked mode EXACTLY that group set (any order) is read as no scope at all. It is the one
|
|
2946
|
+
// scope we wrote for them; any other `?tools=` a ChatGPT user typed is still theirs and still wins, every other host
|
|
2947
|
+
// keeps the seven groups (it has find_tools / call_tool for the rest), and CHATGPT_ROSTER=curated leaves it alone.
|
|
2948
|
+
export const LEGACY_WIDGET_CONNECT_GROUPS = Object.freeze(['core', 'research', 'create', 'channels', 'channel_admin', 'files', 'workspace']);
|
|
2949
|
+
export const isLegacyWidgetConnectScope = (groups) => Array.isArray(groups) && new Set(groups).size === LEGACY_WIDGET_CONNECT_GROUPS.length && LEGACY_WIDGET_CONNECT_GROUPS.every((g) => groups.includes(g));
|
|
2950
|
+
export const widgetHostScope = (scope, widgetHost, env = process.env) => (widgetHost && scope && !scope.app && !scope.directory && chatgptRosterMode(env) === 'ranked' && isLegacyWidgetConnectScope(scope.groups) ? { ...scope, groups: null, legacyConnectUrl: true } : scope);
|
|
2951
|
+
|
|
2871
2952
|
// Tools an Apps-SDK host (ChatGPT) must not be offered. See the seam in registerTools for why, and note this is
|
|
2872
2953
|
// a HOST rule, not a capability we removed: every other surface still offers both.
|
|
2873
2954
|
// `billing_status` JOINED THEM 2026-09-30, off OpenAI's automated plugin scan ("needs further review"): it reports the
|
|
@@ -2898,16 +2979,29 @@ export const WIDGET_MONEY_MOVEMENT_TOOLS = Object.freeze(['refund_stripe_charge'
|
|
|
2898
2979
|
// manage_analytics_measurement_protocol_secret — same finding; its list and create both print GA4 api_secret values into
|
|
2899
2980
|
// the reply, and a secret does not belong in the conversation (the 2026-10-16 credentials-in-chat holds), so it is
|
|
2900
2981
|
// withheld rather than split.
|
|
2901
|
-
// set_openai_ads_negative_keywords + create_reddit_ads_lead_form — "name inconsistent with its behavior": both vendors
|
|
2982
|
+
// set_openai_ads_negative_keywords (+ create_reddit_ads_lead_form, since RETIRED from the roster entirely) — "name inconsistent with its behavior": both vendors
|
|
2902
2983
|
// retired the endpoint, so the tool can only refuse; no name would describe that, and a tool that never acts has no
|
|
2903
2984
|
// place in the list.
|
|
2985
|
+
// THE STORY FILM TOOLS LIVE IN THE AI AD GENERATOR CHATGPT APP (2026-11-11, the owner: no sending ChatGPT users to the
|
|
2986
|
+
// web app when a ChatGPT app of ours carries the feature). The all-in-one ChatGPT app keeps its reviewed roster (the four
|
|
2987
|
+
// would push a ranked family off its cap — chatgpt-full-roster-check §0b), so there they stay withheld, and the answer
|
|
2988
|
+
// names the focused app that lists them (WIDGET_APP_OFFERS.create, ?tools=chatgpt-create). Until that app is published
|
|
2989
|
+
// the answer also gives the web app, because nobody can add an app still in review; HERMOSO_CHATGPT_CREATE_PUBLISHED=1
|
|
2990
|
+
// (the HERMOSO_CHATGPT_EXT_PUBLISHED precedent) drops the fallback. Read per call, so the env flip needs no code change.
|
|
2991
|
+
export const STORY_FILM_TOOLS = Object.freeze(['list_story_cards', 'get_story_card', 'start_story_film', 'advance_story_film']);
|
|
2992
|
+
export const CHATGPT_CREATE_PUBLISHED_ENV = 'HERMOSO_CHATGPT_CREATE_PUBLISHED';
|
|
2993
|
+
export const chatgptCreatePublished = (env = process.env) => String(env?.[CHATGPT_CREATE_PUBLISHED_ENV] ?? '').trim() === '1';
|
|
2994
|
+
export const storyFilmsWithheldWhy = (env = process.env) => `story films are in Hermoso's "${APP_TITLES.create}" app for ChatGPT`
|
|
2995
|
+
+ (chatgptCreatePublished(env) ? `; the user adds it from ChatGPT's apps and asks for a story film there` : `, which is not published yet; until it is, story films run in the Hermoso web app (Studio, Story films) and on every other MCP host`);
|
|
2996
|
+
// Tools a widget host withholds from the all-in-one roster but a FOCUSED ChatGPT app lists, by app (APP_SCOPE_TOKENS).
|
|
2997
|
+
export const WIDGET_APP_OFFERS = Object.freeze({ create: new Set(STORY_FILM_TOOLS) });
|
|
2998
|
+
export const widgetAppOffers = (name, app) => !!app && !!WIDGET_APP_OFFERS[app] && WIDGET_APP_OFFERS[app].has(name);
|
|
2904
2999
|
export const WIDGET_WITHHELD_WHY = Object.freeze({
|
|
2905
3000
|
submit_tiktok_ads_verification: 'it sends a legal name and a government ID or business licence number, which are entered in TikTok Ads Manager instead',
|
|
2906
3001
|
merchant_issue_help: 'Google defines the actions on each issue as opaque flows, so they are not offered as separate tools here; list_merchant_issues names the issue and Merchant Center fixes it',
|
|
2907
3002
|
trigger_merchant_issue_action: 'Google defines the actions on each issue as opaque flows, so they are not offered as separate tools here; list_merchant_issues names the issue and Merchant Center fixes it',
|
|
2908
3003
|
manage_analytics_measurement_protocol_secret: 'it returns GA4 Measurement Protocol secrets, which are managed in Google Analytics rather than in a conversation',
|
|
2909
3004
|
set_openai_ads_negative_keywords: 'OpenAI no longer serves account-level negative keywords, so there is nothing it can set',
|
|
2910
|
-
create_reddit_ads_lead_form: 'Reddit retired onsite lead forms, so there is nothing it can create',
|
|
2911
3005
|
save_location: 'a place is saved from the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places and any @name in a prompt uses one',
|
|
2912
3006
|
delete_location: 'a place is removed in the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places',
|
|
2913
3007
|
get_instructions: 'every tool description on this host is already the full one (SLIM_TOOL_DESCRIPTIONS is never applied here), so there is nothing to fetch',
|
|
@@ -2976,8 +3070,9 @@ export const WIDGET_HOST_SPLITS = Object.freeze(withSplitAccounts({
|
|
|
2976
3070
|
splitOp('delete_meta_comment', { action: 'delete' }, ['commentId!', 'confirm', ...META_ACCT], splitW(true, true), 'Delete a Facebook or Instagram comment', 'Use this when a comment on the brand’s own Facebook or Instagram post must be removed for good. PERMANENT: pass confirm:true only after the user agreed; hide_meta_comment is the reversible option. Free.', { confirm: 'must be true to delete; get the user’s yes first' }),
|
|
2977
3071
|
],
|
|
2978
3072
|
manage_meta_post: [
|
|
2979
|
-
splitOp('edit_facebook_post', { action: 'edit', target: 'facebook' }, ['brand', 'postId!', 'message
|
|
3073
|
+
splitOp('edit_facebook_post', { action: 'edit', target: 'facebook' }, ['brand', 'postId!', 'message', 'aiGenerated', 'pageId'], splitW(true, true), 'Edit the text or AI label of a published Facebook post', 'Use this when the text of a Facebook post the brand already published needs changing, or its AI-generated label should be added (aiGenerated true) or cleared (false). Replaces the post’s message in public. Instagram and Threads posts cannot be edited after publishing. Get postId from list_meta_posts. Free.', { message: 'the new post text' }),
|
|
2980
3074
|
splitOp('delete_meta_post', { action: 'delete' }, ['brand', 'postId!', 'target', 'pageId', 'confirm', 'confirmName', 'confirmChildren'], splitW(true, true), 'Delete a published Facebook, Instagram or Threads post', 'Use this when a post the brand published on Facebook, Instagram or Threads must be removed. PERMANENT. For Instagram call it first WITHOUT confirm: nothing is deleted and the reply shows the post’s caption, likes, comments and carousel cards; show the user that, then call again with confirm:true (plus confirmName / confirmChildren when the reply asks). An Instagram carousel is deleted as a whole album. Get postId from list_meta_posts or list_instagram_media. Free.', { confirm: 'must be true to delete (permanent)' }),
|
|
3075
|
+
splitOp('manage_facebook_reel_collaborators', { action: 'collaborators', target: 'facebook' }, ['brand', 'postId!', 'invitePageIds', 'pageId'], splitW(false, false), 'List or invite collaborator Pages on a Facebook Reel', 'Use this when a Facebook Reel the brand published should be co-authored with another Facebook Page: pass invitePageIds to invite (10 invitations per Page per day), or nothing to list the collaborators and whether each accepted. postId is the Reel’s video id. Free.'),
|
|
2981
3076
|
splitOp('set_facebook_video_cover', { action: 'cover', target: 'facebook' }, ['brand', 'postId!', 'coverAtMs', 'coverImageUrl', 'videoUrl', 'pageId'], splitW(true, true), 'Replace the cover of a published Facebook video or Reel', 'Use this when a Facebook video or Reel the brand already published needs a new cover: a frame (coverAtMs) or a Hermoso-hosted picture (coverImageUrl). postId is the video id. Instagram covers cannot be changed after publishing. Free.'),
|
|
2982
3077
|
],
|
|
2983
3078
|
set_youtube_watermark: [
|
|
@@ -3185,14 +3280,15 @@ export const WIDGET_HOST_SPLITS = Object.freeze(withSplitAccounts({
|
|
|
3185
3280
|
// op tool name → the multi-op tool it comes from.
|
|
3186
3281
|
export const WIDGET_SPLIT_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_SPLITS).flatMap(([from, ops]) => ops.map((o) => [o.name, from]))));
|
|
3187
3282
|
// Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
|
|
3188
|
-
|
|
3189
|
-
|
|
3283
|
+
// `app` (optional) is the focused app of the session: a tool that app offers (WIDGET_APP_OFFERS) is not hidden there.
|
|
3284
|
+
export const widgetHostHides = (name, widgetHost, app = null) => (widgetHost
|
|
3285
|
+
? ((WITHHELD_FROM_WIDGET_HOSTS.has(name) && !widgetAppOffers(name, app)) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
|
|
3190
3286
|
: (Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name) || Object.prototype.hasOwnProperty.call(WIDGET_SPLIT_OF, name)));
|
|
3191
3287
|
// HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
|
|
3192
3288
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
3193
3289
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
3194
3290
|
// edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
|
|
3195
|
-
export const ON_DEMAND_TOOLS = new Set(['submit_to_gallery', 'list_gallery_submissions', 'withdraw_from_gallery', 'save_location', 'delete_location', '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_short', 'finish_short', 'rewrite_ad_line', 'save_video_preset', 'list_video_presets', 'update_video_preset', 'delete_video_preset', 'make_from_preset', 'list_winning_ads']); // rewrite_ad_line (2026-11-08): multiply_ad's description and its change note name it // list_winning_ads (2026-11-08): multiply_ad's description names it // the five video-preset tools (2026-11-08): clone_video's description names save_video_preset, whose reply names make_from_preset; list_video_presets names the rest // make_short / finish_short (2026-11-08): clone_video's description names make_short, and make_short's description and reply name finish_short // 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
|
|
3291
|
+
export const ON_DEMAND_TOOLS = new Set(['list_story_cards', 'get_story_card', 'start_story_film', 'advance_story_film', 'submit_to_gallery', 'list_gallery_submissions', 'withdraw_from_gallery', 'save_location', 'delete_location', '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_short', 'finish_short', 'rewrite_ad_line', 'save_video_preset', 'list_video_presets', 'update_video_preset', 'delete_video_preset', 'make_from_preset', 'list_winning_ads']); // rewrite_ad_line (2026-11-08): multiply_ad's description and its change note name it // list_winning_ads (2026-11-08): multiply_ad's description names it // the five video-preset tools (2026-11-08): clone_video's description names save_video_preset, whose reply names make_from_preset; list_video_presets names the rest // make_short / finish_short (2026-11-08): clone_video's description names make_short, and make_short's description and reply name finish_short // 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
|
|
3196
3292
|
|
|
3197
3293
|
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
3198
3294
|
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
@@ -3219,6 +3315,7 @@ export const LEGACY_TOOL_NAMES = Object.assign(Object.create(null), {
|
|
|
3219
3315
|
post_to_reddit: { gone: 'reddit' }, edit_reddit_post: { gone: 'reddit' }, delete_reddit_post: { gone: 'reddit' },
|
|
3220
3316
|
list_reddit_posts: { gone: 'reddit' }, list_reddit_comments: { gone: 'reddit' }, reply_to_reddit_comment: { gone: 'reddit' },
|
|
3221
3317
|
reddit_post_stats: { gone: 'reddit' },
|
|
3318
|
+
create_reddit_ads_lead_form: { gone: 'reddit_lead_form' }, // retired 2026-11-11: Reddit's create answers 410 Gone
|
|
3222
3319
|
});
|
|
3223
3320
|
// Add one retired name. Refuses a malformed spec rather than storing one legacyToolAnswer could not act on; a row
|
|
3224
3321
|
// that already exists is left as it is (the table is append-only, never rewritten at runtime).
|
|
@@ -3243,23 +3340,74 @@ export const withStaleNote = (out, ctx) => {
|
|
|
3243
3340
|
// ── A CONNECTED ACCOUNT THAT HAS MADE NOTHING IS OFFERED ONE CONCRETE FIRST ASK (2026-10-19) ─────────────────────────
|
|
3244
3341
|
// Since the ChatGPT app was published (10-15), most accounts whose agent was ChatGPT had made nothing, and a live "make me
|
|
3245
3342
|
// a video ad for my coffee shop" on a connected account was answered by ChatGPT's own slideshow with no Hermoso call. So
|
|
3246
|
-
// the first successful reply of a ChatGPT
|
|
3343
|
+
// the first successful reply of a session (ChatGPT only until 2026-11-12, every host since) on an account with no image or video render (the server decides:
|
|
3247
3344
|
// mcpFirstRunFor, read once per session in http.mjs) ends with one next step the user can say yes to: their brand's
|
|
3248
3345
|
// video ad when a brand is saved, else drafting the brand from their site. A tool that is already making something
|
|
3249
3346
|
// never carries it. Agent-facing, like STALE_ROSTER_NOTE; the same advice rides _meta as a hint. Pure, so a check RUNS it.
|
|
3250
3347
|
export const FIRST_RUN_QUIET_TOOLS = new Set(['plan_ad', 'render_ad', 'generate_image', 'generate_video', 'make_template_ad', 'make_motion_film', 'clone_video', 'clone_static', 'remix_static', 'draft_brand', 'edit_image', 'multiply_ad', 'get_job', 'list_jobs']);
|
|
3251
3348
|
export const FIRST_RUN_APP_URL = 'https://app.hermoso.ai';
|
|
3349
|
+
// EVERY HOST, AND BEFORE THE FIRST CALL (2026-11-12, the owner: "does it automatically put their AI into running the
|
|
3350
|
+
// onboarding and knowing how to proceed?"). 31% of accounts that connected from ChatGPT never asked anything, and the
|
|
3351
|
+
// note above only fired on ChatGPT and only after a tool call that, on those accounts, never came. So http.mjs now reads
|
|
3352
|
+
// mcpFirstRunFor for every host and a fresh account's session gets ONE line at the very top of its `instructions`
|
|
3353
|
+
// (withFirstRunLine), which a host reads at initialize, before the user types anything. Hosts that ignore instructions
|
|
3354
|
+
// still get the first-reply note.
|
|
3355
|
+
//
|
|
3356
|
+
// WHICH STEPS A SESSION CAN OFFER is read off the instructions that session is served: every instructions string is
|
|
3357
|
+
// already checked to name only tools its roster lists (chatgpt-review-findings-check, app-scope-check), so a focused
|
|
3358
|
+
// app or the Claude directory cage, whose instructions name no render tool, is never told to offer a render.
|
|
3359
|
+
//
|
|
3360
|
+
// THE 2 KB HEAD. The line goes FIRST, so every byte of it pushes the head down. FIRST_RUN_LINE_MAX bounds it (the brand
|
|
3361
|
+
// name is cut to fit) and tools/mcp-first-run-guidance-check.mjs proves that, with the longest line on top, every
|
|
3362
|
+
// element the head checks pin still ends inside 2,048 characters, and that the ChatGPT and directory strings still fit
|
|
3363
|
+
// whole. To make that room the head was shortened on 2026-11-12, saying the same things in fewer words: the ACT line
|
|
3364
|
+
// lost its restatement of which render tools take `model`, the PUBLISH line its repeat of "never say Hermoso lacks a
|
|
3365
|
+
// platform" (still said below), the research bullet its adjectives; the ChatGPT string lost wording, not rules; the
|
|
3366
|
+
// directory string a sentence its PUBLISH bullet already says (upload_file turns the user's own file into a URL).
|
|
3367
|
+
export const FIRST_RUN_LINE_MAX = 146;
|
|
3368
|
+
const FIRST_RUN_NAME_MAX = 30;
|
|
3369
|
+
const firstRunBrandName = (fr, max) => String(fr?.brand?.name || '').replace(/["“”\n\r]/g, '').replace(/\s+/g, ' ').trim().slice(0, max).trim();
|
|
3370
|
+
const FIRST_RUN_ALL = Object.freeze({ brand: true, ad: true, connectors: true });
|
|
3371
|
+
export const firstRunOffers = (instructions) => {
|
|
3372
|
+
const s = String(instructions || '');
|
|
3373
|
+
const has = (n) => new RegExp(`(^|[^a-z0-9_])${n}([^a-z0-9_]|$)`).test(s);
|
|
3374
|
+
return { brand: has('draft_brand'), ad: has('plan_ad') && has('render_ad'), connectors: has('list_connectors') };
|
|
3375
|
+
};
|
|
3376
|
+
// The line itself: agent-facing, one sentence, a first step the agent can take before the user has asked for anything.
|
|
3377
|
+
export function firstRunLine(fr, offer = FIRST_RUN_ALL) {
|
|
3378
|
+
if (!fr || fr.fresh !== true) return null;
|
|
3379
|
+
const o = offer || FIRST_RUN_ALL;
|
|
3380
|
+
const name = firstRunBrandName(fr, FIRST_RUN_NAME_MAX);
|
|
3381
|
+
if (fr.unclaimed === true) return `NEW ACCOUNT, no credits: start by sending the user to ${FIRST_RUN_APP_URL} to claim free starter credits${!name && o.brand ? ', then set up their brand (draft_brand)' : ''}.`;
|
|
3382
|
+
if (name && o.ad) {
|
|
3383
|
+
// UTF-8 bytes, not characters: ChatGPT's budget is bytes, and a name in another script costs two or three per letter.
|
|
3384
|
+
const say = (n) => `NEW ACCOUNT, nothing made yet: start by offering a 10-second video ad for ${n}, their saved brand (plan_ad, render_ad).`;
|
|
3385
|
+
let n = name; while (n && new TextEncoder().encode(say(n)).length > FIRST_RUN_LINE_MAX) n = n.slice(0, -1).trim();
|
|
3386
|
+
if (n !== name && n.includes(' ')) n = n.slice(0, n.lastIndexOf(' ')).trim(); // a cut name ends on a whole word
|
|
3387
|
+
return n ? say(n) : `NEW ACCOUNT, nothing made yet: start by offering a 10-second video ad for their saved brand (plan_ad, render_ad).`;
|
|
3388
|
+
}
|
|
3389
|
+
if (!name && o.brand) return `NEW ACCOUNT, nothing made yet: start by asking for the user's website (draft_brand)${o.ad ? ', then offer a 10-second video ad (plan_ad, render_ad)' : ' so every post is made for their brand'}.`;
|
|
3390
|
+
if (o.connectors) return 'NEW ACCOUNT, nothing made yet: start by asking what the user wants done first; list_connectors shows what is connected.';
|
|
3391
|
+
return null;
|
|
3392
|
+
}
|
|
3393
|
+
// The session's instructions: the line on top for a fresh account, the base string untouched otherwise.
|
|
3394
|
+
export const withFirstRunLine = (instructions, fr) => {
|
|
3395
|
+
const line = firstRunLine(fr, fr?.offer || firstRunOffers(instructions));
|
|
3396
|
+
return line ? `${line}\n${instructions}` : instructions;
|
|
3397
|
+
};
|
|
3252
3398
|
export function firstRunNote(tool, fr, r) {
|
|
3253
3399
|
if (!fr || fr.fresh !== true || !r || r.isError || !Array.isArray(r.content) || FIRST_RUN_QUIET_TOOLS.has(String(tool || ''))) return null;
|
|
3254
|
-
const
|
|
3255
|
-
const
|
|
3256
|
-
|
|
3400
|
+
const o = fr.offer || FIRST_RUN_ALL;
|
|
3401
|
+
const name = firstRunBrandName(fr, 60);
|
|
3402
|
+
let ask, how, hint;
|
|
3403
|
+
if (name && o.ad) { ask = `Make a 10-second video ad for ${name}`; how = 'plan_ad with durationSeconds 10, then render_ad'; hint = { do: 'plan_ad({ durationSeconds: 10 }), then render_ad', why: `nothing has been made on this account yet; a short video ad for ${name} is the first thing to offer` }; }
|
|
3404
|
+
else if (!name && o.brand) { ask = 'Set up my brand from my website'; how = 'draft_brand with their website, or a one-line description when they have none'; hint = { do: "draft_brand({ domain: '<their website>' })", why: 'nothing has been made and no brand is saved; with the brand drafted, every ad is made for it' }; }
|
|
3405
|
+
else if (o.connectors) { ask = 'Show me which of my accounts are connected'; how = 'list_connectors'; hint = { do: 'list_connectors()', why: 'nothing has been made on this account yet; what is connected decides what can be done first' }; }
|
|
3406
|
+
else if (fr.unclaimed !== true) return null;
|
|
3257
3407
|
const claim = fr.unclaimed === true ? ` It has no credits yet: the free starter credits are claimed once in the Hermoso app at ${FIRST_RUN_APP_URL} (a quick check that they are a person), so offer that link first.` : '';
|
|
3258
3408
|
return {
|
|
3259
|
-
text: `Note for the assistant, not for the user: this account has not made anything with Hermoso yet.${claim} After you answer, offer the user one concrete next step they can say yes to, for example "${ask}" (${how})
|
|
3260
|
-
hint: fr.unclaimed === true ? { do: `send the user to ${FIRST_RUN_APP_URL} to claim the free starter credits`, why: 'the balance is zero until they do, so every paid first step would be refused' }
|
|
3261
|
-
: name ? { do: 'plan_ad({ durationSeconds: 10 }), then render_ad', why: `nothing has been made on this account yet; a short video ad for ${name} is the first thing to offer` }
|
|
3262
|
-
: { do: "draft_brand({ domain: '<their website>' })", why: 'nothing has been made and no brand is saved; with the brand drafted, every ad is made for it' },
|
|
3409
|
+
text: `Note for the assistant, not for the user: this account has not made anything with Hermoso yet.${claim}${ask ? ` After you answer, offer the user one concrete next step they can say yes to, for example "${ask}" (${how}).` : ''}`,
|
|
3410
|
+
hint: fr.unclaimed === true ? { do: `send the user to ${FIRST_RUN_APP_URL} to claim the free starter credits`, why: 'the balance is zero until they do, so every paid first step would be refused' } : hint,
|
|
3263
3411
|
};
|
|
3264
3412
|
}
|
|
3265
3413
|
// ── AN UNDECLARED ARGUMENT IS REFUSED, NEVER STRIPPED (2026-09-30) ──────────────────────────────────────────────
|
|
@@ -3297,7 +3445,9 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
|
3297
3445
|
if (spec.gone) {
|
|
3298
3446
|
const text = spec.gone === 'reddit'
|
|
3299
3447
|
? `${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.`
|
|
3300
|
-
:
|
|
3448
|
+
: spec.gone === 'reddit_lead_form'
|
|
3449
|
+
? `${name} is no longer offered: Reddit retired onsite lead forms (the API sunset on 2026-09-21, the create call answers 410 Gone since 2026-09-30, and ads still using one were paused), so nothing can create one. Existing forms stay readable with list_reddit_ads_lead_forms. For new leads from Reddit, use a form on the advertiser's own landing page inside a LEAD_GENERATION campaign (create_reddit_ads_campaign), measured with list_reddit_ads_pixels and send_reddit_ads_conversions. Reddit Ads itself is fully supported.`
|
|
3450
|
+
: `${name} is no longer offered by Hermoso.`;
|
|
3301
3451
|
return withStaleNote({ content: [{ type: 'text', text }], isError: false }, ctx);
|
|
3302
3452
|
}
|
|
3303
3453
|
const h = ctx.handleOf[spec.to];
|
|
@@ -3763,7 +3913,7 @@ export function stripOutputSchema(def) {
|
|
|
3763
3913
|
export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
|
|
3764
3914
|
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.",
|
|
3765
3915
|
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.",
|
|
3766
|
-
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook or X post link (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).); in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point
|
|
3916
|
+
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook or X post link (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).); in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point before the edit. 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.",
|
|
3767
3917
|
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.",
|
|
3768
3918
|
create_openai_ads_feed: "Create an empty feed on ChatGPT Ads, with a name and the countries it serves (ISO codes). By default it is a product feed, and products are added afterwards with update_openai_ads_feed_products. inventoryType 'hotel' asks for a hotel property feed instead, which OpenAI accepts only for advertisers in its hotel beta and refuses for everyone else. Needs ChatGPT Ads connected.",
|
|
3769
3919
|
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.",
|
|
@@ -3826,12 +3976,12 @@ export async function ingestFileParam(tool, args, upload = apiUploadUrl) {
|
|
|
3826
3976
|
// list is exactly the wording OpenAI's scan holds (chatgpt-tool-scan-holds), so ChatGPT keeps every full description.
|
|
3827
3977
|
export const SLIM_TOOL_DESCRIPTIONS = Object.freeze({
|
|
3828
3978
|
make_thumbnail: 'Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any "thumbnail", "video cover" or MrBeast-style packaging ask INSTEAD of generate_image. Default: 4 variants at 1K (YouTube shows 1280x720 at most), each billed; 2K or 4K only when the user asks. dryRun:true prices it free. The headline overlay is free. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. ALWAYS check the returned postRenderCheck against the image before presenting it.',
|
|
3829
|
-
schedule_post: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): none named is refused at scheduling with the Pages listed, so ask the user which. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default.',
|
|
3979
|
+
schedule_post: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages), for Facebook and for an Instagram that posts through a Page: none named is refused at scheduling with the Pages listed, so ask the user which. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default.',
|
|
3830
3980
|
post_to_x: '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. Text is NEVER truncated. 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. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. Needs X connected (Settings > Connectors > X).',
|
|
3831
3981
|
clip_video: 'Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. SUBTITLES ARE BURNED IN BY DEFAULT (white sentence case timed by the spoken words, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE (per-sentence, not word-level); never promise frame-accurate sync.',
|
|
3832
3982
|
post_to_bluesky: 'Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Returns the post\'s public bsky.app URL. Connect at Settings > Connectors > Bluesky.',
|
|
3833
3983
|
make_explainer: '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. 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`). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT; the film ships clean unless the user asks; `captions:true` adds held key points and `subtitles:true` adds subtitles timed to the narration. Takes SEVERAL minutes — one image render per frame; NOT for a short product ad: use render_ad, generate_video or make_template_ad.',
|
|
3834
|
-
multiply_ad: 'MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and
|
|
3984
|
+
multiply_ad: 'MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and the source\'s own captions and on-screen text stay exactly as they are. ONE JOB PER VARIANT: get_job each until done; never describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. No ad: list_winning_ads. New words: rewrite_ad_line.',
|
|
3835
3985
|
set_post_refill: 'Turn AUTOPILOT POSTING (autoposting, the posting refill) on, change it or pause it. Available on paid plans: on the Free plan switching it on is refused, so relay that and do not retry. PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled).',
|
|
3836
3986
|
post_to_linkedin_page: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
|
|
3837
3987
|
post_to_telegram: 'Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand\'s own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel\'s @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. 0 credits. Connect at Settings > Connectors > Telegram.',
|
|
@@ -3983,7 +4133,7 @@ async function regateForWorkspace(ctx, pre = null) {
|
|
|
3983
4133
|
// A HOST OR CAGE RULE IS NOT A CONNECTOR RULE (2026-09-30): re-gating used to re-enable every listed-group tool
|
|
3984
4134
|
// the new workspace could serve, which re-listed buy_credits on ChatGPT (and a caged tool in the directory) after
|
|
3985
4135
|
// a use_brand. Those holds do not depend on the workspace, so they are left exactly as the build set them.
|
|
3986
|
-
if (widgetHostHides(name, ctx.widgetHost) || toolHeldBackByDirectory(name, grp, ctx)) continue;
|
|
4136
|
+
if (widgetHostHides(name, ctx.widgetHost, ctx.app) || toolHeldBackByDirectory(name, grp, ctx)) continue;
|
|
3987
4137
|
const hold = toolHeldBackByConnectors(name, ctx.conn) && !widgetHostUngated(name, ctx);
|
|
3988
4138
|
try { if (hold) { h.disable(); disabled++; } else { h.enable(); enabled++; } } catch {}
|
|
3989
4139
|
}
|
|
@@ -3998,7 +4148,7 @@ const sessionBound = (factory, ctx) => { const h = factory(ctx); try { h._hermos
|
|
|
3998
4148
|
export const holdReasonFor = (name, ctx) => {
|
|
3999
4149
|
if (heldForNoShopifyStoreOf(canonicalToolName(name), ctx.conn)) return 'shopify_store'; // a Shopify tool on an account with no Shopify store (roster-scope.mjs) — its own answer, not "not available yet"
|
|
4000
4150
|
if (toolWithheldByServer(name, ctx.conn)) return 'unavailable'; // the server does not offer this capability right now (toolWithheldByServer)
|
|
4001
|
-
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost) return 'host_policy';
|
|
4151
|
+
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost && !widgetAppOffers(name, ctx.app)) return 'host_policy';
|
|
4002
4152
|
if (EXECUTOR_TOOLS.has(name) && ctx.widgetHost) return 'host_policy'; // OpenAI's generic-executor rule — see EXECUTOR_TOOLS (its own sentence below)
|
|
4003
4153
|
// A FOCUSED APP'S CAGE ANSWERS BEFORE THE CONNECTOR GATE (2026-11-07, measured live on ?tools=app-social): a tool that
|
|
4004
4154
|
// lives in ANOTHER app was answered "needs the meta connection, connect it" when the workspace lacked Meta, which sends
|
|
@@ -4026,6 +4176,7 @@ export const holdHints = (name, why, ctx = null) => {
|
|
|
4026
4176
|
if (why === 'shopify_store') return [{ do: 'do not offer this here; if the user runs a Shopify store, tell them to install Hermoso from the Shopify App Store and open it from their Shopify admin', why: `${name} ${SHOPIFY_STORE_NOTE}` }];
|
|
4027
4177
|
if (why === 'unavailable') return [{ do: 'do not offer this capability; carry on with the tools that are listed', why: `${name} is not available in Hermoso yet` }];
|
|
4028
4178
|
if (why === 'host_policy' && executorLike(name)) return [{ do: 'call the Hermoso tools in your list directly', why: `on this host every Hermoso operation is its own listed tool, so ${name} is not offered` }];
|
|
4179
|
+
if (why === 'host_policy' && STORY_FILM_TOOLS.includes(name)) return [{ do: chatgptCreatePublished() ? `tell the user story films are in the "${APP_TITLES.create}" app for ChatGPT` : `tell the user story films are in the "${APP_TITLES.create}" app for ChatGPT once it is published, and in the Hermoso web app until then`, why: `${name} is listed in the "${APP_TITLES.create}" ChatGPT app, not in this one` }];
|
|
4029
4180
|
if (why === 'host_policy') return [{ do: 'use the Hermoso app or another MCP client for this one', why: `${name} is withheld by this host's own policy, not by Hermoso` }];
|
|
4030
4181
|
if (why === 'app_scope') return [{ do: 'carry on with the tools this connection lists', why: `${name} belongs to another Hermoso app (${APP_HOME_TEXT(name, ctx)}), not this one` }];
|
|
4031
4182
|
if (why === 'directory') return [{ do: 'carry on with the research, publishing, inbox, ads and analytics tools this connection lists', why: `${name} is not part of this connection` }];
|
|
@@ -4351,7 +4502,7 @@ function applyToolGates(h, name, group, ctx, opts) {
|
|
|
4351
4502
|
if (!h) return;
|
|
4352
4503
|
if (!listedInCoreFirst(name, group, ctx)) { try { h.disable(); } catch {} }
|
|
4353
4504
|
if (ON_DEMAND_TOOLS.has(name) && !TOOL_GROUP_NAMES.every((g) => ctx.enabledGroups.has(g))) { try { h.disable(); } catch {} } // (5) listed on demand only (an explicit 'all' is a demand) — see ON_DEMAND_TOOLS
|
|
4354
|
-
if (widgetHostHides(name, !!opts.widgetHost)) { try { h.disable(); } catch {} } // the commerce tools, the generic executor and a renamed tool — see WITHHELD_FROM_WIDGET_HOSTS / EXECUTOR_TOOLS
|
|
4505
|
+
if (widgetHostHides(name, !!opts.widgetHost, ctx.app)) { try { h.disable(); } catch {} } // the commerce tools, the generic executor and a renamed tool — see WITHHELD_FROM_WIDGET_HOSTS / EXECUTOR_TOOLS
|
|
4355
4506
|
if (toolHeldBackByConnectors(name, ctx.conn) && !widgetHostUngated(name, ctx)) { try { h.disable(); } catch {} }
|
|
4356
4507
|
if (toolHeldBackByDirectory(name, group, ctx)) { try { h.disable(); } catch {} } // (4) the directory cage — see DIRECTORY_GROUPS
|
|
4357
4508
|
}
|
|
@@ -5895,13 +6046,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5895
6046
|
description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
|
|
5896
6047
|
+ "Instagram comments, Threads replies and mentions, YouTube comments, TikTok comments, LinkedIn Page comments, Google Business reviews, "
|
|
5897
6048
|
+ "Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram "
|
|
5898
|
-
+ "Direct), Instagram, Bluesky, X and
|
|
6049
|
+
+ "Direct), Instagram, Bluesky, X, Telegram and WhatsApp. 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 "
|
|
5899
6050
|
+ "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
|
|
5900
6051
|
+ "skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is "
|
|
5901
6052
|
+ "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
|
|
5902
6053
|
+ "Messenger and Instagram DMs need no argument; each has a messageKind (text, story_mention, story_reply, share, "
|
|
5903
6054
|
+ "image, video...), a spoken summary, and unavailable:true when Meta withholds the content. Bluesky and X DMs need "
|
|
5904
|
-
+ "conversationId (list_bluesky_convos / list_x_dms).
|
|
6055
|
+
+ "conversationId (list_bluesky_convos / list_x_dms). WhatsApp needs no argument: Hermoso keeps the last 48 hours of each WhatsApp conversation "
|
|
6056
|
+
+ "(WhatsApp has no message history to read back), and each item carries windowOpen, its last few messages (recent) and whether a free reply is still allowed. TikTok and LinkedIn comments are read per post: pass tiktokVideoId / linkedinPostUrn. FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, "
|
|
5905
6057
|
+ "and every other source only re-reads what the per-channel tools already read.",
|
|
5906
6058
|
inputSchema: {
|
|
5907
6059
|
sources: z.array(z.string()).optional().describe(`Limit to these sources: ${INBOX_SOURCE_NAMES.join(', ')}. Omit for every connected channel.`),
|
|
@@ -5981,6 +6133,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5981
6133
|
if (e?.apiCode === 'meta_page_not_connected') return { content: [{ type: 'text', text: String(e.message) }], isError: true };
|
|
5982
6134
|
// NAMED, never swallowed. A connector that is simply not linked is not worth a line; anything else is.
|
|
5983
6135
|
const msg = String(e?.message || e);
|
|
6136
|
+
// …BUT AN EXPIRED OR REVOKED GRANT IS A CONNECTED CHANNEL THAT CANNOT READ (2026-11-11, measured on the TikTok
|
|
6137
|
+
// source: the account grant had expired, the route answered 401 "has expired … Re-authorize", and the inbox read as
|
|
6138
|
+
// quiet). That is the third connector state, so it gets a line naming the fix rather than silence.
|
|
6139
|
+
if ((e?.status === 401 || /not connected/i.test(msg)) && /expired|revoked|re-?authori[sz]e|reconnect/i.test(msg) && !/not connected/i.test(msg)) { notes.push(`${src.label}s could not be read: ${msg.slice(0, 220)}`); continue; }
|
|
5984
6140
|
if (e?.status === 401 || /not connected/i.test(msg)) continue;
|
|
5985
6141
|
notes.push(`${src.label}s could not be read: ${msg.slice(0, 140)}`);
|
|
5986
6142
|
}
|
|
@@ -5996,9 +6152,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5996
6152
|
description: "Answer an inbox item BY ITS COMPOSITE ID — the `id` list_inbox returned, e.g. 'facebook:123_456' "
|
|
5997
6153
|
+ "or 'google_business:accounts/1/locations/2/reviews/3'. Routes to the right channel for you; you do not need "
|
|
5998
6154
|
+ "to know which reply tool a platform uses. The reply is posted as the brand — PUBLIC on every comment, reply, "
|
|
5999
|
-
+ "mention and review, and PRIVATE on the DM sources and
|
|
6155
|
+
+ "mention and review, and PRIVATE on the DM sources, WhatsApp and Intercom (it goes to the customer from the brand's support inbox) — so show the user the "
|
|
6000
6156
|
+ "exact wording and get their go-ahead first either way. X is the one exception and says so: a reply there is a new post, "
|
|
6001
|
-
+ "so it goes through post_to_x with replyTo."
|
|
6157
|
+
+ "so it goes through post_to_x with replyTo. A WhatsApp reply goes out only while the 24-hour window is open (windowOpen on the item); "
|
|
6158
|
+
+ "after that WhatsApp accepts only an approved template, so the reply is refused for free and send_whatsapp_message with `template` is the way.",
|
|
6002
6159
|
inputSchema: {
|
|
6003
6160
|
id: z.string().describe("The composite id from list_inbox — '<source>:<platform id>'. A bare platform id is refused: replying to the wrong network as the brand cannot be taken back."),
|
|
6004
6161
|
text: z.string().describe('The reply, exactly as it should appear publicly.'),
|
|
@@ -6039,6 +6196,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6039
6196
|
else if (t.source === 'bluesky_dm') payload.convoId = t.nativeId;
|
|
6040
6197
|
else if (t.source === 'x_dm') payload.conversationId = t.nativeId;
|
|
6041
6198
|
else if (t.source === 'telegram_dm') payload.chatId = t.nativeId;
|
|
6199
|
+
// WhatsApp: the native id IS the conversation (`<phone number id>|<person>`); the route checks the window and sends.
|
|
6200
|
+
else if (t.source === 'whatsapp') payload.id = t.nativeId;
|
|
6042
6201
|
// Intercom: the id rides in the PATH, and the route sends to the customer only with confirm:true (this tool's own
|
|
6043
6202
|
// contract is that the user already approved the wording). Anything but a confirmed read-back is said as such.
|
|
6044
6203
|
else if (t.source === 'intercom') payload.confirm = true;
|
|
@@ -6051,7 +6210,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6051
6210
|
// A DM IS PRIVATE. Saying "replied publicly as the brand" about a direct message is wrong in the one
|
|
6052
6211
|
// direction that matters — it invites a caller to be more guarded than it needs to be about a private reply,
|
|
6053
6212
|
// and, worse, teaches that the same sentence covers both.
|
|
6054
|
-
const note = (t.source.endsWith('_dm') || t.source === 'intercom') ? `Sent a private reply to the ${t.label} as the brand.` : `Replied to the ${t.label} as the brand.`;
|
|
6213
|
+
const note = (t.source.endsWith('_dm') || t.source === 'intercom' || t.source === 'whatsapp') ? `Sent a private reply to the ${t.label} as the brand.` : `Replied to the ${t.label} as the brand.`;
|
|
6055
6214
|
return ok(note, { ok: true, source: t.source, id: r?.id || r?.messageId || r?.partId || r?.name || undefined, note });
|
|
6056
6215
|
}));
|
|
6057
6216
|
|
|
@@ -6135,7 +6294,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6135
6294
|
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('; ')}` : ''}` : '';
|
|
6136
6295
|
const _je = d.jobEngines; // the certified engine per job (watched side-by-side tests, 2026-10-19)
|
|
6137
6296
|
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.` : '';
|
|
6138
|
-
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 + focusedConnectionsText(sctx, hostRendersWidgets()), d);
|
|
6297
|
+
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 ? (hostRendersWidgets() ? `\nPRICES ABOVE ARE THIS ACCOUNT'S OWN.` : `\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 + focusedConnectionsText(sctx, hostRendersWidgets()), d);
|
|
6139
6298
|
}), ctx));
|
|
6140
6299
|
|
|
6141
6300
|
server.registerTool('hermoso_credits', {
|
|
@@ -6607,7 +6766,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6607
6766
|
title: 'Instagram account insights + audience demographics',
|
|
6608
6767
|
description: 'ACCOUNT-level performance for the brand’s connected Instagram Business account — views, reach, accounts engaged, total interactions, likes, comments, shares, saves, profile link taps, replies, reposts and follows/unfollows — plus the AUDIENCE DEMOGRAPHICS (follower_demographics and engaged_audience_demographics, broken down by age, city, country or gender), which is the read that says WHO the content reached rather than how many. Use meta_post_insights for one post and meta_page_insights for the Facebook Page. THERE IS NO "impressions": Meta deprecated it for every API version on 2025-04-21 and replaced it with "views" — an unknown metric is refused by name rather than quietly dropped. Instagram returns NO demographics for an account under 100 followers (or under 100 engagements in the window), and an absent block means exactly that, never an empty audience. Read-only, 0 credits. Needs the Instagram account connected, either through Meta (an Instagram account linked to a Facebook Page) or on its own through the Instagram connector; when several are connected, name the one you mean with account.',
|
|
6609
6768
|
inputSchema: {
|
|
6610
|
-
metrics: z.array(z.string()).optional().describe('account metrics (default: views, reach, accounts_engaged, total_interactions, likes, comments, shares, saves, profile_links_taps). Add follower_demographics or engaged_audience_demographics for the audience, which also needs a breakdown.'),
|
|
6769
|
+
metrics: z.array(z.string()).optional().describe('account metrics (default: views, reach, accounts_engaged, total_interactions, likes, comments, shares, saves, profile_links_taps). Add follower_demographics or engaged_audience_demographics for the audience, which also needs a breakdown. Add online_followers for how many followers are online in each hour of the day (the best times to post; accounts with 100+ followers, last 30 days).'),
|
|
6611
6770
|
breakdown: z.array(z.string()).optional().describe('contact_button_type / follow_type / media_product_type for account metrics; age / city / country / gender for the demographic metrics (exactly one)'),
|
|
6612
6771
|
timeframe: z.enum(['last_14_days', 'last_30_days', 'last_90_days', 'prev_month', 'this_month', 'this_week']).optional().describe('window for the demographic metrics only (default this_month = the last 30 days). engaged_audience_demographics takes ONLY this_month or this_week — Meta retired the other four for it from API v20; follower_demographics takes all six'),
|
|
6613
6772
|
period: z.enum(['day', 'week', 'days_28']).optional().describe('aggregation for reach, the one time-series metric (default day)'),
|
|
@@ -6616,13 +6775,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6616
6775
|
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
|
|
6617
6776
|
pageId: z.string().optional().describe('Facebook Page id the Instagram account is linked to — omit when only one Page is connected'),
|
|
6618
6777
|
},
|
|
6619
|
-
outputSchema: { instagramId: z.string().optional(), profile: z.any().optional(), metrics: z.array(z.any()).optional(), demographics: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6778
|
+
outputSchema: { instagramId: z.string().optional(), profile: z.any().optional(), metrics: z.array(z.any()).optional(), demographics: z.array(z.any()).optional(), onlineFollowers: z.any().optional(), note: z.string().optional() },
|
|
6620
6779
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6621
6780
|
}, wrap(async (a) => {
|
|
6622
6781
|
const d = await apiGet('/api/instagram/insights', { metrics: (a.metrics || []).join(','), breakdown: (a.breakdown || []).join(','), timeframe: a.timeframe, period: a.period, since: a.since, until: a.until, account: a.account, pageId: a.pageId });
|
|
6623
6782
|
const lines = (d.metrics || []).map(m => `• ${m.name}: ${m.value ?? '— (no value returned — MISSING, not zero)'}`);
|
|
6624
6783
|
const demo = (d.demographics || []).map(x => `${x.metric} by ${(x.breakdown || []).join('/')} (${x.timeframe}):\n${(x.rows || []).map(r => ` ${r.name}: ${r.breakdowns ? JSON.stringify(r.breakdowns).slice(0, 900) : (r.value ?? '—')}`).join('\n')}`);
|
|
6625
|
-
|
|
6784
|
+
const on = d.onlineFollowers?.averageByHour ? `\n\nFollowers online by hour (average over ${d.onlineFollowers.days.length} day(s), Pacific time): ${d.onlineFollowers.averageByHour.map((v, h) => `${h}h ${v}`).join(', ')}` : '';
|
|
6785
|
+
return ok(`Instagram ${d.profile?.username ? '@' + d.profile.username : d.instagramId}${d.profile?.followers != null ? ` · ${d.profile.followers} followers` : ''}\n${lines.join('\n') || '(no account metrics)'}${demo.length ? `\n\n${demo.join('\n\n')}` : ''}${on}\n${d.note || ''}`, d);
|
|
6626
6786
|
}));
|
|
6627
6787
|
|
|
6628
6788
|
server.registerTool('list_instagram_media', {
|
|
@@ -6802,7 +6962,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6802
6962
|
title: 'Read comments on a Meta post',
|
|
6803
6963
|
description: 'Read the comments under a Facebook Page post or Instagram media object — customer questions, objections and the exact language real people use about the product. Good raw material for ad copy, and the first step before replying or moderating. REPLIES: a reply is a comment ON a comment, and its id exists only under its PARENT — it is never returned by the post. Each row says how many replies it has; to read them (and to get the id reply_to_meta_comment / moderate_meta_comment need), call this tool again with postId set to that COMMENT id.',
|
|
6804
6964
|
inputSchema: {
|
|
6805
|
-
postId: z.string().describe('post/media id — or a COMMENT id, which returns that comment\u2019s replies'),
|
|
6965
|
+
postId: z.string().optional().describe('post/media id — or a COMMENT id, which returns that comment\u2019s replies'),
|
|
6966
|
+
order: z.enum(['displayed']).optional().describe('Instagram: the order Instagram shows'),
|
|
6967
|
+
adId: z.string().optional().describe('Meta ad id: its Instagram comments'),
|
|
6806
6968
|
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
|
|
6807
6969
|
pageId: z.string().optional().describe('Page id — omit when only one Page is connected'),
|
|
6808
6970
|
limit: z.number().optional().describe('how many comments (1–50, default 25)'),
|
|
@@ -6811,7 +6973,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6811
6973
|
outputSchema: { count: z.number().optional(), comments: z.array(z.any()).optional() },
|
|
6812
6974
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6813
6975
|
}, wrap(async (a) => {
|
|
6814
|
-
const d = await apiGet('/api/meta/comments', { postId: a.postId, account: a.account, pageId: a.pageId, limit: a.limit, cursor: a.cursor });
|
|
6976
|
+
const d = await apiGet('/api/meta/comments', { postId: a.postId, order: a.order, adId: a.adId, account: a.account, pageId: a.pageId, limit: a.limit, cursor: a.cursor });
|
|
6815
6977
|
// `hidden` is TRI-STATE: true / false / null = "could not tell" (the server returns null when NEITHER
|
|
6816
6978
|
// Facebook's `is_hidden` nor Instagram's `hidden` came back). Rendering null as blank would put the same
|
|
6817
6979
|
// silent "not hidden" back one layer up, which is exactly the defect the server-side table closed.
|
|
@@ -7095,6 +7257,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7095
7257
|
return ok(`${d.count} mention(s) of @${d.username}:\n${lines.join('\n') || '(none)'}`, d);
|
|
7096
7258
|
}));
|
|
7097
7259
|
|
|
7260
|
+
server.registerTool('threads_trending_topics', {
|
|
7261
|
+
title: 'What is trending on Threads',
|
|
7262
|
+
description: 'The topics trending on Threads in a country (country: two-letter code, default US). Meta has not published this API and answers it only for apps it has approved; until it approves Hermoso, the reply is Meta\u2019s own refusal, which is about Hermoso\u2019s app and not the user\u2019s connection. Read-only, free.',
|
|
7263
|
+
inputSchema: { country: z.string().optional().describe('two-letter country code, default US') },
|
|
7264
|
+
outputSchema: { country: z.string().optional(), count: z.number().optional(), topics: z.array(z.any()).optional(), accessGranted: z.boolean().optional(), note: z.string().optional() },
|
|
7265
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
7266
|
+
}, wrap(async (a) => { const d = await apiGet('/api/threads/trending', { country: a.country }); return ok(d.note, d); }));
|
|
7267
|
+
|
|
7268
|
+
server.registerTool('embed_threads', {
|
|
7269
|
+
title: 'Embed a Threads profile or post',
|
|
7270
|
+
description: 'Embed code (HTML) for a Threads PROFILE, showing the name, picture, bio, follower count and a Follow button, or for a post, to put on a website. url is https://www.threads.com/@name/ (or just @name) or a post link; theme light (default), dark or auto; maxWidth 320-658. Private, age-restricted or embed-disabled accounts cannot be embedded. Read-only, free.',
|
|
7271
|
+
inputSchema: { url: z.string().describe('Threads profile (https://www.threads.com/@name/ or @name) or post link'), theme: z.enum(['light', 'dark', 'auto']).optional().describe('default light'), maxWidth: z.number().optional().describe('320 to 658 pixels') },
|
|
7272
|
+
outputSchema: { url: z.string().optional(), kind: z.string().optional(), theme: z.string().optional(), html: z.string().optional(), note: z.string().optional() },
|
|
7273
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
7274
|
+
}, wrap(async (a) => { const d = await apiGet('/api/threads/embed', { url: a.url, theme: a.theme, maxWidth: a.maxWidth }); return ok(`${d.note}\n\n${d.html}`, d); }));
|
|
7275
|
+
|
|
7098
7276
|
server.registerTool('search_threads_keyword', {
|
|
7099
7277
|
title: 'Search Threads by keyword',
|
|
7100
7278
|
description: 'Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.',
|
|
@@ -7138,7 +7316,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7138
7316
|
// their Page list is empty, and leaving the agent to conclude the brand has no Facebook presence. It is the
|
|
7139
7317
|
// same trap `list_connector_accounts` already special-cases for this provider; the two branches are not worth
|
|
7140
7318
|
// a second round trip to separate, so name both and give the one action that fixes either.
|
|
7141
|
-
if (pg.needsSelection && !(pg.pages || []).length) return { content: [{ type: 'text', text: 'No Facebook Page is shared with this profile, so there is nothing to post to or advertise from. Either Meta is not connected to this workspace yet, or it is connected and no Pages have been ticked for this profile — both are fixed in the same place: Settings ▸ Connectors ▸ Meta ▸ Manage accounts (or list_connector_accounts / set_connector_accounts). This is NOT a report that the account has no Pages.' }], isError: true };
|
|
7319
|
+
if (pg.needsSelection && !(pg.pages || []).length) return { content: [{ type: 'text', text: 'No Facebook Page is shared with this profile, so there is nothing to post to or advertise from. Either Meta is not connected to this workspace yet, or it is connected and no Pages have been ticked for this profile — both are fixed in the same place: Settings ▸ Connectors ▸ Meta ▸ Manage accounts (or list_connector_accounts / set_connector_accounts). This is NOT a report that the account has no Pages. If list_connector_accounts shows NO Page to tick, Meta did not list one for this connection: do not send the user to tick it; tell them to reconnect Meta and, in Meta\'s dialog, choose the Page and the business that owns it, leaving business_management switched on.' }], isError: true };
|
|
7142
7320
|
const pages = pg.pages || [], adAccounts = aa.adAccounts || [];
|
|
7143
7321
|
return ok(`Pages: ${pages.map(p => p.name + (p.instagram ? ` (IG @${p.instagram.username})` : '')).join(', ') || 'none'}\nAd accounts: ${adAccounts.map(a => `${a.name} (act_${a.accountId}, ${a.currency}${a.accountStatus && a.accountStatus !== 'ACTIVE' ? `, Meta status ${a.accountStatus}${a.canDeliver ? ' (still delivering)' : ' (not delivering)'}` : a.active === false ? ', not active' : ''})`).join(', ') || 'none'}`, { pages, adAccounts });
|
|
7144
7322
|
}));
|
|
@@ -7388,9 +7566,41 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7388
7566
|
return ok(d.note, d);
|
|
7389
7567
|
}));
|
|
7390
7568
|
|
|
7569
|
+
// THE WHATSAPP INBOX (2026-11-12, customer report). WhatsApp keeps no message history an app can read, so Hermoso keeps
|
|
7570
|
+
// the last 48 hours of each conversation from the webhook (lib/whatsapp-inbox.mjs) and this is the one tool that reads
|
|
7571
|
+
// a whole conversation. Replies go through reply_to_inbox_item or this tool's `reply`, both of which refuse once the
|
|
7572
|
+
// 24-hour window has closed, for free.
|
|
7573
|
+
server.registerTool('read_whatsapp_inbox', {
|
|
7574
|
+
title: 'Read and answer WhatsApp messages',
|
|
7575
|
+
description: 'The WhatsApp messages people sent to the brand’s WhatsApp Business numbers, and its answers to them. With no argument: every conversation from the last 48 hours, newest first, each with who wrote, the last thing they said, whether the brand has answered, and whether the 24-hour reply window is still open. With conversationId: that whole conversation, oldest first, with the delivery status of each reply. WHY ONLY 48 HOURS: WhatsApp has no message history an app can read, so Hermoso keeps what arrives for 48 hours (long enough to answer inside the window) and then deletes it; older messages are in the WhatsApp Business app or WhatsApp Manager. TO REPLY: pass conversationId and reply. It goes out ONLY while the window is open (the person wrote in the last 24 hours) and is refused for free otherwise, because WhatsApp then accepts only an approved template (send_whatsapp_message with template). A reply reaches a real phone: show the user the exact words and get a yes first. Free, 0 credits. Needs WhatsApp connected.',
|
|
7576
|
+
inputSchema: {
|
|
7577
|
+
conversationId: z.string().optional().describe('one conversation’s id from this tool or from list_inbox (the part after "whatsapp:"): the phone number id and the person, joined by |'),
|
|
7578
|
+
phoneNumberId: z.string().optional().describe('only the conversations on this number (from list_whatsapp_accounts)'),
|
|
7579
|
+
reply: z.string().optional().describe('send this text as the reply to conversationId. Only while the 24-hour window is open; the user must have approved the exact words'),
|
|
7580
|
+
limit: z.number().optional().describe('how many conversations (1–200, default 50)'),
|
|
7581
|
+
},
|
|
7582
|
+
outputSchema: { count: z.number().optional(), conversations: z.array(z.any()).optional(), messages: z.array(z.any()).optional(), windowOpen: z.boolean().nullable().optional(), windowNote: z.string().optional(), keepNote: z.string().optional(), ok: z.boolean().optional(), messageId: z.string().optional(), note: z.string().optional() },
|
|
7583
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
7584
|
+
}, wrap(async (a) => {
|
|
7585
|
+
if (a.reply != null && String(a.reply).trim()) {
|
|
7586
|
+
if (!a.conversationId) return { content: [{ type: 'text', text: 'Which conversation? Pass conversationId with the reply. Nothing was sent.' }], isError: true };
|
|
7587
|
+
const d = await apiPost('/api/whatsapp/inbox/reply', { id: a.conversationId, text: a.reply });
|
|
7588
|
+
return ok(`${d.note || 'Sent.'}${d.windowClosesAt ? `\nThe reply window stays open until ${d.windowClosesAt}.` : ''}`, d);
|
|
7589
|
+
}
|
|
7590
|
+
if (a.conversationId) {
|
|
7591
|
+
const d = await apiGet('/api/whatsapp/inbox/thread', { id: a.conversationId });
|
|
7592
|
+
const lines = (d.messages || []).map((m) => `${m.at} ${m.from === 'customer' ? d.who : m.from === 'business_app' ? 'You (WhatsApp Business app)' : 'You'}: ${JSON.stringify(String(m.text || '').slice(0, 300))}${m.status ? ` [${m.status}]` : ''}`);
|
|
7593
|
+
return ok(`WhatsApp with ${d.who} (conversationId ${d.id}):\n${lines.join('\n') || '(no messages in the last 48 hours)'}\n\n${d.windowNote || ''}\n${d.keepNote || ''}`.trim(), d);
|
|
7594
|
+
}
|
|
7595
|
+
const d = await apiGet('/api/whatsapp/inbox', { phoneNumberId: a.phoneNumberId, limit: a.limit });
|
|
7596
|
+
const lines = (d.conversations || []).map((c) => `${c.answered ? '✓' : '•'} ${c.who}: ${JSON.stringify(String(c.text || '').slice(0, 160))} (${c.at}) ${c.windowOpen ? 'reply window open' : 'template only'} [conversationId ${c.id}]`);
|
|
7597
|
+
const tail = [d.note, d.emptyNote, d.keepNote].filter(Boolean).join('\n');
|
|
7598
|
+
return ok(`${d.count || 0} WhatsApp conversation(s):\n${lines.join('\n') || '(none)'}${tail ? `\n\n${tail}` : ''}`, d);
|
|
7599
|
+
}));
|
|
7600
|
+
|
|
7391
7601
|
server.registerTool('send_whatsapp_message', {
|
|
7392
7602
|
title: 'Send a WhatsApp message',
|
|
7393
|
-
description: 'Send a WhatsApp message from one of the brand’s registered numbers to a customer. TWO KINDS, and which one is legal depends on time: pass `template` (an APPROVED template name) to START a conversation — the only thing WhatsApp accepts outside the 24-hour customer-service window — or `text` for a free-form reply INSIDE that window, which opens only when the customer messages the business first.
|
|
7603
|
+
description: 'Send a WhatsApp message from one of the brand’s registered numbers to a customer. TWO KINDS, and which one is legal depends on time: pass `template` (an APPROVED template name) to START a conversation — the only thing WhatsApp accepts outside the 24-hour customer-service window — or `text` for a free-form reply INSIDE that window, which opens only when the customer messages the business first. To ANSWER someone who wrote in, use read_whatsapp_inbox (or list_inbox + reply_to_inbox_item): it shows the conversation and whether the window is open, and refuses a free reply once it has closed. This tool does not check the window: a free-form message is sent as asked and Meta refuses it with a re-engagement error if it is closed, which the reply explains rather than reporting as a broken connection. THIS REACHES A REAL PERSON’S PHONE AND META BILLS THE BUSINESS for the conversation, so it is confirm-gated: show the user the exact message and the recipient, get an explicit yes, then call again with confirm:true. WhatsApp policy REQUIRES the business to hold the recipient’s opt-in first. An unapproved template is refused BEFORE the send, against the status Meta holds. ACCEPTED IS NOT DELIVERED — the reply reports what Meta accepted, and delivery arrives later on a webhook (read_whatsapp_inbox shows it as the message status), so never tell the user it was delivered. THE VARIABLE COUNT IS CHECKED BEFORE ANYTHING IS DISPATCHED: Hermoso reads the template’s own components back from Meta and refuses a payload that supplies the wrong number of values, naming exactly what is missing — for free, before a real phone is involved, instead of Meta answering 132000 after the attempt. It refuses only what it can prove; anything it cannot determine is sent for Meta to judge. 0 Hermoso credits.',
|
|
7394
7604
|
inputSchema: {
|
|
7395
7605
|
phoneNumberId: z.string().describe('WHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several)'),
|
|
7396
7606
|
to: z.string().describe('the recipient in full international form, e.g. +14165550142 — or, for someone who reached the business by WhatsApp username and shared no number, their business-scoped user id exactly as received (e.g. US.13491208655302741918). Authentication templates need the phone number.'),
|
|
@@ -7538,7 +7748,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7538
7748
|
// no need for one edge case just for Facebook."
|
|
7539
7749
|
server.registerTool('schedule_post', {
|
|
7540
7750
|
title: 'Schedule a post for later',
|
|
7541
|
-
description: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. Channels: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack. whop only where list_connectors offers it; elsewhere it is refused. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions`: per-channel wording; `message` for the rest. `title` (Pinterest/YouTube, max 100, derived from the caption); YouTube also `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below (e.g. TikTok `brandedContent` / `yourBrand`, set whenever the post is commercial; X `thread` / `poll`; Instagram `collaborators`). Channels are attempted INDEPENDENTLY: one failing never blocks the others. Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): none named is refused at scheduling with the Pages listed, so ask the user which. Several Google Business listings need `locationId` (list_business_locations), and without it the post is refused when it fires. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, TikTok SELF_ONLY) · ‘draft’ (TikTok, unpublished Facebook Page post); one a channel cannot do is REFUSED now, never posted weaker.',
|
|
7751
|
+
description: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. Channels: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack. whop only where list_connectors offers it; elsewhere it is refused. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions`: per-channel wording; `message` for the rest. `title` (Pinterest/YouTube, max 100, derived from the caption); YouTube also `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below (e.g. TikTok `brandedContent` / `yourBrand`, set whenever the post is commercial; X `thread` / `poll`; Instagram `collaborators`). Channels are attempted INDEPENDENTLY: one failing never blocks the others. Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages), for Facebook and for an Instagram that posts through a Page: none named is refused at scheduling with the Pages listed, so ask the user which. Several Google Business listings need `locationId` (list_business_locations), and without it the post is refused when it fires. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, TikTok SELF_ONLY) · ‘draft’ (TikTok, unpublished Facebook Page post); one a channel cannot do is REFUSED now, never posted weaker.',
|
|
7542
7752
|
inputSchema: {
|
|
7543
7753
|
...HOOK_ATTR,
|
|
7544
7754
|
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'),
|
|
@@ -7755,6 +7965,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7755
7965
|
...((r.videoUrl || r.imageUrl) ? { mediaUrl: r.videoUrl || r.imageUrl } : {}),
|
|
7756
7966
|
...(Array.isArray(r.results) ? { results: r.results.map(x => ({ channel: x?.channel, ok: x?.ok, ...(x?.id ? { id: String(x.id) } : {}), ...(x?.url ? { url: x.url } : {}), ...(x?.urlStatus && x.urlStatus !== 'ready' ? { urlStatus: x.urlStatus, ...(x.urlNote ? { urlNote: x.urlNote } : {}) } : {}), ...(x?.error ? { error: String(x.error).slice(0, 300) } : {}) })) } : {}),
|
|
7757
7967
|
...(r.error ? { error: String(r.error).slice(0, 300) } : {}),
|
|
7968
|
+
...(Array.isArray(r.waitingForReconnect) && r.waitingForReconnect.length ? { waitingForReconnect: r.waitingForReconnect.map((w) => ({ channel: w.channel, provider: w.provider, label: w.label, ...(w.reason ? { reason: String(w.reason).slice(0, 200) } : {}), steps: w.steps })) } : {}),
|
|
7969
|
+
...(r.holdNote ? { holdNote: String(r.holdNote).slice(0, 400) } : {}),
|
|
7758
7970
|
};
|
|
7759
7971
|
};
|
|
7760
7972
|
const qAll = (d.scheduled || []).filter(hasCh), hAll = (d.history || []).filter(hasCh);
|
|
@@ -7771,7 +7983,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7771
7983
|
const arts = qAll.filter(r => r && r.xArticle && r.xArticle.title);
|
|
7772
7984
|
const partial = qShow.length < q || hShow.length < h;
|
|
7773
7985
|
const { scheduled: _allQ, history: _allH, ...rest } = d;
|
|
7774
|
-
|
|
7986
|
+
// QUEUED POSTS THAT WAIT ON A RECONNECT ARE SAID FIRST (2026-11-12): the user must act, so the agent leads with it.
|
|
7987
|
+
const waitQ = qAll.filter((r) => Array.isArray(r.waitingForReconnect) && r.waitingForReconnect.length);
|
|
7988
|
+
const waitProv = new Set(waitQ.flatMap((r) => r.waitingForReconnect.map((w) => w.provider)));
|
|
7989
|
+
const waitNotes = waitQ.length ? await brokenConnectorNotes((i) => waitProv.has(i.provider)) : [];
|
|
7990
|
+
const waitLine = waitQ.length ? ` ⚠ ${waitQ.length} queued post${waitQ.length === 1 ? '' : 's'} cannot go out until the user reconnects ${[...new Set(waitQ.flatMap((r) => r.waitingForReconnect.map((w) => w.label)))].join(' and ')}: ${waitQ.slice(0, 3).map((r) => `${r.id} at ${r.at}`).join(', ')}.${waitNotes.length ? ' ' + waitNotes.map((i) => i.agentNote).join(' ') : ''}` : '';
|
|
7991
|
+
return ok(`${want ? `${want}: ` : ''}${q} post${q === 1 ? '' : 's'} queued, ${h} already fired.${waitLine}${d.posting?.line ? ` Posting: ${d.posting.line}` : ''}${badLine}${arts.length ? ` X Article${arts.length === 1 ? '' : 's'} queued: ${arts.slice(0, 5).map(r => `${r.id} "${r.xArticle.title}" at ${r.at}`).join('; ')}.` : ''}${partial ? ` Showing the next ${qShow.length} queued and the last ${hShow.length} fired; pass upcoming or fired for more, or id for one post in full.` : ''}`,
|
|
7775
7992
|
{ ...rest, scheduled: qShow.map(brief), history: hShow.map(brief), queuedTotal: q, firedTotal: h });
|
|
7776
7993
|
}));
|
|
7777
7994
|
// RESCHEDULE. `PATCH /api/schedule/:id` has existed since drag-to-reschedule shipped and NO agent surface wrapped
|
|
@@ -8111,8 +8328,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8111
8328
|
const d = await (a.dryRun === false ? apiSSE : apiPost)('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force }); // a live run renders for minutes: streamed past the edge's 100s limit (2026-10-06)
|
|
8112
8329
|
const lines = (d.posts || []).map((p, i) => ` ${i + 1}. ${p.at} → ${(p.channels || []).map(c => `${c}[${(p.visibilityByChannel || {})[c] || 'public'}]`).join(', ')}\n ${String(p.message || '').replace(/\n+/g, ' / ').slice(0, 220)}\n new ${p.kind}: ${p.media || `(made when it runs live) ${String(p.asset?.scene || '').slice(0, 160)}`} · ${p.asset?.credits || 0} credits`);
|
|
8113
8330
|
const q = (d.queued || []).filter(x => x && x.id).length, qFail = (d.queued || []).filter(x => x && x.error);
|
|
8331
|
+
const _refillBuy = d.outOfCredits ? buyDoorsOf(d, hostRendersWidgets()) : null; // out of credits: the server's two priced doors (2026-11-12), never on a widget host
|
|
8114
8332
|
return ok([
|
|
8115
8333
|
d.summary || '',
|
|
8334
|
+
_refillBuy ? buyDoorsText(_refillBuy, 'run_post_refill') : '',
|
|
8116
8335
|
lines.length ? `\nThe posts:\n${lines.join('\n')}` : '',
|
|
8117
8336
|
(d.noChannels || d.zeroBudget || (d.outOfCredits && !(d.posts || []).length) || (d.enabled === false && !(d.posts || []).length)) ? '' : d.dryRun ? `\nNOTHING WAS RENDERED OR QUEUED — this was a preview${d.copyCredits ? ` (writing its copy cost ${d.copyCredits} credit(s))` : ''}. Show it to the user; call again with dryRun:false only if they say yes.`
|
|
8118
8337
|
: (d.mode === 'auto' ? `\n${q} post(s) scheduled. Pull any of them before it goes out with cancel_scheduled.` : ((d.drafts || []).length ? `\n${d.drafts.length} new draft(s) waiting for approval — approve, edit or discard them with run_post_refill.` : '\nNo new drafts were made by this run (get_post_refill lists any drafts already waiting).')),
|
|
@@ -8156,7 +8375,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8156
8375
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
8157
8376
|
server.registerTool('post_to_x', {
|
|
8158
8377
|
title: 'Publish a post to X (Twitter)',
|
|
8159
|
-
description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).',
|
|
8378
|
+
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, share a CLIP of another post’s video (`clip`), and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).',
|
|
8160
8379
|
inputSchema: {
|
|
8161
8380
|
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."),
|
|
8162
8381
|
...HOOK_ATTR,
|
|
@@ -8187,13 +8406,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8187
8406
|
taggedUserIds: z.array(z.string()).optional().describe('tag up to 10 people (numeric X user ids, from x_user) in the post\u2019s media'),
|
|
8188
8407
|
dmDeepLink: z.union([z.string(), z.boolean()]).optional().describe('a "Message us" button: true for the connected account, or https://x.com/messages/compose?recipient_id=<id>. TEXT posts only \u2014 X refuses it alongside media.'),
|
|
8189
8408
|
platformCover: z.boolean().optional().describe('VIDEO COVER. X has no cover setting and shows the video\u2019s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad\u2019s empty opening card), sends X a copy with the first frame replaced by the video\u2019s best frame. Your Library file is never changed. true = send the file exactly as it is.'),
|
|
8409
|
+
clip: z.object({
|
|
8410
|
+
postId: z.string().describe('id or x.com link of a post with ONE video over 2 minutes'),
|
|
8411
|
+
startSeconds: z.union([z.number().int(), z.string()]).describe('whole seconds or m:ss'),
|
|
8412
|
+
endSeconds: z.union([z.number().int(), z.string()]).describe('5 to 60 s after start, at most 30% of that video'),
|
|
8413
|
+
}).optional().describe('share a CLIP of another post\u2019s video. It is the post\u2019s only attachment: no media, poll, quote, message-us link or thread. Link rate.'),
|
|
8190
8414
|
},
|
|
8191
|
-
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), thread: z.boolean().optional(), media: z.boolean().optional(), altText: z.boolean().optional(), poll: z.boolean().optional(), mediaNote: z.string().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), text: z.string().optional(), url: z.string().optional() })).optional(), quotedPostId: z.string().optional(), communityId: z.string().optional(), paidPartnership: z.boolean().optional() },
|
|
8415
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), thread: z.boolean().optional(), media: z.boolean().optional(), altText: z.boolean().optional(), poll: z.boolean().optional(), mediaNote: z.string().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), text: z.string().optional(), url: z.string().optional() })).optional(), quotedPostId: z.string().optional(), communityId: z.string().optional(), paidPartnership: z.boolean().optional(), clip: z.object({ postId: z.string().optional(), startSeconds: z.number().optional(), endSeconds: z.number().optional() }).optional(), clipNote: z.string().optional() },
|
|
8192
8416
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
8193
8417
|
}, wrap(async (a) => {
|
|
8194
8418
|
const d = await apiPost('/api/x/post', a);
|
|
8195
8419
|
if (d && d.dryRun === true) return ok(`Not posted (price check). This ${Array.isArray(a.thread) && a.thread.length > 1 ? `${a.thread.length}-post thread` : 'post'} costs ${d.costCredits} credits at X's per-post price. ${d.note || ''}`.trim(), d);
|
|
8196
|
-
const extra = d.media ? (d.altText ? ' with the render + alt text' : ' with the render attached (no alt text was written)') : d.poll ? ' with a poll' : '';
|
|
8420
|
+
const extra = d.media ? (d.altText ? ' with the render + alt text' : ' with the render attached (no alt text was written)') : d.poll ? ' with a poll' : d.clipNote ? ` (${d.clipNote})` : '';
|
|
8197
8421
|
return ok(`Published to X${d.thread ? ` — a ${(d.posts || []).length}-post thread` : ''}${extra}: ${d.url}. Cost ${d.costCredits ?? '?'} credits.${d.mediaNote ? ` ${d.mediaNote}` : ''}`, d);
|
|
8198
8422
|
}));
|
|
8199
8423
|
server.registerTool('post_x_article', {
|
|
@@ -8809,6 +9033,31 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8809
9033
|
if (!d.count) return ok(`No Posts on the “${d.location}” listing right now.`, d);
|
|
8810
9034
|
return ok(`${d.count} Post${d.count === 1 ? '' : 's'} on “${d.location}”: ${(d.posts || []).map(p => `[${p.state || '?'}] ${String(p.summary || '(no text)').slice(0, 60)}`).join(' · ')}`, d);
|
|
8811
9035
|
}));
|
|
9036
|
+
server.registerTool('update_google_business_post', {
|
|
9037
|
+
title: 'Edit a Google Business Profile Post',
|
|
9038
|
+
description: 'Edit a Post already live on the brand’s Google Business Profile listing, in place, instead of deleting and re-posting it: its text (summary), its button (actionType + link), an EVENT or OFFER’s title and dates, an OFFER’s coupon, redeem link or terms, or its photo (imageUrl). Only the fields you pass change. The edit is PUBLIC and immediate, so show the user the new wording and get their go-ahead first. A Post’s type cannot change (that is a new Post) and Google takes no video. The reply is built from the Post Google returns, and names any field Google did not keep. Pass the full post name from list_google_business_posts. Needs Google Business Profile connected.',
|
|
9039
|
+
inputSchema: {
|
|
9040
|
+
...MANAGE_BRAND,
|
|
9041
|
+
postId: z.string().describe('the full post name from list_google_business_posts (accounts/…/locations/…/localPosts/…)'),
|
|
9042
|
+
summary: z.string().optional().describe('the new body text'),
|
|
9043
|
+
actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('the new button'),
|
|
9044
|
+
link: z.string().optional().describe('the URL the button opens; not for CALL, and ignored on an OFFER'),
|
|
9045
|
+
title: z.string().optional().describe('EVENT / OFFER only: the headline'),
|
|
9046
|
+
startDate: z.string().optional().describe('EVENT / OFFER only: YYYY-MM-DD'),
|
|
9047
|
+
endDate: z.string().optional().describe('EVENT / OFFER only: YYYY-MM-DD'),
|
|
9048
|
+
startTime: z.string().optional().describe('EVENT / OFFER only: HH:MM'),
|
|
9049
|
+
endTime: z.string().optional().describe('EVENT / OFFER only: HH:MM'),
|
|
9050
|
+
couponCode: z.string().optional().describe('OFFER only'),
|
|
9051
|
+
redeemOnlineUrl: z.string().optional().describe('OFFER only: the link Google uses on an offer'),
|
|
9052
|
+
termsConditions: z.string().optional().describe('OFFER only'),
|
|
9053
|
+
imageUrl: z.string().optional().describe('a Hermoso render image URL (or an upload_file url) to replace the photo'),
|
|
9054
|
+
},
|
|
9055
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), changed: z.array(z.string()).optional(), notConfirmed: z.array(z.string()).optional(), state: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), url: z.string().nullable().optional(), location: z.string().optional(), note: z.string().optional() },
|
|
9056
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
9057
|
+
}, wrap(async (a) => {
|
|
9058
|
+
const d = await apiPost('/api/google-business/post/update', a);
|
|
9059
|
+
return ok(d.note, d);
|
|
9060
|
+
}));
|
|
8812
9061
|
server.registerTool('delete_google_business_post', {
|
|
8813
9062
|
title: 'Delete a Google Business Profile Post',
|
|
8814
9063
|
description: 'Remove a Post from the brand’s Google Business Profile listing. This takes it off Google Search and Maps immediately and CANNOT be undone — confirm with the user first. Pass the full post name from list_google_business_posts. Needs Google Business Profile connected.',
|
|
@@ -9005,9 +9254,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9005
9254
|
// is that port, over the same route, so all three surfaces answer identically.
|
|
9006
9255
|
server.registerTool('list_youtube_videos', {
|
|
9007
9256
|
title: 'List the brand’s own YouTube uploads',
|
|
9008
|
-
description: 'List the connected channel’s OWN recent uploads — video id, title, publish date and
|
|
9257
|
+
description: 'List the connected channel’s OWN recent uploads — video id, title, publish date, privacy and thumbnail (plus shortsThumbnail, 9:16, when YouTube serves one) — so you can resolve a video WITHOUT asking the user for a link. Call this whenever the user names a video loosely ("my latest", "the shorts one", part of a title) and match it yourself; only ask them when two titles are genuinely ambiguous. This is the tool that gets you the videoId every other YouTube tool needs — youtube_channel returns counts only, and search_youtube searches the PUBLIC index, not your uploads. Includes UNLISTED and PRIVATE videos, which are invisible to any public search. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
9009
9258
|
inputSchema: { ...MANAGE_BRAND, limit: z.number().optional().describe('how many recent uploads to return (default 25, max 50)') },
|
|
9010
|
-
outputSchema: { videos: z.array(z.object({ videoId: z.string().optional(), title: z.string().optional(), publishedAt: z.string().optional(), privacy: z.string().optional(), url: z.string().optional() })).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
9259
|
+
outputSchema: { videos: z.array(z.object({ videoId: z.string().optional(), title: z.string().optional(), publishedAt: z.string().optional(), privacy: z.string().optional(), url: z.string().optional(), thumbnail: z.string().nullable().optional(), shortsThumbnail: z.string().optional() })).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
9011
9260
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9012
9261
|
}, wrap(async (a) => {
|
|
9013
9262
|
const d = await apiGet('/api/youtube/videos', queryBrand(a, { ...(a.limit ? { limit: a.limit } : {}) }));
|
|
@@ -9187,7 +9436,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9187
9436
|
title: 'Set the custom thumbnail on a YouTube video',
|
|
9188
9437
|
description: 'Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image — a make_thumbnail render, a generated image, or a frame. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Works on Shorts too, once YouTube has finished processing the Short. Images up to YouTube’s 50MB cap go as they are; a larger one is compressed automatically. The image must be Hermoso-HOSTED, which is not the same as Hermoso-GENERATED: the user’s own artwork works, put it through upload_file first and pass the URL that returns. An arbitrary external host is refused. 0 credits. Needs a connected YouTube channel.',
|
|
9189
9438
|
inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id (what post_to_youtube returned)'), imageUrl: z.string().describe('a Hermoso-hosted image URL — a make_thumbnail / list_library render, OR any image of the user’s own passed through upload_file first. An arbitrary external host is refused.') },
|
|
9190
|
-
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), thumbnailUrl: z.string().nullable().optional(), bytes: z.number().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
9439
|
+
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), thumbnailUrl: z.string().nullable().optional(), shortsThumbnailUrl: z.string().optional(), bytes: z.number().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
9191
9440
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
9192
9441
|
}, wrap(async (a) => {
|
|
9193
9442
|
const d = await apiPost('/api/youtube/set-thumbnail', a);
|
|
@@ -10802,7 +11051,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10802
11051
|
}));
|
|
10803
11052
|
server.registerTool('meta_insights', {
|
|
10804
11053
|
title: 'Meta ad performance metrics',
|
|
10805
|
-
description: 'Pull performance INSIGHTS
|
|
11054
|
+
description: 'Pull performance INSIGHTS for a connected Meta ad account, or a specific campaign / ad set / ad: spend, impressions, reach, clicks, CTR, CPC, CPM, frequency, and the conversions and video numbers Meta reports. Pass adAccountId (for auth); optionally objectId to scope to one object and level (campaign / adset / ad) for one named row per campaign, ad set or ad. EVERY ROW CARRIES `kpis`, the Ads Manager columns read out of Meta\'s arrays: purchases with purchaseValue, costPerPurchase (CPA) and purchaseRoas (purchase ROAS); addToCart (adds to cart), initiateCheckout (checkouts started), landingPageViews (landing page views) and costPerLandingPageView; linkClicks with linkCtr (link CTR) and linkCpc (link CPC); video3sPlays (3-second video plays), thruPlays (ThruPlay), videoP25 / P50 / P75 / P95 / P100 (plays to 25%, 50%, 75%, 95%, 100%) and videoAvgWatchSeconds (average watch time). A kpi that is null was not reported by Meta for that row: say "not reported", never zero. timeIncrement gives one row per day (1 or "daily"), per week (7) or "monthly" in ONE call. attributionSetting:"adset" counts conversions with each ad set\'s own attribution setting, as Ads Manager\'s columns do; attributionWindows (e.g. "7d_click,1d_view") adds each window\'s count beside the total. fields adds more of Meta\'s insights fields (e.g. "attribution_setting,quality_ranking,outbound_clicks"); an unknown name is refused, never dropped. Up to 500 rows a call: pass limit, and after:nextCursor for the next page. BREAKDOWNS are what make the numbers actionable — a flat total says an ad cost $X, never WHO it worked on: pass breakdowns:"age,gender", "publisher_platform,platform_position" (which placement), "country" / "region" / "comscore_market" (where — US metro; Meta retired "dma" for it, and "dma" is sent as comscore_market), "impression_device" / "device_platform" (what they held). Comma-separated; "placement", "device" and "geo" are accepted as aliases; an unknown value is REJECTED, never silently ignored. THREE breakdowns need an ad-account OPT-IN from 2026-08-06 — impression_device, hourly_stats_aggregated_by_audience_time_zone and frequency_value: Meta returns NO ROWS (not an error) for an account that has not opted in, so they are always ATTEMPTED, and if nothing comes back the report is re-run WITHOUT them and `droppedBreakdowns` + a note name the missing dimension and say an account admin can enable it in Ads Manager. A dropped dimension is ABSENT, never zero — never present the remaining total as if it were still split by it. Date window: datePreset OR since+until (YYYY-MM-DD). datePreset is Meta\'s OWN enum — today, yesterday, last_3d, last_7d, last_14d, last_28d, last_30d, last_90d, this_week_mon_today, this_week_sun_today, last_week_mon_sun, last_week_sun_sat, this_month, last_month, this_quarter, last_quarter, this_year, last_year, maximum, data_maximum. THERE IS NO "lifetime": Meta disabled it in Graph API v10.0 and replaced it with "maximum" (the last 37 months); anything unrecognised is refused by name here rather than 400ing at Meta. Read-only.',
|
|
10806
11055
|
inputSchema: {
|
|
10807
11056
|
adAccountId: z.string().describe('ad account id (act_… or digits)'),
|
|
10808
11057
|
objectId: z.string().optional().describe('a campaign / ad set / ad id to scope to (default: the whole account)'),
|
|
@@ -10812,8 +11061,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10812
11061
|
datePreset: z.string().optional().describe('Meta\'s date_preset enum, default last_30d: today | yesterday | last_3d | last_7d | last_14d | last_28d | last_30d | last_90d | this_week_mon_today | this_week_sun_today | last_week_mon_sun | last_week_sun_sat | this_month | last_month | this_quarter | last_quarter | this_year | last_year | maximum | data_maximum. NOT "lifetime" — Meta retired it in v10.0; use "maximum" (37 months) or since+until.'),
|
|
10813
11062
|
since: z.string().optional().describe('start date YYYY-MM-DD (use with until). Meta keeps 37 months: an older start is moved to the oldest day Meta has, and the note says so'),
|
|
10814
11063
|
until: z.string().optional().describe('end date YYYY-MM-DD'),
|
|
11064
|
+
timeIncrement: z.union([z.string(), z.number()]).optional().describe('one row per period instead of one for the whole window: 1 or "daily" = per day, 7 or "weekly" = per week, any whole number of days 1–90, or "monthly". Default: one row for the whole window'),
|
|
11065
|
+
fields: z.string().optional().describe('comma-separated extra Meta insights fields to add to every row, e.g. "attribution_setting,quality_ranking,outbound_clicks,website_purchase_roas,objective"'),
|
|
11066
|
+
attributionSetting: z.enum(['adset', 'account', 'default']).optional().describe('how conversions are attributed: "adset" = each ad set\'s own setting (what Ads Manager\'s columns show), "account" = the ad account\'s setting, "default" or omitted = Meta\'s default (7-day click and 1-day view)'),
|
|
11067
|
+
attributionWindows: z.string().optional().describe('comma-separated windows to report beside each action total: 1d_click, 7d_click, 28d_click, 1d_view, 7d_view, 28d_view, 1d_ev, dda, default'),
|
|
11068
|
+
actionReportTime: z.enum(['impression', 'conversion', 'mixed', 'lifetime']).optional().describe('which day a conversion is counted on: the day of the ad impression, or the day the conversion happened'),
|
|
11069
|
+
limit: z.number().optional().describe('rows per page, 1–500 (default 200)'),
|
|
11070
|
+
after: z.string().optional().describe('the nextCursor of the previous page'),
|
|
10815
11071
|
},
|
|
10816
|
-
outputSchema: { objectId: z.string().optional(), rows: z.array(z.any()).optional(), breakdowns: z.array(z.string()).optional(), breakdownsRequested: z.array(z.string()).optional(), droppedBreakdowns: z.array(z.string()).optional(), breakdownStatus: z.string().optional(), actionBreakdowns: z.array(z.string()).optional(), lines: z.array(z.string()).optional(), note: z.string().optional() },
|
|
11072
|
+
outputSchema: { objectId: z.string().optional(), currency: z.string().nullable().optional(), level: z.string().nullable().optional(), timeIncrement: z.string().nullable().optional(), rows: z.array(z.any()).optional(), breakdowns: z.array(z.string()).optional(), breakdownsRequested: z.array(z.string()).optional(), droppedBreakdowns: z.array(z.string()).optional(), breakdownStatus: z.string().optional(), actionBreakdowns: z.array(z.string()).optional(), nextCursor: z.string().nullable().optional(), coreOnly: z.boolean().optional(), lines: z.array(z.string()).optional(), kpiLines: z.array(z.string()).optional(), note: z.string().optional() },
|
|
10817
11073
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
10818
11074
|
}, wrap(async (a) => {
|
|
10819
11075
|
const d = await apiGet('/api/meta/insights', a);
|
|
@@ -10826,7 +11082,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10826
11082
|
const lines = (d.lines || []).slice(0, 40);
|
|
10827
11083
|
return ok(`${drop}${(d.rows || []).length} row(s) broken down by ${d.breakdowns.join(' × ')} (${r.date_start}→${r.date_stop}):\n${lines.join('\n')}${(d.rows || []).length > 40 ? `\n…and ${d.rows.length - 40} more.` : ''}${d.note ? `\n(${d.note})` : ''}`, d);
|
|
10828
11084
|
}
|
|
10829
|
-
|
|
11085
|
+
// One row per campaign / ad set / ad, or per day: print the rows. The old reply printed the FIRST row only, so a
|
|
11086
|
+
// host that reads the text saw one campaign of twelve and no conversions at all.
|
|
11087
|
+
if ((d.rows || []).length > 1 || d.timeIncrement || (d.level && d.level !== 'account')) {
|
|
11088
|
+
const CAP = 60, lines = (d.lines || []).slice(0, CAP), n = (d.rows || []).length;
|
|
11089
|
+
const what = [d.level && d.level !== 'account' ? `one row per ${d.level}` : '', d.timeIncrement ? (d.timeIncrement === 'monthly' ? 'per month' : String(d.timeIncrement) === '1' ? 'per day' : `per ${d.timeIncrement} days`) : ''].filter(Boolean).join(', ');
|
|
11090
|
+
return ok(`${drop}${n} row(s)${what ? `, ${what}` : ''}${d.currency ? `, in ${d.currency}` : ''}:\n${lines.join('\n')}${n > CAP ? `\n…and ${n - CAP} more in rows.` : ''}${d.nextCursor ? `\nMore rows exist: call again with after:"${d.nextCursor}".` : ''}${d.note ? `\n(${d.note})` : ''}\nEach row's kpis holds these numbers; one that is null was not reported by Meta for that row.`, d);
|
|
11091
|
+
}
|
|
11092
|
+
const kpiLine = (d.kpiLines || [])[0] || '';
|
|
11093
|
+
return ok(`${drop}Spend ${moneyText(r.spend || 0, d.currency)} · ${r.impressions || 0} impressions · ${r.clicks || 0} clicks · CTR ${r.ctr || 0}% · CPC ${moneyText(r.cpc || 0, d.currency)} (${r.date_start}→${r.date_stop}).${kpiLine ? `\n${kpiLine}.` : ''}${d.note ? `\n(${d.note})` : ''} For one row per campaign pass level:"campaign"; per day, timeIncrement:1; for WHO/WHERE it worked, breakdowns:"age,gender" or "publisher_platform,platform_position".`, d);
|
|
10830
11094
|
}));
|
|
10831
11095
|
// ---------- Meta: SEE the ad, SIZE the audience, BUILD the audience (2026-07-31) ----------
|
|
10832
11096
|
server.group('ads');
|
|
@@ -10851,6 +11115,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10851
11115
|
const misses = (d.previews || []).filter(p => !p.url).map(p => p.placement);
|
|
10852
11116
|
return ok(`Meta-rendered previews of ${d.objectId} — each link opens the REAL ad and EXPIRES IN 24 HOURS:\n${live.map(p => `• ${p.placement}: ${p.url}`).join('\n')}${misses.length ? `\n(${misses.join(', ')} not available for this creative.)` : ''}`, d);
|
|
10853
11117
|
}));
|
|
11118
|
+
server.registerTool('meta_catalog_suggestions', {
|
|
11119
|
+
title: 'Which catalog Meta would show under a new ad',
|
|
11120
|
+
description: 'Meta can show products from a catalog under a new ad by default (the product showcase). This previews what it would pick for an ad account before the ad is made: the catalog and product sets, each on by default or waiting for confirmation, or why there is none (no catalog, not enough context, not enabled). Pass adAccountId, objective (OUTCOME_SALES default, OUTCOME_TRAFFIC, OUTCOME_ENGAGEMENT or OUTCOME_LEADS) and adUrl and/or pageId; without either Meta cannot suggest. It applies to ads created from now on, and a creative\u2019s product extensions cannot be changed after it is made. Read-only, free.',
|
|
11121
|
+
inputSchema: {
|
|
11122
|
+
adAccountId: z.string().describe('Meta ad account id (act_ optional), from list_meta_ads'),
|
|
11123
|
+
objective: z.enum(['OUTCOME_SALES', 'OUTCOME_TRAFFIC', 'OUTCOME_ENGAGEMENT', 'OUTCOME_LEADS']).optional().describe('the campaign objective of the ad you plan (default OUTCOME_SALES)'),
|
|
11124
|
+
adUrl: z.string().optional().describe('the landing page the ad will open (recommended)'), pageId: z.string().optional().describe('the Facebook Page the ad will run from'),
|
|
11125
|
+
},
|
|
11126
|
+
outputSchema: { status: z.string().nullable().optional(), detail: z.string().nullable().optional(), catalog: z.any().optional(), productSets: z.array(z.any()).optional(), note: z.string().optional() },
|
|
11127
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
11128
|
+
}, wrap(async (a) => { const d = await apiPost('/api/meta/catalog-suggestions', a); return ok(d.note, d); }));
|
|
11129
|
+
|
|
10854
11130
|
server.registerTool('estimate_meta_reach', {
|
|
10855
11131
|
title: 'Estimate how many people a Meta audience reaches',
|
|
10856
11132
|
description: 'Ask Meta how many people a targeting spec can actually reach — BEFORE any budget is committed. Two ways: pass adSetId to size an ad set you already built (Meta uses its own saved targeting), or pass the same `targeting` object you would give create_meta_ad (plus optional objective / optimizationGoal / country / pixelId) to size an audience you are considering. Returns the monthly-active range, a daily-active estimate, and an explicit warning when the audience is too narrow to deliver. Free, read-only, creates nothing and spends nothing. Use it before recommending a budget and every time the user narrows a geo or piles on interests.',
|
|
@@ -11721,17 +11997,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11721
11997
|
}));
|
|
11722
11998
|
server.registerTool('google_ads_report', {
|
|
11723
11999
|
title: 'Google Ads GAQL report',
|
|
11724
|
-
description: 'Run a GAQL (Google Ads Query Language) report for detailed performance breakdowns — ad groups, ads, keywords, search terms, demographics, geo. Pass customerId + a GAQL query (SELECT … FROM <resource> WHERE segments.date DURING LAST_30_DAYS). Allowed FROM resources: campaign, ad_group, ad_group_ad, keyword_view, campaign_budget, campaign_criterion, ad_group_criterion, conversion_action, campaign_conversion_goal, search_term_view, age_range_view, gender_view, geographic_view, asset, change_event and every other documented GAQL report resource (the refusal names the full list if one is missing). Campaign network settings (Search partners / Display) are readable here as campaign.network_settings.* and changed with set_google_ads_networks. cost_micros is micros — divide by 1,000,000 for the account currency. Read-only, free.',
|
|
12000
|
+
description: 'Run a GAQL (Google Ads Query Language) report for detailed performance breakdowns — ad groups, ads, keywords, search terms, demographics, geo. Pass customerId + a GAQL query (SELECT … FROM <resource> WHERE segments.date DURING LAST_30_DAYS). Allowed FROM resources: campaign, ad_group, ad_group_ad, keyword_view, campaign_budget, campaign_criterion, ad_group_criterion, conversion_action, campaign_conversion_goal, search_term_view, age_range_view, gender_view, geographic_view, asset, change_event and every other documented GAQL report resource (the refusal names the full list if one is missing). Campaign network settings (Search partners / Display) are readable here as campaign.network_settings.* and changed with set_google_ads_networks. cost_micros is micros — divide by 1,000,000 for the account currency. Reach (metrics.unique_users, unique_users_two_plus…ten_plus, cross_device_conversions) reads FROM campaign or customer and is never summed across rows; the reply notes its window limits. Lead form submissions: list_google_ads_lead_submissions. Read-only, free.',
|
|
11725
12001
|
inputSchema: {
|
|
11726
12002
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
|
|
11727
12003
|
query: z.string().describe('GAQL, e.g. "SELECT ad_group.name, metrics.clicks, metrics.cost_micros FROM ad_group WHERE segments.date DURING LAST_7_DAYS"'),
|
|
11728
12004
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
11729
12005
|
},
|
|
11730
|
-
outputSchema: { customerId: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional() },
|
|
12006
|
+
outputSchema: { customerId: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), note: z.string().optional() },
|
|
11731
12007
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11732
12008
|
}, wrap(async (a) => {
|
|
11733
12009
|
const d = await apiPost('/api/google-ads/report', a);
|
|
11734
|
-
return ok(`${d.count} row(s) from Google Ads${d.customerId ? ` (customer ${d.customerId})` : ''}:\n${rowLines(d.rows)}`, d);
|
|
12010
|
+
return ok(`${d.count} row(s) from Google Ads${d.customerId ? ` (customer ${d.customerId})` : ''}:\n${rowLines(d.rows)}${d.note ? `\n${d.note}` : ''}`, d);
|
|
11735
12011
|
}));
|
|
11736
12012
|
// ── Google Ads: campaign → ad group → ad → keywords → targeting → bidding. Google's object graph REQUIRES all
|
|
11737
12013
|
// three levels: a campaign alone can never serve an impression, so create_google_ads_campaign can build the
|
|
@@ -12367,13 +12643,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12367
12643
|
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/audience', a); return ok(d.note || 'Done.', d); }));
|
|
12368
12644
|
server.registerTool('set_google_ads_audiences', {
|
|
12369
12645
|
title: 'Target or exclude audiences on Google Ads',
|
|
12370
|
-
description: 'Who a Google Ads campaign reaches. adGroupId (Search/Display): userLists, interests (affinity/in-market ids), customSegments (Display only), mode "observation" (report, do not narrow — default on Search) or "targeting" (narrow to them); Demand Gen ad groups take audiences[] ids. assetGroupId (Performance Max): audiences[] as signals and searchThemes[]. campaignId: exclusions only. exclude{userLists, interests, customSegments}, excludeAges ["18-24"…"65+","unknown"], excludeGenders. remove[] takes criterion resource names from a call with no changes (which lists what is set). Narrowing a LIVE ad group needs confirm:true.',
|
|
12646
|
+
description: 'Who a Google Ads campaign reaches. adGroupId (Search/Display): userLists, interests (affinity/in-market ids), customSegments (Display only), mode "observation" (report, do not narrow — default on Search) or "targeting" (narrow to them); Demand Gen ad groups take audiences[] ids. assetGroupId (Performance Max): audiences[] as signals and searchThemes[]. campaignId: exclusions only. exclude{userLists, interests, customSegments}, excludeAges ["18-24"…"65+","unknown"], excludeGenders, excludeIncomes (household income bands; with campaignId this is how a Performance Max campaign excludes them). remove[] takes criterion resource names from a call with no changes (which lists what is set). Narrowing a LIVE ad group needs confirm:true.',
|
|
12371
12647
|
inputSchema: {
|
|
12372
12648
|
customerId: z.string().optional().describe("Google Ads account id, 10 digits (dashes ok); omit for the brand's default account"), adGroupId: z.string().optional().describe('ad group to target (Search, Display or Demand Gen); one of adGroupId, assetGroupId, campaignId'), assetGroupId: z.string().optional().describe('Performance Max asset group id; audiences become signals there'), campaignId: z.string().optional().describe('campaign id; exclusions only'),
|
|
12373
12649
|
userLists: z.array(z.string()).optional().describe('user list ids to target on the ad group'), interests: z.array(z.string()).optional().describe('interest ids (affinity or in-market) to target on the ad group'), customSegments: z.array(z.string()).optional().describe('custom segment ids to target; Display ad groups only, Search refuses them'), audiences: z.array(z.string()).optional().describe('audience ids from manage_google_ads_audience (create_audience) for Demand Gen or Performance Max'),
|
|
12374
12650
|
searchThemes: z.array(z.string()).optional().describe('search theme phrases for a Performance Max asset group, up to 50, 10 words or fewer each'), mode: z.enum(['observation', 'targeting']).optional().describe('observation (report only, default on Search) or targeting (narrow delivery to these audiences)'),
|
|
12375
12651
|
exclude: z.object({ userLists: z.array(z.string()).optional().describe('user list ids to exclude'), interests: z.array(z.string()).optional().describe('interest ids to exclude'), customSegments: z.array(z.string()).optional().describe('custom segment ids to exclude') }).optional().describe('audiences to exclude from the ad group or campaign: {userLists, interests, customSegments} ids'),
|
|
12376
|
-
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'),
|
|
12652
|
+
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'), excludeIncomes: z.array(z.string()).optional().describe('household income bands to exclude: top10 (the highest-income 10% of households), 11-20, 21-30, 31-40, 41-50, lower50 (the lower half) or unknown'), remove: z.array(z.string()).optional().describe('criterion resource names to remove; a call with no other changes lists the current ones'),
|
|
12377
12653
|
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'),
|
|
12378
12654
|
},
|
|
12379
12655
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
@@ -12402,13 +12678,54 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12402
12678
|
ad: z.object({
|
|
12403
12679
|
videoAssets: z.array(z.string()).optional().describe('video asset resource names from upload_google_ads_video; one of this or youtubeVideoIds is required'), youtubeVideoIds: z.array(z.string()).optional().describe('YouTube video ids of existing videos; alternative to videoAssets'), logoImage: z.string().optional().describe('square (1:1) logo image URL or Hermoso asset; used when logoAssets is empty'), logoAssets: z.array(z.string()).optional().describe('square (1:1) logo asset resource names from upload_google_ads_asset; alternative to logoImage'),
|
|
12404
12680
|
finalUrl: z.string().describe('landing page the ad opens, full http or https URL'), businessName: z.string().describe('business name shown with the ad, 25 characters maximum'), headlines: z.array(z.string()).describe('short headlines, 1 to 5'), longHeadlines: z.array(z.string()).describe('long headlines, 1 to 5'), descriptions: z.array(z.string()).describe('ad descriptions, 1 to 5'), name: z.string().optional().describe('name of the ad inside Google Ads (optional)'),
|
|
12405
|
-
|
|
12681
|
+
assetAutomation: z.record(z.string(), z.boolean()).optional().describe('what Google may generate from this ad, {TYPE: true|false}: OVERLAY_TEXT_ON_VIDEOS (headlines laid over the video), GENERATE_VERTICAL_YOUTUBE_VIDEOS, GENERATE_SHORTER_YOUTUBE_VIDEOS, GENERATE_LANDING_PAGE_TEXT. Google turns these ON by default; pass false to keep the video exactly as made. Change it later with set_google_ads_asset_automation'),
|
|
12682
|
+
}).describe('video ad: {videoAssets or youtubeVideoIds, logo, finalUrl, businessName, headlines, longHeadlines, descriptions, assetAutomation}'),
|
|
12406
12683
|
finalUrlSuffix: z.string().optional().describe("UTM suffix appended to clicks; omit for Hermoso's default, empty string for none"), trackingUrlTemplate: z.string().optional().describe('click-tracking redirect template, e.g. {lpurl}?src=x; only if you use a tracker'), urlCustomParameters: z.record(z.string(), z.string()).optional().describe('up to 8 custom URL parameters, used in URLs as {_key}'),
|
|
12407
12684
|
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'),
|
|
12408
12685
|
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'),
|
|
12409
12686
|
},
|
|
12410
12687
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12411
12688
|
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/demand-gen', a); return ok(d.note || 'Done.', d); }));
|
|
12689
|
+
// Google Ads v26.x gaps (2026-11-12, lib/google-ads-v26x.mjs): asset automation, seasonality adjustments, lead submissions.
|
|
12690
|
+
server.registerTool('set_google_ads_asset_automation', {
|
|
12691
|
+
title: 'Read or change what Google may make from the creative (text on videos, video cuts, written text)',
|
|
12692
|
+
description: 'What Google Ads is allowed to generate from the user’s own creative: text laid over videos (OVERLAY_TEXT_ON_VIDEOS), vertical and shorter video cuts, generated videos and animated images, image crops and enhancements, written headlines and landing-page text. Pass campaignId for campaign-level automation (Performance Max, Search), or adGroupId + adId for one ad: a Demand Gen video ad keeps OVERLAY_TEXT_ON_VIDEOS, GENERATE_VERTICAL_YOUTUBE_VIDEOS and GENERATE_SHORTER_YOUTUBE_VIDEOS there, and Google turns them ON by default. With no settings it READS what is set. settings is {TYPE: true|false}; a type you do not name keeps its current value, and one that was never set follows Google’s default. Turning a type OFF needs nothing more; turning one ON for something live needs confirm:true, because Google then serves versions it generated. The reply is what Google stored, read back. Free.',
|
|
12693
|
+
inputSchema: {
|
|
12694
|
+
customerId: z.string().optional().describe("Google Ads account id, 10 digits (dashes ok); omit for the brand's default account"),
|
|
12695
|
+
campaignId: z.string().optional().describe('campaign id, for campaign-level automation; or pass adGroupId + adId for one ad'),
|
|
12696
|
+
adGroupId: z.string().optional().describe('ad group id of the ad (with adId)'), adId: z.string().optional().describe('ad id (google_ads_report FROM ad_group_ad lists ad_group.id and ad_group_ad.ad.id)'),
|
|
12697
|
+
settings: z.record(z.string(), z.boolean()).optional().describe('{TYPE: true to opt in, false to opt out}. Types: TEXT_ASSET_AUTOMATION, GENERATE_VERTICAL_YOUTUBE_VIDEOS, GENERATE_SHORTER_YOUTUBE_VIDEOS, GENERATE_LANDING_PAGE_PREVIEW, GENERATE_LANDING_PAGE_TEXT, GENERATE_ENHANCED_YOUTUBE_VIDEOS, GENERATE_IMAGE_ENHANCEMENT, GENERATE_IMAGE_EXTRACTION, GENERATE_DESIGN_VERSIONS_FOR_IMAGES, FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION, GENERATE_VIDEOS_FROM_OTHER_ASSETS, GENERATE_ANIMATED_IMAGES_FROM_OTHER_ASSETS, AUTOMATED_VIDEO_CRAWL, OVERLAY_TEXT_ON_VIDEOS. Omit to read.'),
|
|
12698
|
+
videoCrawlSources: z.array(z.object({ url: z.string().describe('full http(s) URL Google may look for videos at'), platform: z.enum(['LANDING_PAGE', 'SOCIAL', 'YOUTUBE']).describe('what kind of source the URL is'), enabled: z.boolean().optional().describe('default true') })).optional().describe('only with settings.AUTOMATED_VIDEO_CRAWL: where Google may find videos'),
|
|
12699
|
+
confirm: z.boolean().optional().describe('true to turn an automation ON for a campaign or ad that is live'), 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'),
|
|
12700
|
+
},
|
|
12701
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12702
|
+
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/asset-automation', a); return ok(d.note || 'Done.', d); }));
|
|
12703
|
+
server.registerTool('manage_google_ads_seasonality', {
|
|
12704
|
+
title: 'Google Ads seasonality adjustments (tell Smart Bidding about a sale or launch)',
|
|
12705
|
+
description: 'Seasonality adjustments tell Google’s Smart Bidding that conversion rate will move for a short event (a sale, a launch, a holiday weekend), so it bids for the event instead of learning it late. action "list" shows them; "create" needs name, start and end ("YYYY-MM-DD HH:mm:ss", the end is exclusive, at most 14 days apart, and Google expects a future event), conversionRateModifier (0.1 to 10: 1.5 means 50% more conversions per click than usual, 0.8 means 20% fewer) and what it covers: campaignIds (up to 2,000 campaigns) OR channels (SEARCH, DISPLAY, SHOPPING, DEMAND_GEN), never both; optional devices. "update" changes the named fields of one (id); "remove" deletes one (id). It moves real bids on every campaign it covers, so create, update and remove need confirm:true after the user agrees; dryRun:true has Google validate it and changes nothing. Google’s API cannot create an account-wide adjustment and does not allow them on manager accounts. The reply is read back from Google. Free.',
|
|
12706
|
+
inputSchema: {
|
|
12707
|
+
customerId: z.string().optional().describe("Google Ads account id, 10 digits (dashes ok); omit for the brand's default account"), action: z.enum(['list', 'create', 'update', 'remove']).optional().describe('default list'),
|
|
12708
|
+
id: z.string().optional().describe('seasonality adjustment id (update, remove), from action list'), name: z.string().optional().describe('name, up to 255 characters (create)'), description: z.string().optional().describe('optional note, up to 2,048 characters'),
|
|
12709
|
+
start: z.string().optional().describe('when the event begins, "YYYY-MM-DD HH:mm:ss" (inclusive)'), end: z.string().optional().describe('when it stops, "YYYY-MM-DD HH:mm:ss" (exclusive); at most 14 days after start'),
|
|
12710
|
+
conversionRateModifier: z.number().optional().describe('expected conversion rate change, 0.1 to 10 (1.5 = +50%, 0.8 = -20%)'),
|
|
12711
|
+
campaignIds: z.array(z.string()).optional().describe('campaign ids it covers (up to 2,000); not together with channels'), channels: z.array(z.enum(['SEARCH', 'DISPLAY', 'SHOPPING', 'DEMAND_GEN'])).optional().describe('every campaign of these channel types; not together with campaignIds'),
|
|
12712
|
+
devices: z.array(z.enum(['MOBILE', 'TABLET', 'DESKTOP', 'CONNECTED_TV', 'OTHER'])).optional().describe('limit it to these devices; omit for all'), includeRemoved: z.boolean().optional().describe('action list: also show removed ones'),
|
|
12713
|
+
confirm: z.boolean().optional().describe('true after the user agreed to the dates, modifier and coverage (create, update, remove)'), 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'),
|
|
12714
|
+
},
|
|
12715
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12716
|
+
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/seasonality', a); return ok(d.note || 'Done.', d); }));
|
|
12717
|
+
server.registerTool('list_google_ads_lead_submissions', {
|
|
12718
|
+
title: 'Read Google Ads lead form submissions',
|
|
12719
|
+
description: 'The people who filled in a Google Ads lead form: each submission’s time, the answers (name, email, phone and any custom questions), the Google click id, and the campaign, ad group and lead form it came from, newest first. Filter by campaignId, adGroupId, assetId (the lead form asset) and since / until (YYYY-MM-DD); limit up to 1,000 (default 100). An empty answer means none that Google still holds for this account and filter, not proof that no form was ever submitted. These are the advertiser’s own leads and contain personal details: show them to the user who asked and do not copy them anywhere else unless asked. Read-only, free.',
|
|
12720
|
+
inputSchema: {
|
|
12721
|
+
customerId: z.string().optional().describe("Google Ads account id, 10 digits (dashes ok); omit for the brand's default account"),
|
|
12722
|
+
campaignId: z.string().optional().describe('only submissions from this campaign'), adGroupId: z.string().optional().describe('only submissions from this ad group'), assetId: z.string().optional().describe('only submissions to this lead form asset'),
|
|
12723
|
+
since: z.string().optional().describe('YYYY-MM-DD, submissions on or after this day'), until: z.string().optional().describe('YYYY-MM-DD, submissions on or before this day'), limit: z.number().optional().describe('how many, 1 to 1,000 (default 100)'),
|
|
12724
|
+
loginCustomerId: z.string().optional().describe('manager (MCC) account id, when the account is managed through one'),
|
|
12725
|
+
},
|
|
12726
|
+
outputSchema: { ok: z.boolean().optional(), customerId: z.string().optional(), count: z.number().optional(), limit: z.number().optional(), truncated: z.boolean().optional(), leads: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12727
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12728
|
+
}, wrap(async (a) => { const d = await apiPost('/api/google-ads/lead-submissions', a); return ok(d.note || 'Done.', d); }));
|
|
12412
12729
|
server.registerTool('create_google_ads_performance_max_campaign', {
|
|
12413
12730
|
title: 'Create a Google Ads Performance Max campaign',
|
|
12414
12731
|
description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps). ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
|
|
@@ -12515,7 +12832,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12515
12832
|
}));
|
|
12516
12833
|
server.registerTool('analytics_report', {
|
|
12517
12834
|
title: 'Run a GA4 report',
|
|
12518
|
-
description: 'WHAT HAPPENED ON THE SITE — the GA4 Data API report, and the tool that answers "where is our traffic coming from", "which campaign converted", "which landing page is working". Pass metrics by name (activeUsers, sessions, screenPageViews, conversions, totalRevenue, engagementRate, bounceRate, averageSessionDuration …) and dimensions to break them down by (sessionDefaultChannelGroup, sessionSource / sessionMedium / sessionCampaignName, landingPage, pagePath, country, deviceCategory, date, eventName …). GA4 publishes hundreds of both and each property ships its OWN custom dimensions, so names are forwarded as given rather than validated against a copied list — use list_analytics_definitions to see a property\'s custom ones. THE PROPERTY IS A NUMERIC ID (e.g. 123456789), NOT the G-XXXXXXX Measurement ID — run list_analytics_properties to resolve it. Dates default to the last 28 days and accept either YYYY-MM-DD or GA4\'s relative forms ("28daysAgo", "yesterday", "today"). PERIOD OVER PERIOD IN ONE CALL: compare:"previous_period" (or "previous_year") adds the earlier period and returns per-period totals plus `change` (absolute + %) computed from GA4\'s own totals — quote it, never redo the maths; or pass up to 4 named dateRanges. comparisons put segments side by side; totals:true adds a total row. Rows come back as flat named objects,
|
|
12835
|
+
description: 'WHAT HAPPENED ON THE SITE — the GA4 Data API report, and the tool that answers "where is our traffic coming from", "which campaign converted", "which landing page is working". Pass metrics by name (activeUsers, sessions, screenPageViews, conversions, totalRevenue, engagementRate, bounceRate, averageSessionDuration …) and dimensions to break them down by (sessionDefaultChannelGroup, sessionSource / sessionMedium / sessionCampaignName, landingPage, pagePath, country, deviceCategory, date, eventName …). GA4 publishes hundreds of both and each property ships its OWN custom dimensions, so names are forwarded as given rather than validated against a copied list — use list_analytics_definitions to see a property\'s custom ones. THE PROPERTY IS A NUMERIC ID (e.g. 123456789), NOT the G-XXXXXXX Measurement ID — run list_analytics_properties to resolve it. Dates default to the last 28 days and accept either YYYY-MM-DD or GA4\'s relative forms ("28daysAgo", "yesterday", "today"). PERIOD OVER PERIOD IN ONE CALL: compare:"previous_period" (or "previous_year") adds the earlier period and returns per-period totals plus `change` (absolute + %) computed from GA4\'s own totals — quote it, never redo the maths; or pass up to 4 named dateRanges. comparisons put segments side by side; totals:true adds a total row. Rows come back as flat named objects. When GA4 says the data is SAMPLED, TRUNCATED, rolled into an "(other)" row or thresholded, completenessNotes says so in words; repeat those when reporting. Read-only, 0 credits.',
|
|
12519
12836
|
inputSchema: {
|
|
12520
12837
|
property: z.string().describe('the NUMERIC GA4 property id from list_analytics_properties (e.g. "123456789") — never the G-XXXXXXX Measurement ID, and it must be one SHARED with this profile'),
|
|
12521
12838
|
metrics: z.array(z.string()).optional().describe('GA4 metric names — default ["activeUsers","sessions","screenPageViews"]'),
|
|
@@ -12551,7 +12868,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12551
12868
|
// PERIOD CHANGE is computed server-side from GA4's own totals (lib/ga4-report-depth.mjs) — printed so nobody redoes it.
|
|
12552
12869
|
const chg = d.change ? `\nCHANGE (${(d.dateRanges || []).map(r => r.name).join(' vs ')}, from GA4's totals — quote these): ${Object.entries(d.change).map(([m, c]) => `${m} ${c.current} vs ${c.previous} (${c.pct == null ? 'previous was 0' : `${c.pct > 0 ? '+' : ''}${c.pct}%`})`).join(' · ')}` : '';
|
|
12553
12870
|
const tot = d.totals && !d.change ? `\nTOTALS: ${d.totals.map(t => Object.entries(t).map(([k, v]) => `${k}=${v}`).join(' · ')).join(' | ')}` : '';
|
|
12554
|
-
|
|
12871
|
+
// WHAT GOOGLE SAID ABOUT COMPLETENESS (sampled, truncated, rolled into "(other)", thresholded), one line each,
|
|
12872
|
+
// built server-side from GA4's own metadata (lib/ga4-report-honesty.mjs) so every surface prints the same words.
|
|
12873
|
+
const cmp = (d.completenessNotes || []).length ? `\n${d.completenessNotes.map((x) => `⚠ ${x}`).join('\n')}` : '';
|
|
12874
|
+
return ok(`${head}${d.filtered ? ' (FILTERED — this is a SUBSET of the property, not its total)' : ''}${/\.$/.test(head) && !d.filtered ? '' : '.'}${chg}${tot}${cmp}\n${rowLines(d.rows)}`, d);
|
|
12555
12875
|
}));
|
|
12556
12876
|
server.registerTool('analytics_realtime', {
|
|
12557
12877
|
title: 'Who is on the site right now (GA4 realtime)',
|
|
@@ -15414,14 +15734,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15414
15734
|
// ---------- Amazon Ads (2026-11-07): Sponsored Products on the brand's own Amazon Ads account, through Login with Amazon.
|
|
15415
15735
|
// Ships DARK (mcp/roster-scope.mjs AMAZON_ADS_DARK_TOOLS) until Amazon approves Hermoso's API access. Every rule is in
|
|
15416
15736
|
// lib/amazon-ads.mjs: every create is born PAUSED, ENABLED needs confirm:true, delete ARCHIVES and is permanent.
|
|
15417
|
-
|
|
15737
|
+
// A profile must be one this BRAND ticked (set_connector_accounts with provider "amazon_ads"); any other is refused by
|
|
15738
|
+
// the server before Amazon is called, so this tool surface never sees the login's whole roster.
|
|
15739
|
+
const amzProfile = z.string().optional().describe('the Amazon Ads profile id (list_amazon_ads_profiles); optional when exactly one is shared with this brand');
|
|
15418
15740
|
const amzIds = z.array(z.string());
|
|
15419
15741
|
const amzOut = { ok: z.boolean().optional(), profileId: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() };
|
|
15420
15742
|
const amzStrategy = z.enum(['LEGACY_FOR_SALES', 'AUTO_FOR_SALES', 'MANUAL', 'RULE_BASED']).optional().describe('LEGACY_FOR_SALES (default) dynamic bids down only; AUTO_FOR_SALES up and down; MANUAL fixed bids; RULE_BASED');
|
|
15421
15743
|
const amzPlacements = z.record(z.number()).optional().describe('bid increase per placement in whole percent 0-900, keys PLACEMENT_TOP, PLACEMENT_REST_OF_SEARCH, PLACEMENT_PRODUCT_PAGE, SITE_AMAZON_BUSINESS');
|
|
15422
15744
|
server.registerTool('list_amazon_ads_profiles', {
|
|
15423
15745
|
title: 'List Amazon Ads profiles',
|
|
15424
|
-
description: 'The Amazon Ads advertiser profiles this
|
|
15746
|
+
description: 'The Amazon Ads advertiser profiles shared with this brand (ticked under Manage accounts), one per advertiser per marketplace across North America, Europe and the Far East: profileId, marketplace, currency, seller or vendor, account name. Only these are usable. Every other Amazon Ads tool takes amazonProfileId. Read-only, free.',
|
|
15425
15747
|
inputSchema: {},
|
|
15426
15748
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), profiles: z.array(z.any()).optional(), note: z.string().optional() },
|
|
15427
15749
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -15679,6 +16001,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15679
16001
|
fileIds: z.array(z.string()).optional().describe('the same as imageUrls with images already on the account (4–6 file ids); may be mixed with imageUrls, ids first. On an edit, omit both to keep the ad\u2019s current images; a new list replaces them all'),
|
|
15680
16002
|
price: z.string().optional().describe('optional price string shown on the card'),
|
|
15681
16003
|
});
|
|
16004
|
+
// PRODUCT FILTERS FOR A PRODUCT-FEED AD GROUP (2026-11-12): openapi.json 2.3.0 ProductSetParams / ProductSetFilterParams.
|
|
16005
|
+
const oaiProductSetShape = z.object({
|
|
16006
|
+
productFeedId: z.string().optional().describe('the campaign\u2019s product feed; omit to use it'),
|
|
16007
|
+
filters: z.array(z.object({
|
|
16008
|
+
field: z.string().describe('feed attribute: brand, price, product_category, item_id, star_rating, title, condition, or ads_metadata.custom_label_0'),
|
|
16009
|
+
operator: z.enum(['in', 'not_in', 'gt', 'gte', 'lt', 'lte', 'contains', 'not_contains', 'starts_with']).describe('in / not_in match any listed value; gt / gte / lt / lte compare one number'),
|
|
16010
|
+
values: z.array(z.string()).describe('values as text, e.g. ["Acme"] or ["100"]'),
|
|
16011
|
+
})).optional().describe('a product must meet ALL filters. Several on one field are allowed and narrow each other (price gte 20 AND price lte 100). On an edit this list REPLACES the old one; [] removes them all'),
|
|
16012
|
+
}).describe('which products of a product-feed campaign this ad group advertises; omit for every product in the feed');
|
|
15682
16013
|
server.registerTool('list_openai_ads_campaigns', {
|
|
15683
16014
|
title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
|
|
15684
16015
|
description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected under Settings > Connectors > ChatGPT Ads in the Hermoso app; one connection covers one ad account.',
|
|
@@ -15694,9 +16025,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15694
16025
|
const d = await apiGet('/api/openai-ads/campaigns', a);
|
|
15695
16026
|
const blk = (x) => `${x.servingIssues && x.servingIssues.length ? ` ⚠ not serving: ${x.servingIssues.map(i => i.meaning).join('; ')}` : ''}${x.phoneVerificationRequired ? ' ⚠ the advertiser must verify a phone number in Ads Manager before this can serve' : ''}${x.geographicTargetingBlocked ? ' ⚠ OpenAI flags this one’s geographic targeting as blocked' : ''}`;
|
|
15696
16027
|
if (d.level === 'ad') return ok(`${d.count} ad(s) in ChatGPT Ads ad group ${d.adGroupId}:\n${(d.ads || []).map(x => `• ${x.title || x.name} (${x.id}) — ${x.status}, review ${x.reviewStatus || 'unknown'}${x.appeal ? `, appeal ${x.appeal.status}` : ''}${x.targetUrl ? ` → ${x.targetUrl}` : ''}${blk(x)}`).join('\n') || '(none)'}`, d);
|
|
15697
|
-
|
|
16028
|
+
const psTxt = (ps) => (ps ? `, feed ${ps.productFeedId}: ${(ps.filters || []).length ? ps.filters.map((f) => `${f.field} ${f.operator} ${(f.values || []).join('|')}`).join(' AND ') : 'every product'}` : '');
|
|
16029
|
+
if (d.level === 'adGroup') return ok(`${d.count} ad group(s) in ChatGPT Ads campaign ${d.campaignId}:\n${(d.adGroups || []).map(g => `• ${g.name} (${g.id}) — ${g.status}, ${g.contextHints} context hint(s)${g.bidLabel ? `, bids by ${g.bidLabel}` : `${g.bidStrategy && g.bidStrategy !== 'fixed_bid' ? `, ${g.bidStrategy}${g.targetBid != null ? ` (target ${g.targetBid})` : ''}` : ''}${g.maxBid != null ? `, max bid ${g.maxBid}` : ''}`}${g.bidDeprecation ? ' — Max Bid / Max CPC retires 2027-01-01, move it to a target or intelligent bidding' : ''}${(g.audienceBidMultipliers || []).length ? `, ${g.audienceBidMultipliers.length} audience bid multiplier(s)` : ''}${psTxt(g.productSet)}${blk(g)}`).join('\n') || '(none)'}`, d);
|
|
15698
16030
|
const acc = d.account;
|
|
15699
|
-
return ok(`ChatGPT Ads account${acc ? ` "${acc.brandName || acc.name}" (${acc.id})${acc.currency ? `, ${acc.currency}` : ''}${acc.status ? ` · ${acc.status}` : ''}${acc.reviewStatus ? ` · brand review ${acc.reviewStatus}` : ''}${acc.legalName && acc.legalName !== (acc.brandName || acc.name) ? ` · legal name "${acc.legalName}"` : ''}${acc.accountName && acc.accountName !== (acc.brandName || acc.name) ? ` · account name "${acc.accountName}"` : ''}` : ''}\n${d.count} campaign(s):\n${(d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day` : ''}${c.lifetimeBudget != null ? `, ${c.lifetimeBudget} lifetime` : ''}${c.biddingType ? `, ${c.biddingType}` : ''}${blk(c)}
|
|
16031
|
+
return ok(`ChatGPT Ads account${acc ? ` "${acc.brandName || acc.name}" (${acc.id})${acc.currency ? `, ${acc.currency}` : ''}${acc.status ? ` · ${acc.status}` : ''}${acc.reviewStatus ? ` · brand review ${acc.reviewStatus}` : ''}${acc.legalName && acc.legalName !== (acc.brandName || acc.name) ? ` · legal name "${acc.legalName}"` : ''}${acc.accountName && acc.accountName !== (acc.brandName || acc.name) ? ` · account name "${acc.accountName}"` : ''}` : ''}\n${d.count} campaign(s):\n${(d.campaigns || []).map(c => { const inc = [...(c.countries || []), ...(c.locationTargets || []).map((l) => `${l.name || l.id}${l.type && l.type !== 'country' ? ` (${l.type})` : ''}`)], exc = [...(c.excludedCountries || []), ...(c.excludedLocations || [])]; return `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day` : ''}${c.lifetimeBudget != null ? `, ${c.lifetimeBudget} lifetime` : ''}${c.biddingType ? `, ${c.biddingType}` : ''}${c.mode === 'product_feed' ? `, product feed ${c.productFeedId || '(none set)'}` : ''}${inc.length ? `, in ${inc.join(', ')}` : ''}${exc.length ? `, not in ${exc.join(', ')}` : ''}${blk(c)}`; }).join('\n') || '(none)'}`, d);
|
|
15700
16032
|
}));
|
|
15701
16033
|
server.registerTool('openai_ads_report', {
|
|
15702
16034
|
title: 'ChatGPT Ads performance report',
|
|
@@ -15728,7 +16060,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15728
16060
|
}));
|
|
15729
16061
|
server.registerTool('openai_ads_geo_search', {
|
|
15730
16062
|
title: 'Find ChatGPT Ads location ids',
|
|
15731
|
-
description: 'Look up ChatGPT Ads location ids by name
|
|
16063
|
+
description: 'Look up ChatGPT Ads location ids by name (countries, regions and markets) so a campaign can be geo-targeted, product-feed campaigns included. GEO AND CUSTOM AUDIENCES ARE THE ONLY LIST-BASED TARGETING THIS PLATFORM HAS: there are no interests, no lookalikes, no age or gender. Everything else is semantic, through an ad group’s context hints. Custom audiences are targeted with customAudienceIds / excludedCustomAudienceIds (see list_openai_ads_audiences). Pass the returned ids as locationIds (or excludedLocationIds to leave a place out) when creating or updating a campaign; a campaign with no location targeting runs everywhere available. OpenAI opens new markets without notice (Indonesia, Malaysia, the Philippines, Singapore, Thailand, Vietnam and Taiwan went live 2026-09-30), so search a market rather than assuming from memory that ChatGPT Ads does not serve it. Read-only, free.',
|
|
15732
16064
|
inputSchema: { query: z.string().describe('a place name, e.g. "Toronto" or "United Kingdom"'), limit: z.number().optional().describe('maximum number of locations to return') },
|
|
15733
16065
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
15734
16066
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -17130,7 +17462,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17130
17462
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/archive', a); return ok(d.note, d); }));
|
|
17131
17463
|
server.registerTool('create_openai_ads_campaign', {
|
|
17132
17464
|
title: 'Build a ChatGPT Ads campaign (paused)',
|
|
17133
|
-
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign -> ad group -> ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, contextHints, ad{creative}} and this builds the whole tree. BIDDING: leave the ad group’s bid out and it gets INTELLIGENT BIDDING (maximize_clicks / maximize_conversions, OpenAI’s renamed “Maximize results”)
|
|
17465
|
+
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign -> ad group -> ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, contextHints, ad{creative}} and this builds the whole tree. BIDDING: leave the ad group’s bid out and it gets INTELLIGENT BIDDING (maximize_clicks / maximize_conversions, OpenAI’s renamed “Maximize results”), or a cost target (target_cpa + targetCpa, target_cpc + targetCpc); a fixed maxBid (Max Bid / Max CPC) is deprecated and ChatGPT Ads retires it on 2027-01-01. PRODUCT-FEED CAMPAIGNS: pass productFeedId, and adGroup.productSet to choose which products. THE CREATIVE IS A TEXT + IMAGE CARD AND NOTHING ELSE — title 3–50 characters, body 100 maximum, one landing page, one still image. THERE IS NO VIDEO ON THIS CHANNEL: never offer a video ad here, and if the brand only has video, pull a frame from it first. TARGETING IS SEMANTIC: context hints are natural-language descriptions of the conversations where this ad belongs (up to 2,000 per ad group). Geo (countries / locationIds) and PLATFORMS (which of the iOS app, Android app and web the ad runs on) are the only other dimensions — leave platforms out to run on all three. They guide matching, they are NOT exact-match keywords, and they do not guarantee delivery. OpenAI’s own guidance is BREADTH — many genuinely distinct hints and many distinct title/body angles beat one message repeated. The whole tree is VALIDATED before the first write; if a level below the campaign is still rejected, the campaign is left PAUSED (spending nothing) and the note says exactly what exists. Everything is READ BACK from OpenAI before you are told it exists: print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
17134
17466
|
inputSchema: {
|
|
17135
17467
|
name: z.string().describe('campaign name, at least 3 characters'),
|
|
17136
17468
|
description: z.string().optional().describe('optional campaign description'),
|
|
@@ -17142,7 +17474,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17142
17474
|
landingPageQueryTemplate: z.string().optional().describe('query-string template appended to landing URLs (e.g. utm_source=chatgpt&utm_campaign={campaign_id}) — ChatGPT Ads’ landing_page_configuration.query_string_template'),
|
|
17143
17475
|
biddingType: z.enum(['impressions', 'clicks', 'conversions']).optional().describe('default clicks (CPC). "conversions" is oCPC — you still pay per click, but ChatGPT Ads optimises toward a conversion event, and it REQUIRES conversionEventSettingIds naming exactly one active event setting.'),
|
|
17144
17476
|
countries: z.array(z.string()).optional().describe('2-letter country codes'),
|
|
17145
|
-
locationIds: z.array(z.string()).optional().describe('ids from openai_ads_geo_search
|
|
17477
|
+
locationIds: z.array(z.string()).optional().describe('region or market ids from openai_ads_geo_search, up to 2,500; product-feed campaigns take them too'),
|
|
17478
|
+
excludedCountries: z.array(z.string()).optional().describe('2-letter country codes to leave out'),
|
|
17479
|
+
excludedLocationIds: z.array(z.string()).optional().describe('region or market ids to leave out, e.g. one state of a targeted country'),
|
|
17480
|
+
productFeedId: z.string().optional().describe('make this a PRODUCT-FEED campaign advertising this feed (list_openai_ads_feeds); fixed once created'),
|
|
17146
17481
|
platforms: z.array(z.enum(['ios_app', 'android_app', 'web', 'desktop_web', 'ios_web', 'android_web'])).optional().describe('WHICH CHATGPT SURFACES THIS CAMPAIGN RUNS ON — OpenAI’s “Eligible platforms”: ios_app (the ChatGPT iOS app), android_app (the Android app), web (ChatGPT in ANY browser), or narrower browser targets desktop_web, ios_web and android_web. OMIT IT to run everywhere, which is the default and almost always right; naming a subset STOPS the ad serving everywhere else. Never combine web with desktop_web / ios_web / android_web: web already includes them, and OpenAI says to name the individual browser values without web when narrowing (that combination is refused before anything is sent). There is no empty state — ChatGPT Ads refuses an empty list — so widening back means naming ios_app, android_app and web.'),
|
|
17147
17482
|
customAudienceIds: z.array(z.string()).optional().describe('TARGET a CUSTOM AUDIENCE — ids from list_openai_ads_audiences (created with create_openai_ads_audience, then filled with members). Until 2026-08-12 an audience could be created AND uploaded and then pointed at nothing: this is the field that consumes them. Combines with geo — the ad reaches people in the named locations who are ALSO in these audiences.'),
|
|
17148
17483
|
excludedCustomAudienceIds: z.array(z.string()).optional().describe('EXCLUDE custom audiences — same ids, opposite effect (suppressing existing customers, say). An id in BOTH lists is refused rather than resolved by a guess, because OpenAI does not document which side wins.'),
|
|
@@ -17156,6 +17491,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17156
17491
|
targetCpc: z.number().optional().describe("Target CPC in the account currency: the average cost per click OpenAI aims for (bidStrategy target_cpc, clicks campaign, from 2026-11-09). Not with maxBid."),
|
|
17157
17492
|
billingEvent: z.enum(['click', 'impression']).optional().describe('what the ad group is billed on: click (default) or impression'),
|
|
17158
17493
|
contextHints: z.array(z.string()).optional().describe('up to 2,000 natural-language conversation/topic descriptions — make them genuinely distinct from each other'),
|
|
17494
|
+
productSet: oaiProductSetShape.optional(),
|
|
17159
17495
|
ad: z.object({ name: z.string().optional().describe('internal ad name; defaults to the title'), creative: oaiCreativeShape.describe('title 3-50 characters, body up to 100, landing page URL and one still image; no video') }).optional().describe('the ad created inside the ad group; carries the creative'),
|
|
17160
17496
|
}).optional().describe('build the ad group (and its ad) in the same call — a campaign alone cannot serve'),
|
|
17161
17497
|
},
|
|
@@ -17176,6 +17512,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17176
17512
|
targetCpc: z.number().optional().describe("Target CPC in the account currency: the average cost per click OpenAI aims for (bidStrategy target_cpc, clicks campaign, from 2026-11-09). Not with maxBid."),
|
|
17177
17513
|
billingEvent: z.enum(['click', 'impression']).optional().describe('default click'),
|
|
17178
17514
|
contextHints: z.array(z.string()).optional().describe('up to 2,000, deduplicated server-side'),
|
|
17515
|
+
productSet: oaiProductSetShape.optional(),
|
|
17179
17516
|
landingPageQueryTemplate: z.string().optional().describe('query string added to every landing page click, e.g. utm_source=chatgpt&utm_content={ad_id}. Placeholders: {campaign_id} {ad_group_id} {ad_id} {ad_account_id} {oppref}; oppref and olref cannot be parameter names. On an update, an empty string clears it.'),
|
|
17180
17517
|
audienceBidMultipliers: z.array(z.object({ customAudienceId: z.string().describe('custom audience id to bid more or less for, from list_openai_ads_audiences'), multiplier: z.number().describe('0.1 to 10; 2 bids twice as much for this audience') })).optional().describe('bid more or less for people in a custom audience (eligible ids: list_openai_ads_audiences with intendedUse bid_multiplier, about 25,000 matched users). On an update this REPLACES the list; [] removes them all, and a bid change keeps the existing multipliers.'),
|
|
17181
17518
|
status: z.enum(['active', 'paused']).optional().describe('default paused'),
|
|
@@ -17244,12 +17581,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17244
17581
|
campaignId: z.string().optional().describe('campaign id to edit; use with level campaign'), adGroupId: z.string().optional().describe('ad group id to edit; use with level adGroup'), adId: z.string().optional().describe('ad id to edit; use with level ad'),
|
|
17245
17582
|
name: z.string().optional().describe('new name'), description: z.string().optional().describe('new description'),
|
|
17246
17583
|
dailyBudget: z.number().optional().describe('new daily budget in the account currency; campaign level'), lifetimeBudget: z.number().optional().describe('new lifetime budget in the account currency; campaign level'),
|
|
17247
|
-
countries: z.array(z.string()).optional().describe('2-letter country codes; replaces the whole geo list, so send the full list'), locationIds: z.array(z.string()).optional().describe('
|
|
17584
|
+
countries: z.array(z.string()).optional().describe('2-letter country codes; replaces the whole geo list, so send the full list'), locationIds: z.array(z.string()).optional().describe('region or market ids from openai_ads_geo_search; replaces the whole list'),
|
|
17585
|
+
excludedCountries: z.array(z.string()).optional().describe('2-letter country codes to leave out; replaces the list, [] clears it'), excludedLocationIds: z.array(z.string()).optional().describe('region or market ids to leave out; replaces the list, [] clears it'), endTime: z.number().optional().describe('unix seconds when the campaign stops; campaign level'),
|
|
17248
17586
|
platforms: z.array(z.enum(['ios_app', 'android_app', 'web', 'desktop_web', 'ios_web', 'android_web'])).optional().describe('REPLACES which ChatGPT surfaces the campaign runs on (ios_app / android_app / web, or the narrower browser targets desktop_web / ios_web / android_web; never web together with those, since web already includes them). Wholesale like the rest of targeting: a patch that changes geo or audiences on a campaign that already restricts platforms is REFUSED by name rather than silently widening it back to every surface. There is no [] — name all three to go back to everywhere.'),
|
|
17249
17587
|
customAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s targeted custom audiences. TARGETING IS REPLACED WHOLESALE, not merged — a patch that omits something the campaign already targets is REFUSED by name rather than silently dropping it, so restate it here or pass [] to clear it deliberately.'),
|
|
17250
17588
|
excludedCustomAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s excluded custom audiences — same wholesale rule as customAudienceIds.'),
|
|
17251
17589
|
conversionEventSettingIds: z.array(z.string()).optional().describe('NOT CHANGEABLE: ChatGPT Ads refuses a new conversion event on an existing campaign ("cannot be modified after creation"). Passing it is refused with the way forward: build a new campaign with create_openai_ads_campaign, move the ad group and ads, pause this one.'),
|
|
17252
17590
|
contextHints: z.array(z.string()).optional().describe('REPLACES the existing list'),
|
|
17591
|
+
productSet: oaiProductSetShape.optional(),
|
|
17253
17592
|
maxBid: z.number().optional().describe("DEPRECATED — Max Bid / Max CPC, retired by ChatGPT Ads on 2027-01-01: a fixed bid in the account currency (bidStrategy fixed_bid; on a conversions campaign it is the CPA bid). Needed only on an impressions campaign (per impression); otherwise use intelligent bidding or a target."), billingEvent: z.enum(['click', 'impression']).optional().describe('required alongside a maxBid — bidding is replaced wholesale; maximize_* and target_* are click-billed and need no billingEvent'),
|
|
17254
17593
|
bidStrategy: z.enum(['fixed_bid', 'maximize_clicks', 'maximize_conversions', 'target_cpc', 'target_cpa']).optional().describe("CHANGE HOW THIS AD GROUP BIDS. BIDDING IS REPLACED WHOLESALE, so a patch that moves the bid without restating the strategy would DEMOTE a maximize_* or target_* ad group, and one that sets a strategy without restating maxBid / the target DELETES it — both are refused by name with what would have been lost. To move an existing Max Bid / Max CPC ad group (fixed_bid, retired 2027-01-01) onto a target from 2026-11-09, pass bidStrategy target_cpa + targetCpa (conversions campaign) or target_cpc + targetCpc (clicks campaign); the old maxBid is dropped on purpose because OpenAI refuses both together. maximize_clicks / maximize_conversions is intelligent bidding."),
|
|
17255
17594
|
targetCpa: z.number().optional().describe("Target CPA in the account currency: the average cost per conversion OpenAI aims for (bidStrategy target_cpa, conversions campaign, from 2026-11-09). Billing stays per click. Not with maxBid."),
|
|
@@ -18976,7 +19315,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18976
19315
|
}));
|
|
18977
19316
|
server.registerTool('list_reddit_ads_lead_forms', {
|
|
18978
19317
|
title: 'List Reddit lead generation forms',
|
|
18979
|
-
description: 'Listing and reading forms KEEPS WORKING past the 2026-09-21 sunset
|
|
19318
|
+
description: 'THERE IS NO CREATE: Reddit retired onsite lead forms (the create call answers 410 Gone since 2026-09-30 and ads still using one were paused), so Hermoso offers no tool that makes one. For new leads from Reddit, use a form on the advertiser’s own landing page inside a LEAD_GENERATION campaign (create_reddit_ads_campaign), measured with list_reddit_ads_pixels / send_reddit_ads_conversions. Listing and reading forms KEEPS WORKING past the 2026-09-21 sunset so advertisers can retain their records, but Reddit marked these reads deprecated too on 2026-11-05 (existing forms only, gone from later API versions, no end date yet): use this to EXPORT what exists. Reddit exposes no API for the LEADS a form collected; those are downloaded from Ads Manager, and existing onsite-form ads are paused on 2026-09-30. List the lead generation forms on a Reddit ad account, with the fields each one asks for. Reddit publishes NO endpoint for reading the leads a form has collected — the user downloads those from Reddit’s Ads Manager. Say that plainly if asked for the leads themselves; do not imply they can be fetched. Read-only, free.',
|
|
18980
19319
|
inputSchema: { adAccountId: z.string().optional().describe('Reddit ad account id (a2_...); omit when only one is shared'), limit: z.number().optional().describe('default 50, max 100') },
|
|
18981
19320
|
outputSchema: { adAccountId: z.string().optional(), count: z.number().optional(), forms: z.array(z.any()).optional(), note: z.string().optional() },
|
|
18982
19321
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -18984,25 +19323,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18984
19323
|
const d = await apiGet('/api/reddit-ads/lead-forms', a);
|
|
18985
19324
|
return ok(d.note, d);
|
|
18986
19325
|
}));
|
|
18987
|
-
|
|
18988
|
-
|
|
18989
|
-
|
|
18990
|
-
|
|
18991
|
-
adAccountId: z.string().optional().describe('Reddit ad account id (a2_...); omit when only one is shared'),
|
|
18992
|
-
name: z.string().describe('internal name — redditors do not see it'),
|
|
18993
|
-
prompt: z.string().describe('the line shown above the form telling people what they are signing up for'),
|
|
18994
|
-
privacyLink: z.string().describe('full https:// URL to your privacy policy — Reddit requires it'),
|
|
18995
|
-
questions: z.array(z.object({
|
|
18996
|
-
type: z.enum(['EMAIL', 'FIRST_NAME', 'LAST_NAME', 'PHONE_NUMBER', 'POSTAL_CODE', 'JOB_TITLE', 'COMPANY', 'COMPANY_EMAIL']).describe('contact field to ask: EMAIL, FIRST_NAME, LAST_NAME, PHONE_NUMBER, POSTAL_CODE, JOB_TITLE, COMPANY or COMPANY_EMAIL'),
|
|
18997
|
-
required: z.boolean().optional().describe('default true'),
|
|
18998
|
-
})).describe('at least one'),
|
|
18999
|
-
},
|
|
19000
|
-
outputSchema: { id: z.string().optional(), name: z.string().optional(), questions: z.array(z.any()).optional(), note: z.string().optional() },
|
|
19001
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
19002
|
-
}, wrap(async (a) => {
|
|
19003
|
-
const d = await apiPost('/api/reddit-ads/lead-forms', a);
|
|
19004
|
-
return ok(d.note, d);
|
|
19005
|
-
}));
|
|
19326
|
+
// create_reddit_ads_lead_form RETIRED FROM THE ROSTER 2026-11-11 (gap audit D1). Reddit removed onsite lead forms:
|
|
19327
|
+
// the create call answers 410 Gone from 2026-09-30, so the tool could only refuse and cost roster tokens on every
|
|
19328
|
+
// turn. A host on an older snapshot, or call_tool, gets the sentence in LEGACY_TOOL_NAMES (gone: 'reddit_lead_form');
|
|
19329
|
+
// list_reddit_ads_lead_forms says up front that there is no create and names the replacement.
|
|
19006
19330
|
server.registerTool('reddit_ads_history', {
|
|
19007
19331
|
title: 'Reddit ad account changelog',
|
|
19008
19332
|
description: 'Read the CHANGELOG for a Reddit ad account — what was changed, from what to what, by which member, and when. This is the tool for "performance fell off a cliff on Tuesday, what changed?" and for auditing what an agent or a teammate actually did. Call it with nothing but the ad account to get every change; narrow it with a date window, change types (BUDGET, BID, STATUS, TARGETING…) or specific campaign / ad group / ad ids. An empty result genuinely means nothing was changed in that window — say that, do not read it as missing data. Read-only, free.',
|
|
@@ -19586,7 +19910,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19586
19910
|
// customer comparing us on Google (where we ship Performance Max) against TikTok found a hole on one side.
|
|
19587
19911
|
server.registerTool('create_tiktok_smart_campaign', {
|
|
19588
19912
|
title: 'Create a TikTok Smart+ campaign (forced paused)',
|
|
19589
|
-
description: 'Create an UPGRADED SMART+ campaign — TikTok’s automated campaign type, where TikTok picks the audience, placements and creative combinations for you. It is what a customer means by "smart campaign", "automated campaign", "performance max" or "Smart+" on TikTok. CREATED PAUSED WITH NO OVERRIDE, exactly like the classic lane and for the same reason: TikTok creates ENABLED by default, so an omitted status would ship something that serves on the next auction. It spends nothing until set_tiktok_smart_status(confirm:true). SMART+ TAKES A SHORTER OBJECTIVE LIST THAN A CLASSIC CAMPAIGN — APP_PROMOTION, WEB_CONVERSIONS, LEAD_GENERATION and nothing else — and a value TikTok accepts on the classic endpoint can still be refused here, at the ad group, after the campaign exists; that is refused up front instead. CATALOG ADS: set catalogEnabled and catalogType (GENERIC for itemized NON-commerce inventories,
|
|
19913
|
+
description: 'Create an UPGRADED SMART+ campaign — TikTok’s automated campaign type, where TikTok picks the audience, placements and creative combinations for you. It is what a customer means by "smart campaign", "automated campaign", "performance max" or "Smart+" on TikTok. CREATED PAUSED WITH NO OVERRIDE, exactly like the classic lane and for the same reason: TikTok creates ENABLED by default, so an omitted status would ship something that serves on the next auction. It spends nothing until set_tiktok_smart_status(confirm:true). SMART+ TAKES A SHORTER OBJECTIVE LIST THAN A CLASSIC CAMPAIGN — APP_PROMOTION, WEB_CONVERSIONS, LEAD_GENERATION and nothing else — and a value TikTok accepts on the classic endpoint can still be refused here, at the ad group, after the campaign exists; that is refused up front instead. CATALOG ADS: set catalogEnabled and catalogType (ECOMMERCE for a commerce catalog; GENERIC for itemized NON-commerce inventories; TRAVEL_ENTERTAINMENT for hotels, flights and destinations, on WEB_CONVERSIONS or, since TikTok added App Travel Catalog Ads on 2026-11-06, on APP_PROMOTION). There is no PRODUCT catalog type.',
|
|
19590
19914
|
inputSchema: {
|
|
19591
19915
|
name: z.string().describe('campaign name'),
|
|
19592
19916
|
objective: z.enum(['APP_PROMOTION', 'WEB_CONVERSIONS', 'LEAD_GENERATION']).describe('Smart+ supports these three ONLY'),
|
|
@@ -19595,9 +19919,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19595
19919
|
budgetMode: z.enum(['BUDGET_MODE_DYNAMIC_DAILY_BUDGET', 'BUDGET_MODE_TOTAL', 'BUDGET_MODE_INFINITE', 'BUDGET_MODE_DAY']).optional().describe('the valid set depends on budgetOptimizeOn: DYNAMIC_DAILY_BUDGET or TOTAL with CBO on (the default), INFINITE / DAY / TOTAL with CBO off. Everything except INFINITE also needs budget'),
|
|
19596
19920
|
budgetOptimizeOn: z.boolean().optional().describe('campaign budget optimisation (CBO). TikTok is retiring non-CBO App Install VBO campaigns via the API at the end of September 2026, so leave this true for APP_INSTALL'),
|
|
19597
19921
|
catalogEnabled: z.boolean().optional().describe('make it a catalog campaign'),
|
|
19598
|
-
salesDestination: z.enum(['WEBSITE', 'APP']).optional().describe('REQUIRED for WEB_CONVERSIONS
|
|
19599
|
-
catalogType: z.enum(['ECOMMERCE', 'TRAVEL_ENTERTAINMENT', 'MINI_SERIES', 'GENERIC', 'ONLINE_TO_OFFLINE']).optional().describe('required when catalogEnabled, and the valid set depends on the OBJECTIVE: WEB_CONVERSIONS takes ECOMMERCE / TRAVEL_ENTERTAINMENT / MINI_SERIES / GENERIC, LEAD_GENERATION takes GENERIC only, APP_PROMOTION takes ONLINE_TO_OFFLINE
|
|
19922
|
+
salesDestination: z.enum(['WEBSITE', 'APP', 'WEB_AND_APP']).optional().describe('REQUIRED for WEB_CONVERSIONS: where the sales happen. APP additionally needs a catalog; WEB_AND_APP drives sales on both'),
|
|
19923
|
+
catalogType: z.enum(['ECOMMERCE', 'TRAVEL_ENTERTAINMENT', 'MINI_SERIES', 'GENERIC', 'ONLINE_TO_OFFLINE']).optional().describe('required when catalogEnabled, and the valid set depends on the OBJECTIVE: WEB_CONVERSIONS takes ECOMMERCE / TRAVEL_ENTERTAINMENT / MINI_SERIES / GENERIC, LEAD_GENERATION takes GENERIC only, APP_PROMOTION takes ONLINE_TO_OFFLINE or TRAVEL_ENTERTAINMENT. MINI_SERIES and both APP_PROMOTION catalog types are allowlist-only at TikTok'),
|
|
19600
19924
|
appPromotionType: z.enum(['APP_INSTALL', 'APP_RETARGETING', 'MINIS']).optional().describe('REQUIRED when the objective is APP_PROMOTION. MINIS promotes a Series or mini game'),
|
|
19925
|
+
isPromotionalCampaign: z.boolean().optional().describe('promotion campaign settings for a launch, event or time-bound offer. Only for APP_PROMOTION with appPromotionType APP_INSTALL (allowlist-only at TikTok) or WEB_CONVERSIONS with salesDestination WEBSITE'),
|
|
19601
19926
|
},
|
|
19602
19927
|
outputSchema: { campaignId: z.string().optional(), advertiserId: z.string().optional(), summary: z.string().optional() },
|
|
19603
19928
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
@@ -19625,6 +19950,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19625
19950
|
roasBid: z.number().optional().describe('target ROAS (0.01-1000), required when deepBidType is VO_MIN_ROAS'),
|
|
19626
19951
|
pixelId: z.string().optional().describe('required for WEB_CONVERSIONS / LEAD_GENERATION when optimizationGoal is CONVERT or VALUE'),
|
|
19627
19952
|
optimizationEvent: z.string().optional().describe('the conversion event, required whenever pixelId is set (e.g. SHOPPING, ON_WEB_ORDER)'),
|
|
19953
|
+
appId: z.string().optional().describe('the app to promote, from list_tiktok_ads_apps; required for APP_INSTALL / APP_RETARGETING and for WEB_CONVERSIONS with salesDestination APP'),
|
|
19954
|
+
catalogId: z.string().optional().describe('required when the campaign has catalogEnabled; from list_tiktok_ads_catalogs'),
|
|
19955
|
+
catalogAuthorizedBcId: z.string().optional().describe('the Business Center that owns catalogId; required with it'),
|
|
19956
|
+
placementType: z.enum(['PLACEMENT_TYPE_AUTOMATIC', 'PLACEMENT_TYPE_NORMAL']).optional().describe('omit to let TikTok choose; some setups (a CLICK goal, for one) only take PLACEMENT_TYPE_NORMAL with placements'),
|
|
19957
|
+
placements: z.array(z.enum(['PLACEMENT_TIKTOK', 'PLACEMENT_PANGLE', 'PLACEMENT_GLOBAL_APP_BUNDLE'])).optional().describe('required with PLACEMENT_TYPE_NORMAL, e.g. ["PLACEMENT_TIKTOK"]; cannot be changed after creation'),
|
|
19958
|
+
productSource: z.enum(['CATALOG']).optional().describe('required for APP_PROMOTION catalog campaigns (ONLINE_TO_OFFLINE or TRAVEL_ENTERTAINMENT); CATALOG is TikTok\u2019s only value'),
|
|
19628
19959
|
},
|
|
19629
19960
|
outputSchema: { adGroupId: z.string().optional(), summary: z.string().optional() },
|
|
19630
19961
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
@@ -19642,6 +19973,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19642
19973
|
callToActions: z.array(z.string()).optional().describe('up to 3 TikTok call-to-action enums, e.g. LEARN_MORE, SHOP_NOW'),
|
|
19643
19974
|
adConfiguration: z.record(z.any()).optional().describe('TikTok ad_configuration for Spark or catalog ads (identity_type, identity_id, product_set_id, …)'),
|
|
19644
19975
|
playableUrls: z.array(z.string()).optional().describe('up to 5 playableUrl values from upload_tiktok_ads_playable. App Promotion ads with a SINGLE_VIDEO creative only; TikTok allowlists Playable Ads per advertiser'),
|
|
19976
|
+
interactiveAddOnCardId: z.string().optional().describe('ONE interactive add-on card id: a Display, Hotel, Flight or Destination Card, or a Countdown or Gift Code Sticker'),
|
|
19977
|
+
disclaimerText: z.string().optional().describe('a text disclaimer shown on the ad, up to 90 characters'),
|
|
19978
|
+
disclaimerLinks: z.array(z.object({ text: z.string(), url: z.string() })).optional().describe('OR up to 3 clickable disclaimers; their texts may total 40 characters'),
|
|
19645
19979
|
advertiserId: z.string().optional().describe('TikTok ad account id from list_tiktok_ads_accounts; omit when only one account is shared with this profile'),
|
|
19646
19980
|
},
|
|
19647
19981
|
outputSchema: { adIds: z.array(z.string()).optional(), summary: z.string().optional() },
|
|
@@ -19649,13 +19983,32 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19649
19983
|
}, wrap(async (a) => { const d = await apiPost('/api/tiktok-ads/smart/ad', a); return ok(d.summary, d); }));
|
|
19650
19984
|
|
|
19651
19985
|
server.registerTool('list_tiktok_smart_campaigns', {
|
|
19652
|
-
title: 'List TikTok Smart+ campaigns',
|
|
19653
|
-
description: 'Read the Smart+ campaigns on a TikTok advertiser. TikTok’s reads LAG its writes, so
|
|
19654
|
-
inputSchema: { advertiserId: z.string().optional().describe('TikTok ad account id from list_tiktok_ads_accounts; omit when only one account is shared with this profile'), limit: z.number().optional().describe('page size,
|
|
19986
|
+
title: 'List TikTok Smart+ campaigns or ad groups',
|
|
19987
|
+
description: 'Read the Smart+ campaigns on a TikTok advertiser, or with level "adgroup" its Smart+ ad groups, filtered by campaignIds, adGroupIds, name, statuses, and modifiedAfter / createdAfter / createdBefore (UTC). TikTok’s reads LAG its writes, so something created seconds ago can be missing here and still exist — the reply says so rather than reporting an empty account.',
|
|
19988
|
+
inputSchema: { advertiserId: z.string().optional().describe('TikTok ad account id from list_tiktok_ads_accounts; omit when only one account is shared with this profile'), level: z.enum(['campaign', 'adgroup']).optional().describe('campaign (default) or adgroup'), limit: z.number().optional().describe('page size, default 20 (campaigns up to 1000, ad groups up to 100)'), page: z.number().optional().describe('page number, from 1; the reply says when there are more'),
|
|
19989
|
+
campaignIds: z.array(z.string()).optional().describe('adgroup level: only ad groups in these campaigns (up to 100)'), adGroupIds: z.array(z.string()).optional().describe('adgroup level: these ad groups (up to 100)'), name: z.string().optional().describe('adgroup level: ad group name'),
|
|
19990
|
+
statuses: z.array(z.enum(['UNDER_REVIEW', 'NOT_DELIVERED', 'SCHEDULED', 'PARTIAL_DELIVERY', 'ACTIVE', 'ENDED', 'PAUSED', 'DELETED'])).optional().describe('adgroup level: delivery statuses'),
|
|
19991
|
+
modifiedAfter: z.string().optional().describe('adgroup level: changed after this UTC time, "YYYY-MM-DD HH:MM:SS"'), createdAfter: z.string().optional().describe('adgroup level: created after this UTC time'), createdBefore: z.string().optional().describe('adgroup level: created before this UTC time') },
|
|
19655
19992
|
outputSchema: { count: z.number().optional(), summary: z.string().optional() },
|
|
19656
19993
|
annotations: { destructiveHint: false, readOnlyHint: true, openWorldHint: true },
|
|
19657
19994
|
}, wrap(async (a) => { const d = await apiGet('/api/tiktok-ads/smart/campaigns', a); return ok(d.summary, d); }));
|
|
19658
19995
|
|
|
19996
|
+
server.registerTool('manage_tiktok_ads_negative_keywords', {
|
|
19997
|
+
title: 'TikTok negative keywords (ad account, campaign or ad group)',
|
|
19998
|
+
description: 'Search terms a TikTok ad must NOT show for. level "advertiser" (default) is the whole ad account, or "campaign" / "adgroup" with objectId. action "list" reads them; "add" takes keywords (words, or {text, match} with match exact, phrase or broad; broad by default; up to 1,000, 80 characters each); "update" changes one (keywordId + keyword, fromMatch when the word has several match types); "remove" deletes keywordIds (match narrows it to one match type). A negative is a word plus a match type, and the keyword id names the word, so one id can carry several match types. Adding only narrows who sees the ads and runs on the ask; update, remove and add with replace:true let ads show on searches they were kept off, so they need confirm:true after the user agrees. Every change is read back from TikTok. Free.',
|
|
19999
|
+
inputSchema: {
|
|
20000
|
+
action: z.enum(['list', 'add', 'update', 'remove']).optional().describe('default list'), level: z.enum(['advertiser', 'campaign', 'adgroup']).optional().describe('advertiser (the whole ad account, default), campaign or adgroup'),
|
|
20001
|
+
objectId: z.string().optional().describe('campaign or ad group id; omit at advertiser level'), objectIds: z.array(z.string()).optional().describe('add only: several campaigns or ad groups at once, up to 50'),
|
|
20002
|
+
keywords: z.array(z.union([z.string(), z.object({ text: z.string(), match: z.enum(['exact', 'phrase', 'broad']).optional() })])).optional().describe('add: the negative keywords'),
|
|
20003
|
+
replace: z.boolean().optional().describe('add: true REPLACES every existing negative on the object with these (needs confirm:true)'),
|
|
20004
|
+
keywordId: z.string().optional().describe('update: the keyword id from action list'), fromMatch: z.enum(['exact', 'phrase', 'broad']).optional().describe('update: which match type of keywordId to change, when the word has several'), keyword: z.object({ text: z.string(), match: z.enum(['exact', 'phrase', 'broad']).optional() }).optional().describe('update: the new text and match type'),
|
|
20005
|
+
keywordIds: z.array(z.string()).optional().describe('remove: keyword ids from action list'), match: z.enum(['exact', 'phrase', 'broad']).optional().describe('remove: only this match type of those words'), confirm: z.boolean().optional().describe('true after the user agreed to an update, remove or replace'),
|
|
20006
|
+
advertiserId: z.string().optional().describe('TikTok ad account id from list_tiktok_ads_accounts; omit when only one account is shared with this profile'),
|
|
20007
|
+
},
|
|
20008
|
+
outputSchema: { count: z.number().optional(), keywords: z.array(z.any()).optional(), verified: z.boolean().optional(), summary: z.string().optional() },
|
|
20009
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
20010
|
+
}, wrap(async (a) => { const d = await apiPost('/api/tiktok-ads/negative-keywords', a); return ok(d.summary, d); }));
|
|
20011
|
+
|
|
19659
20012
|
server.registerTool('tiktok_bid_protection', {
|
|
19660
20013
|
title: 'TikTok bid protection status and credit history',
|
|
19661
20014
|
description: "Whether TikTok's BID PROTECTION is covering a Smart+ campaign or ad group, and what it actually paid back. Bid protection compensates in AD CREDITS when delivery misses the bid, so this answers two different questions from one tool: with no dates it returns today's coverage STATUS per id; with startDate and endDate it returns the day-by-day HISTORY, each row carrying the credit amount and currency. Smart+ objects ONLY — a classic TikTok campaign id does not resolve here. TikTok keeps 60 days, and the two endpoints size differently: status takes up to 200 ids, history allows 200 DIVIDED BY the day span (a 10-day window takes 20 ids), which is refused here with the arithmetic done rather than by TikTok with it left to you. An id TikTok says nothing about is NAMED in the reply, because a short list otherwise reads as 'those are unprotected'. Read-only, 0 credits.",
|
|
@@ -19670,6 +20023,44 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19670
20023
|
annotations: { destructiveHint: false, readOnlyHint: true, openWorldHint: true },
|
|
19671
20024
|
}, wrap(async (a) => { const d = await apiGet('/api/tiktok-ads/smart/bid-protection', a); return ok(d.summary, d); }));
|
|
19672
20025
|
|
|
20026
|
+
server.registerTool('preview_tiktok_smart_ad', {
|
|
20027
|
+
title: 'Preview a TikTok Smart+ ad',
|
|
20028
|
+
description: 'See a Smart+ ad as it renders on TikTok, before or after it exists. Pass smartPlusAdId for an existing Smart+ ad, OR the fields create_tiktok_smart_ad takes to preview one you have not created yet: creatives (each with identity_type and identity_id from list_tiktok_ads_identities, which TikTok requires here), adTexts, callToActions, adConfiguration (the catalog_tag_list of an inventory card goes inside its product info object), interactiveAddOnCardId, and disclaimerText or disclaimerLinks. For a catalog ad add catalogEnabled, catalogId and catalogAuthorizedBcId. Up to 31 creatives. Returns a preview link TikTok keeps for 30 days. Creates nothing and spends nothing.',
|
|
20029
|
+
inputSchema: {
|
|
20030
|
+
smartPlusAdId: z.string().optional().describe('preview an existing Smart+ ad; omit to preview a planned one'),
|
|
20031
|
+
creatives: z.array(z.record(z.any())).optional().describe('up to 31 TikTok creative_info objects: {ad_format, video_info: {video_id}, image_info: [{web_uri}], tiktok_item_id, identity_type, identity_id, music_info}'),
|
|
20032
|
+
adTexts: z.array(z.string()).optional().describe('up to 5 ad texts'),
|
|
20033
|
+
callToActions: z.array(z.string()).optional().describe('up to 3 TikTok call-to-action enums; the preview shows the first'),
|
|
20034
|
+
adConfiguration: z.record(z.any()).optional().describe('TikTok ad_configuration for Spark or catalog ads'),
|
|
20035
|
+
interactiveAddOnCardId: z.string().optional().describe('ONE interactive add-on card id: a Display Card, Countdown or Gift Code Sticker, or an Inventory Card'),
|
|
20036
|
+
disclaimerText: z.string().optional().describe('a text disclaimer, up to 90 characters'),
|
|
20037
|
+
disclaimerLinks: z.array(z.object({ text: z.string(), url: z.string() })).optional().describe('OR up to 3 clickable disclaimers; their texts may total 40 characters'),
|
|
20038
|
+
catalogEnabled: z.boolean().optional().describe('preview a catalog ad'),
|
|
20039
|
+
catalogId: z.string().optional().describe('required with catalogEnabled; from list_tiktok_ads_catalogs'),
|
|
20040
|
+
catalogAuthorizedBcId: z.string().optional().describe('the Business Center that owns catalogId'),
|
|
20041
|
+
advertiserId: z.string().optional().describe('TikTok ad account id from list_tiktok_ads_accounts; omit when only one account is shared with this profile'),
|
|
20042
|
+
},
|
|
20043
|
+
outputSchema: { previewType: z.string().optional(), previewLink: z.string().optional(), validFor: z.string().optional(), summary: z.string().optional() },
|
|
20044
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
20045
|
+
}, wrap(async (a) => { const d = await apiPost('/api/tiktok-ads/smart/preview', a); return ok(d.summary, d); }));
|
|
20046
|
+
|
|
20047
|
+
server.registerTool('prereview_tiktok_ads_creative', {
|
|
20048
|
+
title: 'Pre-review TikTok ad material',
|
|
20049
|
+
description: 'Ask TikTok whether ad material would pass its ad review BEFORE you build the ad, so a rejection costs nothing. Submit up to 5 materials (videoIds and imageIds from upload_tiktok_ads_creative, adTexts, landingPageUrls) for ONE country the ad will run in. TikTok answers per material: APPROVED, REJECTED (with its reason and suggested fix), UNSURE or UNAVAILABLE. Submitting returns a taskId plus any verdicts already in, since short texts are often judged within seconds; a task usually finishes in about 5 minutes, so call again with taskId alone to read the rest. A material with no verdict yet is NOT approved. Results last 180 days and apply to the country reviewed only. Creates no ad and spends nothing.',
|
|
20050
|
+
inputSchema: {
|
|
20051
|
+
taskId: z.string().optional().describe('read the verdicts of an earlier submission; omit to submit'),
|
|
20052
|
+
videoIds: z.array(z.string()).optional().describe('videoId values from upload_tiktok_ads_creative'),
|
|
20053
|
+
imageIds: z.array(z.string()).optional().describe('imageId values from upload_tiktok_ads_creative'),
|
|
20054
|
+
adTexts: z.array(z.string()).optional().describe('ad copy to check'),
|
|
20055
|
+
landingPageUrls: z.array(z.string()).optional().describe('full http(s) landing page URLs to check'),
|
|
20056
|
+
location: z.string().optional().describe('ONE 2-letter country code the ad will run in, e.g. US; required to submit'),
|
|
20057
|
+
isEcommerce: z.boolean().optional().describe('true when the material is for a catalog or TikTok Shop ad'),
|
|
20058
|
+
advertiserId: z.string().optional().describe('TikTok ad account id from list_tiktok_ads_accounts; omit when only one account is shared with this profile'),
|
|
20059
|
+
},
|
|
20060
|
+
outputSchema: { taskId: z.string().optional(), taskStatus: z.string().nullable().optional(), results: z.array(z.any()).optional(), summary: z.string().optional() },
|
|
20061
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20062
|
+
}, wrap(async (a) => { const d = a.taskId ? await apiGet('/api/tiktok-ads/prereview', a) : await apiPost('/api/tiktok-ads/prereview', a); return ok(d.summary, d); }));
|
|
20063
|
+
|
|
19673
20064
|
server.registerTool('set_tiktok_smart_status', {
|
|
19674
20065
|
title: 'Enable, pause or delete a Smart+ object',
|
|
19675
20066
|
description: 'Enable, pause or delete Smart+ campaigns, ad groups or ads (up to 20 ids per call). ENABLE starts REAL SPEND on the next auction and DELETE is permanent (a deleted campaign takes its ad groups and ads with it), so both require confirm:true after the user says yes. DISABLE (pause) is never gated. The reply is read back from TikTok and names any id whose new state it could not confirm.',
|
|
@@ -20947,6 +21338,282 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20947
21338
|
const d = await apiPost('/api/tiktok-ads/rule-bind', a);
|
|
20948
21339
|
return ok(d.note, d);
|
|
20949
21340
|
}));
|
|
21341
|
+
// ══ AUTOMATED AD RULES — META + GOOGLE ADS (2026-11-13) ═══════════════════════════════════════════════════════════
|
|
21342
|
+
// Meta runs its rules natively (the Ad Rules Library); Google's API has no rules resource, so Hermoso runs those on its
|
|
21343
|
+
// job queue. One spend-safety design for both (lib/ad-rules.mjs): every rule is born OFF, switching on a rule that can
|
|
21344
|
+
// raise spend takes confirm:true plus the scope token read off the stored rule, and per-account caps bound every raise.
|
|
21345
|
+
server.registerTool('create_meta_ad_rule', {
|
|
21346
|
+
title: "Create a Meta automated rule (created switched OFF)",
|
|
21347
|
+
description: "AUTOMATED RULES ON META — a standing instruction META runs on the user's ad account on its own schedule, with nobody watching: \"pause any ad set whose cost per result is over $X today\", \"raise the budget 20% when ROAS is above 3, at most once a day, never above $200\". Meta runs these rules itself (Ads Manager ▸ Automated rules shows them too). EVERY RULE IS CREATED SWITCHED OFF and read back from Meta to prove it; switching it on is a separate act (set_meta_ad_rule_status). Actions: pause, enable, notify, or change a budget/bid by a percent or amount. A rule that RAISES needs a limit (the ceiling), and the reply says plainly what it will be able to do unattended. Money values are in the account currency's BASE UNIT (for USD 1000 = $10.00), as Meta defines them. Conditions take any Meta rule filter field; for anything the plain fields cannot say, pass Meta's raw evaluationSpec / executionSpec / scheduleSpec. Free.",
|
|
21348
|
+
inputSchema: {
|
|
21349
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21350
|
+
name: z.string().describe("the rule's name in Ads Manager"),
|
|
21351
|
+
scope: z.object({ level: z.enum(["campaign", "adset", "ad"]).optional().describe("campaign, adset or ad: every object at that level, INCLUDING ones created later (Meta re-resolves it each run)"), ids: z.array(z.string()).optional().describe("a FIXED list of campaign, ad set or ad ids instead of a level"), campaignIds: z.array(z.string()).optional().describe("only objects under these campaigns"), adsetIds: z.array(z.string()).optional().describe("only ads under these ad sets"), nameContains: z.string().optional().describe("only objects whose name contains this"), statuses: z.array(z.string()).optional().describe("effective_status values, e.g. [\"ACTIVE\"]") }).optional().describe("what the rule looks at: level or ids (Meta requires one)"),
|
|
21352
|
+
conditions: z.array(z.object({ field: z.string().optional().describe("any Meta rule filter field, as Meta spells it: spent, cost_per, cpa, cpc, cpm, ctr, results, impressions, frequency, website_purchase_roas, mobile_app_purchase_roas, cost_per_purchase_fb, cost_per_lead_fb, today_spent, yesterday_spent, lifetime_spent, daily_budget, hours_since_creation …"), operator: z.string().optional().describe("GREATER_THAN, LESS_THAN, EQUAL, NOT_EQUAL, IN_RANGE, NOT_IN_RANGE, IN, NOT_IN, CONTAIN, NOT_CONTAIN"), value: z.any().optional().describe("a number, or [low, high] for IN_RANGE. MONEY IS IN THE CURRENCY BASE UNIT: for USD 1000 = $10.00") })).optional().describe("when it acts (all must hold)"),
|
|
21353
|
+
window: z.string().optional().describe("time window for the metrics: TODAY, YESTERDAY, LAST_3_DAYS, LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, LIFETIME, THIS_MONTH …"),
|
|
21354
|
+
attributionWindow: z.string().optional().describe("Meta attribution_window value, optional"),
|
|
21355
|
+
action: z.object({ type: z.enum(["pause", "enable", "notify", "budget", "bid"]).optional().describe("pause, enable (switch back on), notify, budget or bid"), change: z.enum(["increase", "decrease"]).optional().describe("increase or decrease, for budget/bid. Meta has no \"set to\""), by: z.object({ percent: z.number().optional(), amount: z.number().optional().describe("in the currency base unit (cents for USD)") }).optional().describe("exactly one of percent or amount"), limit: z.any().optional().describe("REQUIRED for an increase: the ceiling (base unit, 5000 = $50.00). A floor for a decrease. [low, high] with targetField"), targetField: z.string().optional().describe("scale the budget/bid toward a target metric instead (Meta's target_field), e.g. cost_per_result"), target: z.number().optional().describe("the target value for targetField"), minMinutesBetween: z.number().optional().describe("Meta's action_frequency: minutes before the same object is changed again; default 1440 (once a day)"), maxTimes: z.number().optional().describe("the most times this action may run in all (execution_count_limit)"), level: z.string().optional().describe("campaign for a campaign budget (CBO); otherwise the ad set budget"), userIds: z.array(z.string()).optional().describe("Meta user ids to notify; default is the connection's user") }).optional().describe("what it does"),
|
|
21356
|
+
schedule: z.object({ type: z.enum(["DAILY", "HOURLY", "SEMI_HOURLY", "CUSTOM"]).optional().describe("DAILY, HOURLY, SEMI_HOURLY or CUSTOM"), times: z.array(z.object({ startMinute: z.number().optional().describe("minutes after midnight, account time, multiple of 30"), endMinute: z.number().optional(), days: z.array(z.number()).optional().describe("0 = Sunday … 6 = Saturday") })).optional().describe("for CUSTOM") }).optional().describe("how often Meta checks it (schedule rules)"),
|
|
21357
|
+
trigger: z.object({ type: z.string().optional().describe("METADATA_CREATION, METADATA_UPDATE, STATS_CHANGE, STATS_MILESTONE or DELIVERY_INSIGHTS_CHANGE"), field: z.string().optional(), value: z.any().optional(), operator: z.string().optional() }).optional().describe("makes it a TRIGGER rule Meta checks in real time (no schedule; pause/enable/notify only)"),
|
|
21358
|
+
evaluationSpec: z.record(z.any()).optional().describe("advanced: Meta's raw evaluation_spec, passed through as given (overrides the plain fields)"),
|
|
21359
|
+
executionSpec: z.record(z.any()).optional().describe("advanced: Meta's raw execution_spec, passed through as given (overrides the plain fields)"),
|
|
21360
|
+
scheduleSpec: z.record(z.any()).optional().describe("advanced: Meta's raw schedule_spec, passed through as given (overrides the plain fields)"),
|
|
21361
|
+
},
|
|
21362
|
+
annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true},
|
|
21363
|
+
}, wrap(async (a) => {
|
|
21364
|
+
const d = await apiPost('/api/meta/rules', a);
|
|
21365
|
+
return ok(adRuleText(d), d);
|
|
21366
|
+
}));
|
|
21367
|
+
server.registerTool('list_meta_ad_rules', {
|
|
21368
|
+
title: "List Meta automated rules, and which can raise spend",
|
|
21369
|
+
description: "The automated rules on a Meta ad account — including ones made in Ads Manager — each with what it does in one sentence, whether it is ON, whether it CAN RAISE SPEND (canSpend, armingActions), whether its reach grows, and its scopeToken (what set_meta_ad_rule_status wants echoed back to switch on a rule that can raise spend). Also returns the ad account's caps on rules. Pass ruleId for one rule. Read-only. Free.",
|
|
21370
|
+
inputSchema: {
|
|
21371
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21372
|
+
ruleId: z.string().optional().describe("rule id from list_meta_ad_rules"),
|
|
21373
|
+
},
|
|
21374
|
+
annotations: {"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true},
|
|
21375
|
+
}, wrap(async (a) => {
|
|
21376
|
+
const d = await apiGet('/api/meta/rules', a);
|
|
21377
|
+
return ok(adRuleText(d), d);
|
|
21378
|
+
}));
|
|
21379
|
+
server.registerTool('update_meta_ad_rule', {
|
|
21380
|
+
title: "Change a Meta automated rule",
|
|
21381
|
+
description: "Change a rule's name, what it looks at (send scope, conditions and window TOGETHER — Meta replaces the whole evaluation), its action and/or its schedule. Never changes whether it is on (set_meta_ad_rule_status does). IF THE RULE IS ON and the new version can raise spend, this takes confirm:true plus confirmScope for the NEW version and is checked against the account caps: an edit is never a way round the switch-on gate. The answer is Meta's stored rule, read back. Free.",
|
|
21382
|
+
inputSchema: {
|
|
21383
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21384
|
+
ruleId: z.string().describe("rule id from list_meta_ad_rules"),
|
|
21385
|
+
name: z.string().optional(),
|
|
21386
|
+
scope: z.object({ level: z.enum(["campaign", "adset", "ad"]).optional().describe("campaign, adset or ad: every object at that level, INCLUDING ones created later (Meta re-resolves it each run)"), ids: z.array(z.string()).optional().describe("a FIXED list of campaign, ad set or ad ids instead of a level"), campaignIds: z.array(z.string()).optional().describe("only objects under these campaigns"), adsetIds: z.array(z.string()).optional().describe("only ads under these ad sets"), nameContains: z.string().optional().describe("only objects whose name contains this"), statuses: z.array(z.string()).optional().describe("effective_status values, e.g. [\"ACTIVE\"]") }).optional().describe("what the rule looks at: level or ids (Meta requires one)"),
|
|
21387
|
+
conditions: z.array(z.object({ field: z.string().optional().describe("any Meta rule filter field, as Meta spells it: spent, cost_per, cpa, cpc, cpm, ctr, results, impressions, frequency, website_purchase_roas, mobile_app_purchase_roas, cost_per_purchase_fb, cost_per_lead_fb, today_spent, yesterday_spent, lifetime_spent, daily_budget, hours_since_creation …"), operator: z.string().optional().describe("GREATER_THAN, LESS_THAN, EQUAL, NOT_EQUAL, IN_RANGE, NOT_IN_RANGE, IN, NOT_IN, CONTAIN, NOT_CONTAIN"), value: z.any().optional().describe("a number, or [low, high] for IN_RANGE. MONEY IS IN THE CURRENCY BASE UNIT: for USD 1000 = $10.00") })).optional().describe("when it acts (all must hold)"),
|
|
21388
|
+
window: z.string().optional().describe("time window for the metrics: TODAY, YESTERDAY, LAST_3_DAYS, LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, LIFETIME, THIS_MONTH …"),
|
|
21389
|
+
attributionWindow: z.string().optional(),
|
|
21390
|
+
action: z.object({ type: z.enum(["pause", "enable", "notify", "budget", "bid"]).optional().describe("pause, enable (switch back on), notify, budget or bid"), change: z.enum(["increase", "decrease"]).optional().describe("increase or decrease, for budget/bid. Meta has no \"set to\""), by: z.object({ percent: z.number().optional(), amount: z.number().optional().describe("in the currency base unit (cents for USD)") }).optional().describe("exactly one of percent or amount"), limit: z.any().optional().describe("REQUIRED for an increase: the ceiling (base unit, 5000 = $50.00). A floor for a decrease. [low, high] with targetField"), targetField: z.string().optional().describe("scale the budget/bid toward a target metric instead (Meta's target_field), e.g. cost_per_result"), target: z.number().optional().describe("the target value for targetField"), minMinutesBetween: z.number().optional().describe("Meta's action_frequency: minutes before the same object is changed again; default 1440 (once a day)"), maxTimes: z.number().optional().describe("the most times this action may run in all (execution_count_limit)"), level: z.string().optional().describe("campaign for a campaign budget (CBO); otherwise the ad set budget"), userIds: z.array(z.string()).optional().describe("Meta user ids to notify; default is the connection's user") }).optional().describe("what it does"),
|
|
21391
|
+
schedule: z.object({ type: z.enum(["DAILY", "HOURLY", "SEMI_HOURLY", "CUSTOM"]).optional().describe("DAILY, HOURLY, SEMI_HOURLY or CUSTOM"), times: z.array(z.object({ startMinute: z.number().optional().describe("minutes after midnight, account time, multiple of 30"), endMinute: z.number().optional(), days: z.array(z.number()).optional().describe("0 = Sunday … 6 = Saturday") })).optional().describe("for CUSTOM") }).optional().describe("how often Meta checks it (schedule rules)"),
|
|
21392
|
+
trigger: z.object({ type: z.string().optional().describe("METADATA_CREATION, METADATA_UPDATE, STATS_CHANGE, STATS_MILESTONE or DELIVERY_INSIGHTS_CHANGE"), field: z.string().optional(), value: z.any().optional(), operator: z.string().optional() }).optional().describe("makes it a TRIGGER rule Meta checks in real time (no schedule; pause/enable/notify only)"),
|
|
21393
|
+
evaluationSpec: z.record(z.any()).optional().describe("advanced: Meta's raw evaluation_spec, passed through as given (overrides the plain fields)"),
|
|
21394
|
+
executionSpec: z.record(z.any()).optional().describe("advanced: Meta's raw execution_spec, passed through as given (overrides the plain fields)"),
|
|
21395
|
+
scheduleSpec: z.record(z.any()).optional().describe("advanced: Meta's raw schedule_spec, passed through as given (overrides the plain fields)"),
|
|
21396
|
+
confirm: z.boolean().optional().describe("true once the user has said yes to exactly what the refusal described"),
|
|
21397
|
+
confirmScope: z.string().optional().describe("the scopeToken the list tool (or the refusal) prints; required with confirm when the rule can raise spend"),
|
|
21398
|
+
},
|
|
21399
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true},
|
|
21400
|
+
}, wrap(async (a) => {
|
|
21401
|
+
const d = await apiPost('/api/meta/rules/update', a);
|
|
21402
|
+
return ok(adRuleText(d), d);
|
|
21403
|
+
}));
|
|
21404
|
+
server.registerTool('set_meta_ad_rule_status', {
|
|
21405
|
+
title: "Switch a Meta automated rule on or off",
|
|
21406
|
+
description: "ENABLED hands the rule to Meta to run unattended. A rule that can only pause, lower or notify needs confirm:true. A rule that can switch something ON or RAISE a budget/bid needs confirm:true AND confirmScope (the scopeToken from list_meta_ad_rules, computed from the rule as Meta stores it), and is refused if its worst case breaks the ad account's caps (set_meta_ad_rule_limits). Calling without them changes nothing and returns exactly what the rule would do — show the user, get a yes, then confirm. DISABLED is the brake: never gated. The answer is the status read back from Meta. Free.",
|
|
21407
|
+
inputSchema: {
|
|
21408
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21409
|
+
ruleId: z.string().describe("rule id from list_meta_ad_rules"),
|
|
21410
|
+
status: z.enum(["ENABLED", "DISABLED"]).describe("ENABLED or DISABLED"),
|
|
21411
|
+
confirm: z.boolean().optional().describe("true once the user has said yes to exactly what the refusal described"),
|
|
21412
|
+
confirmScope: z.string().optional().describe("the scopeToken the list tool (or the refusal) prints; required with confirm when the rule can raise spend"),
|
|
21413
|
+
},
|
|
21414
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true},
|
|
21415
|
+
}, wrap(async (a) => {
|
|
21416
|
+
const d = await apiPost('/api/meta/rules/status', a);
|
|
21417
|
+
return ok(adRuleText(d), d);
|
|
21418
|
+
}));
|
|
21419
|
+
server.registerTool('delete_meta_ad_rule', {
|
|
21420
|
+
title: "Delete a Meta automated rule",
|
|
21421
|
+
description: "Permanently delete a rule (Meta publishes no restore; switching it off is the reversible choice). Needs confirm:true. Read back from Meta to prove it is gone. Free.",
|
|
21422
|
+
inputSchema: {
|
|
21423
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21424
|
+
ruleId: z.string().describe("rule id from list_meta_ad_rules"),
|
|
21425
|
+
confirm: z.boolean().optional().describe("required: deleting cannot be undone"),
|
|
21426
|
+
},
|
|
21427
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true},
|
|
21428
|
+
}, wrap(async (a) => {
|
|
21429
|
+
const d = await apiPost('/api/meta/rules/delete', a);
|
|
21430
|
+
return ok(adRuleText(d), d);
|
|
21431
|
+
}));
|
|
21432
|
+
server.registerTool('run_meta_ad_rule', {
|
|
21433
|
+
title: "Dry-run (or run now) a Meta automated rule",
|
|
21434
|
+
description: "DRY RUN BY DEFAULT: Meta's own preview of which campaigns / ad sets / ads pass the rule right now, with names and budgets — nothing changes. Use it before switching a rule on, and to sanity-check money units. dryRun:false asks Meta to RUN it now (Meta runs a switched-off rule by hand too), so it takes the same gate as switching on; the run is asynchronous, read it with meta_ad_rule_history. Schedule rules only. Free.",
|
|
21435
|
+
inputSchema: {
|
|
21436
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21437
|
+
ruleId: z.string().describe("rule id from list_meta_ad_rules"),
|
|
21438
|
+
dryRun: z.boolean().optional().describe("default true; false runs it for real"),
|
|
21439
|
+
confirm: z.boolean().optional().describe("true once the user has said yes to exactly what the refusal described"),
|
|
21440
|
+
confirmScope: z.string().optional().describe("the scopeToken the list tool (or the refusal) prints; required with confirm when the rule can raise spend"),
|
|
21441
|
+
},
|
|
21442
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": true},
|
|
21443
|
+
}, wrap(async (a) => {
|
|
21444
|
+
const d = await apiPost('/api/meta/rules/run', a);
|
|
21445
|
+
return ok(adRuleText(d), d);
|
|
21446
|
+
}));
|
|
21447
|
+
server.registerTool('meta_ad_rule_history', {
|
|
21448
|
+
title: "What Meta's automated rules did",
|
|
21449
|
+
description: "Every run Meta recorded — for one rule (ruleId) or the whole ad account — with what each changed and the rule as it was at that run. Filter by objectId, action (CHANGED_BUDGET, PAUSED, UNPAUSED, CHANGED_BID …) or hideNoChanges. Read-only. Free.",
|
|
21450
|
+
inputSchema: {
|
|
21451
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21452
|
+
ruleId: z.string().optional().describe("rule id from list_meta_ad_rules"),
|
|
21453
|
+
objectId: z.string().optional().describe("only runs that touched this object"),
|
|
21454
|
+
action: z.string().optional().describe("CHANGED_BUDGET, CHANGED_BID, PAUSED, UNPAUSED, NOT_CHANGED, ERROR, EMAIL …"),
|
|
21455
|
+
hideNoChanges: z.boolean().optional(),
|
|
21456
|
+
limit: z.number().optional().describe("1-100, default 25"),
|
|
21457
|
+
after: z.string().optional().describe("paging cursor"),
|
|
21458
|
+
},
|
|
21459
|
+
annotations: {"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true},
|
|
21460
|
+
}, wrap(async (a) => {
|
|
21461
|
+
const d = await apiGet('/api/meta/rules/history', a);
|
|
21462
|
+
return ok(adRuleText(d), d);
|
|
21463
|
+
}));
|
|
21464
|
+
server.registerTool('set_meta_ad_rule_limits', {
|
|
21465
|
+
title: "Caps on what Meta rules may raise",
|
|
21466
|
+
description: "The ad account's caps on automated rules: the most a rule may raise one budget/bid in a day (default 100%), the most rules may add to budgets in a day, and the highest budget a rule may set. A rule whose worst case breaks them cannot be switched on. Tightening needs nothing; LOOSENING needs confirm:true. The reply names any rule already ON that now exceeds them (Meta keeps running it until switched off). Free.",
|
|
21467
|
+
inputSchema: {
|
|
21468
|
+
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21469
|
+
maxRaisePercentPerDay: z.number().nullable().optional().describe("the most rules may raise ONE budget or bid in a day, percent (default 100); null removes it"),
|
|
21470
|
+
maxRaiseAmountPerDay: z.number().nullable().optional().describe("the most rules may ADD to budgets across the account in a day, account currency; null = no cap"),
|
|
21471
|
+
maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
|
|
21472
|
+
confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
|
|
21473
|
+
},
|
|
21474
|
+
annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true},
|
|
21475
|
+
}, wrap(async (a) => {
|
|
21476
|
+
const d = await apiPost('/api/meta/rules/limits', a);
|
|
21477
|
+
return ok(adRuleText(d), d);
|
|
21478
|
+
}));
|
|
21479
|
+
server.registerTool('create_google_ads_rule', {
|
|
21480
|
+
title: "Create a Google Ads automated rule (created switched OFF)",
|
|
21481
|
+
description: "AUTOMATED RULES ON GOOGLE ADS — \"pause ad groups whose CPA is over $50 over the last 7 days\", \"raise the budget 20% when ROAS > 4, never above $300\", on a schedule with optional quiet hours. Google's API has NO rules resource (its UI rules are not reachable by any API), so HERMOSO runs these: every run reads the metrics, applies the caps against real numbers, writes through Google's own mutates, reads the result back, and logs it (google_ads_rule_history); a live run can be undone (undo_google_ads_rule_action). EVERY RULE IS CREATED SWITCHED OFF; dry-run it with run_google_ads_rule, then switch it on with set_google_ads_rule_status. Conditions take derived metrics or any metrics.<field> Google reports; actions pause / enable / notify / change a budget (campaign) or the manual CPC bid (ad group) by percent, amount or to a value. An increase needs a limit. Free.",
|
|
21482
|
+
inputSchema: {
|
|
21483
|
+
customerId: z.string().optional().describe("Google Ads customer id from list_google_ads_campaigns; omit when only one is shared with this brand"),
|
|
21484
|
+
loginCustomerId: z.string().optional().describe("manager (MCC) id, only when the account is reached through a manager; stored on the rule"),
|
|
21485
|
+
name: z.string(),
|
|
21486
|
+
scope: z.object({ level: z.enum(["campaign", "adGroup", "ad"]).optional().describe("campaign, adGroup or ad. Budgets live on campaigns, the manual CPC bid on ad groups"), ids: z.array(z.string()).optional().describe("a FIXED list of ids at that level; leave out for every object at the level (re-read every run)"), campaignIds: z.array(z.string()).optional().describe("only ad groups / ads under these campaigns"), adGroupIds: z.array(z.string()).optional().describe("only ads under these ad groups"), nameContains: z.string().optional().describe("only objects whose name contains this"), statuses: z.array(z.string()).optional().describe("ENABLED and/or PAUSED; default ENABLED (PAUSED for an enable rule)") }).describe("what the rule looks at"),
|
|
21487
|
+
conditions: z.array(z.object({ metric: z.string().optional().describe("cost, impressions, clicks, conversions, conversions_value, ctr, cpc, cpm, cpa (= cost_per_conversion = cost_per_result), roas, conversion_rate, budget, bid — or ANY Google report metric as metrics.<field>, e.g. metrics.search_impression_share. Money is in the account currency"), operator: z.string().optional().describe(">, <, >=, <=, =, !=, between, not_between (Meta's names work too)"), value: z.any().optional().describe("a number, or [low, high] for between"), window: z.string().optional().describe("TODAY, YESTERDAY, LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH … or \"DAYS:N\"; defaults to the rule's window") })).optional().describe("when it acts. A value that cannot be measured (e.g. cpa with no conversions) never matches"),
|
|
21488
|
+
match: z.enum(["all", "any"]).optional().describe("all (default) or any"),
|
|
21489
|
+
window: z.string().optional().describe("default window for conditions (LAST_7_DAYS)"),
|
|
21490
|
+
unconditional: z.boolean().optional().describe("true to act on everything in scope every run (no conditions); never inferred"),
|
|
21491
|
+
actions: z.array(z.object({ type: z.enum(["pause", "enable", "budget", "bid", "notify"]).optional().describe("pause, enable, budget, bid or notify"), change: z.enum(["increase", "decrease", "set"]).optional().describe("increase, decrease or set"), by: z.object({ percent: z.number().optional(), amount: z.number().optional().describe("in the account currency") }).optional().describe("exactly one, for increase/decrease"), to: z.number().optional().describe("for set: the new value in the account currency"), limit: z.number().optional().describe("REQUIRED for an increase: the highest value it may ever reach. A floor for a decrease"), allowSharedBudget: z.boolean().optional().describe("allow changing a budget shared by several campaigns") })).describe("what it does on each match"),
|
|
21492
|
+
schedule: z.object({ everyMinutes: z.number().optional().describe("15 to 10080; default 60"), days: z.array(z.number()).optional().describe("weekdays 0-6 (0 = Sunday); default every day"), quietHours: z.object({ start: z.string().optional().describe("HH:MM"), end: z.string().optional().describe("HH:MM") }).optional().describe("a window in which it never acts, e.g. 22:00-07:00"), timezone: z.string().optional().describe("IANA name; default the ad account's") }).optional().describe("when Hermoso runs it"),
|
|
21493
|
+
caps: z.object({ maxChangesPerDay: z.number().optional().describe("how often this rule may change the same object in a day, 1-48 (default 1)"), maxRaisePerDay: z.number().optional().describe("the most this rule may add to budgets in a day, account currency") }).optional().describe("this rule's own caps"),
|
|
21494
|
+
},
|
|
21495
|
+
annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true},
|
|
21496
|
+
}, wrap(async (a) => {
|
|
21497
|
+
const d = await apiPost('/api/google-ads/rules', a);
|
|
21498
|
+
return ok(adRuleText(d), d);
|
|
21499
|
+
}));
|
|
21500
|
+
server.registerTool('list_google_ads_rules', {
|
|
21501
|
+
title: "List Google Ads automated rules",
|
|
21502
|
+
description: "The Google Ads rules Hermoso runs for this brand, each with a one-sentence summary, ON/OFF, last run and outcome, next run, whether it CAN RAISE SPEND, and its scopeToken; plus each account's caps. Rules made in the Google Ads web UI are not listed: Google exposes them to no API. Read-only. Free.",
|
|
21503
|
+
inputSchema: {
|
|
21504
|
+
ruleId: z.string().optional().describe("rule id from list_google_ads_rules (gr_…)"),
|
|
21505
|
+
customerId: z.string().optional().describe("only this account's rules"),
|
|
21506
|
+
},
|
|
21507
|
+
annotations: {"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false},
|
|
21508
|
+
}, wrap(async (a) => {
|
|
21509
|
+
const d = await apiGet('/api/google-ads/rules', a);
|
|
21510
|
+
return ok(adRuleText(d), d);
|
|
21511
|
+
}));
|
|
21512
|
+
server.registerTool('update_google_ads_rule', {
|
|
21513
|
+
title: "Change a Google Ads automated rule",
|
|
21514
|
+
description: "Change any part of a rule; anything not passed keeps its value. Never switches it on or off. If the rule is ON and the new version can raise spend, it needs confirm:true plus confirmScope for the NEW version. Free.",
|
|
21515
|
+
inputSchema: {
|
|
21516
|
+
ruleId: z.string().describe("rule id from list_google_ads_rules (gr_…)"),
|
|
21517
|
+
name: z.string().optional(),
|
|
21518
|
+
scope: z.object({ level: z.enum(["campaign", "adGroup", "ad"]).optional().describe("campaign, adGroup or ad. Budgets live on campaigns, the manual CPC bid on ad groups"), ids: z.array(z.string()).optional().describe("a FIXED list of ids at that level; leave out for every object at the level (re-read every run)"), campaignIds: z.array(z.string()).optional().describe("only ad groups / ads under these campaigns"), adGroupIds: z.array(z.string()).optional().describe("only ads under these ad groups"), nameContains: z.string().optional().describe("only objects whose name contains this"), statuses: z.array(z.string()).optional().describe("ENABLED and/or PAUSED; default ENABLED (PAUSED for an enable rule)") }).optional().describe("what the rule looks at"),
|
|
21519
|
+
conditions: z.array(z.object({ metric: z.string().optional().describe("cost, impressions, clicks, conversions, conversions_value, ctr, cpc, cpm, cpa (= cost_per_conversion = cost_per_result), roas, conversion_rate, budget, bid — or ANY Google report metric as metrics.<field>, e.g. metrics.search_impression_share. Money is in the account currency"), operator: z.string().optional().describe(">, <, >=, <=, =, !=, between, not_between (Meta's names work too)"), value: z.any().optional().describe("a number, or [low, high] for between"), window: z.string().optional().describe("TODAY, YESTERDAY, LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH … or \"DAYS:N\"; defaults to the rule's window") })).optional().describe("when it acts. A value that cannot be measured (e.g. cpa with no conversions) never matches"),
|
|
21520
|
+
match: z.enum(["all", "any"]).optional(),
|
|
21521
|
+
window: z.string().optional(),
|
|
21522
|
+
unconditional: z.boolean().optional(),
|
|
21523
|
+
actions: z.array(z.object({ type: z.enum(["pause", "enable", "budget", "bid", "notify"]).optional().describe("pause, enable, budget, bid or notify"), change: z.enum(["increase", "decrease", "set"]).optional().describe("increase, decrease or set"), by: z.object({ percent: z.number().optional(), amount: z.number().optional().describe("in the account currency") }).optional().describe("exactly one, for increase/decrease"), to: z.number().optional().describe("for set: the new value in the account currency"), limit: z.number().optional().describe("REQUIRED for an increase: the highest value it may ever reach. A floor for a decrease"), allowSharedBudget: z.boolean().optional().describe("allow changing a budget shared by several campaigns") })).optional().describe("what it does on each match"),
|
|
21524
|
+
schedule: z.object({ everyMinutes: z.number().optional().describe("15 to 10080; default 60"), days: z.array(z.number()).optional().describe("weekdays 0-6 (0 = Sunday); default every day"), quietHours: z.object({ start: z.string().optional().describe("HH:MM"), end: z.string().optional().describe("HH:MM") }).optional().describe("a window in which it never acts, e.g. 22:00-07:00"), timezone: z.string().optional().describe("IANA name; default the ad account's") }).optional().describe("when Hermoso runs it"),
|
|
21525
|
+
caps: z.object({ maxChangesPerDay: z.number().optional().describe("how often this rule may change the same object in a day, 1-48 (default 1)"), maxRaisePerDay: z.number().optional().describe("the most this rule may add to budgets in a day, account currency") }).optional().describe("this rule's own caps"),
|
|
21526
|
+
confirm: z.boolean().optional().describe("true once the user has said yes to exactly what the refusal described"),
|
|
21527
|
+
confirmScope: z.string().optional().describe("the scopeToken the list tool (or the refusal) prints; required with confirm when the rule can raise spend"),
|
|
21528
|
+
},
|
|
21529
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false},
|
|
21530
|
+
}, wrap(async (a) => {
|
|
21531
|
+
const d = await apiPost('/api/google-ads/rules/update', a);
|
|
21532
|
+
return ok(adRuleText(d), d);
|
|
21533
|
+
}));
|
|
21534
|
+
server.registerTool('set_google_ads_rule_status', {
|
|
21535
|
+
title: "Switch a Google Ads automated rule on or off",
|
|
21536
|
+
description: "ENABLED means Hermoso runs it on its schedule with nobody watching. A rule that can only pause, lower or notify needs confirm:true; one that can enable or RAISE needs confirm:true AND confirmScope (from list_google_ads_rules). Calling without them changes nothing and says what it would do. DISABLED stops it at once and is never gated (it does not put back what it changed: undo_google_ads_rule_action does). Free.",
|
|
21537
|
+
inputSchema: {
|
|
21538
|
+
ruleId: z.string().describe("rule id from list_google_ads_rules (gr_…)"),
|
|
21539
|
+
status: z.enum(["ENABLED", "DISABLED"]).describe("ENABLED or DISABLED"),
|
|
21540
|
+
confirm: z.boolean().optional().describe("true once the user has said yes to exactly what the refusal described"),
|
|
21541
|
+
confirmScope: z.string().optional().describe("the scopeToken the list tool (or the refusal) prints; required with confirm when the rule can raise spend"),
|
|
21542
|
+
},
|
|
21543
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false},
|
|
21544
|
+
}, wrap(async (a) => {
|
|
21545
|
+
const d = await apiPost('/api/google-ads/rules/status', a);
|
|
21546
|
+
return ok(adRuleText(d), d);
|
|
21547
|
+
}));
|
|
21548
|
+
server.registerTool('delete_google_ads_rule', {
|
|
21549
|
+
title: "Delete a Google Ads automated rule",
|
|
21550
|
+
description: "Delete a rule (its run history stays readable). Needs confirm:true. Does not put back anything it changed. Free.",
|
|
21551
|
+
inputSchema: {
|
|
21552
|
+
ruleId: z.string().describe("rule id from list_google_ads_rules (gr_…)"),
|
|
21553
|
+
confirm: z.boolean().optional().describe("required"),
|
|
21554
|
+
},
|
|
21555
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false},
|
|
21556
|
+
}, wrap(async (a) => {
|
|
21557
|
+
const d = await apiPost('/api/google-ads/rules/delete', a);
|
|
21558
|
+
return ok(adRuleText(d), d);
|
|
21559
|
+
}));
|
|
21560
|
+
server.registerTool('run_google_ads_rule', {
|
|
21561
|
+
title: "Dry-run (or run now) a Google Ads automated rule",
|
|
21562
|
+
description: "DRY RUN BY DEFAULT: reads the live metrics and returns, object by object, the values it measured and exactly what it WOULD change, with every cap applied — nothing is written. It is the same plan a live run applies, so it cannot describe something a run would not do. dryRun:false applies it now (same gate as switching on), reads the result back and logs it; undo_google_ads_rule_action reverses it. Free.",
|
|
21563
|
+
inputSchema: {
|
|
21564
|
+
ruleId: z.string().describe("rule id from list_google_ads_rules (gr_…)"),
|
|
21565
|
+
dryRun: z.boolean().optional().describe("default true"),
|
|
21566
|
+
confirm: z.boolean().optional().describe("true once the user has said yes to exactly what the refusal described"),
|
|
21567
|
+
confirmScope: z.string().optional().describe("the scopeToken the list tool (or the refusal) prints; required with confirm when the rule can raise spend"),
|
|
21568
|
+
},
|
|
21569
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": true},
|
|
21570
|
+
}, wrap(async (a) => {
|
|
21571
|
+
const d = await apiPost('/api/google-ads/rules/run', a);
|
|
21572
|
+
return ok(adRuleText(d), d);
|
|
21573
|
+
}));
|
|
21574
|
+
server.registerTool('google_ads_rule_history', {
|
|
21575
|
+
title: "What Google Ads automated rules did",
|
|
21576
|
+
description: "Every run, newest first: when, dry/live/undo, what it measured, what it changed (read back from Google) or skipped and why, what a cap held back. Filter by ruleId, mode, or changesOnly. Read-only. Free.",
|
|
21577
|
+
inputSchema: {
|
|
21578
|
+
ruleId: z.string().optional().describe("rule id from list_google_ads_rules (gr_…)"),
|
|
21579
|
+
mode: z.enum(["live", "dry", "undo"]).optional().describe("live, dry or undo"),
|
|
21580
|
+
changesOnly: z.boolean().optional(),
|
|
21581
|
+
limit: z.number().optional().describe("1-100, default 20"),
|
|
21582
|
+
},
|
|
21583
|
+
annotations: {"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false},
|
|
21584
|
+
}, wrap(async (a) => {
|
|
21585
|
+
const d = await apiGet('/api/google-ads/rules/history', a);
|
|
21586
|
+
return ok(adRuleText(d), d);
|
|
21587
|
+
}));
|
|
21588
|
+
server.registerTool('undo_google_ads_rule_action', {
|
|
21589
|
+
title: "Undo a Google Ads rule run",
|
|
21590
|
+
description: "Put back what one live run changed (the latest by default, or runId). A change is reverted only if the object still holds the value the run gave it; anything changed since is left alone and named. If putting things back would RAISE spend (re-enabling, restoring a higher budget) it needs confirm:true. Does not switch the rule off. Free.",
|
|
21591
|
+
inputSchema: {
|
|
21592
|
+
runId: z.string().optional().describe("from google_ads_rule_history; default the latest live run with changes"),
|
|
21593
|
+
ruleId: z.string().optional().describe("rule id from list_google_ads_rules (gr_…)"),
|
|
21594
|
+
confirm: z.boolean().optional().describe("true once the user has said yes to exactly what the refusal described"),
|
|
21595
|
+
},
|
|
21596
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": true},
|
|
21597
|
+
}, wrap(async (a) => {
|
|
21598
|
+
const d = await apiPost('/api/google-ads/rules/undo', a);
|
|
21599
|
+
return ok(adRuleText(d), d);
|
|
21600
|
+
}));
|
|
21601
|
+
server.registerTool('set_google_ads_rule_limits', {
|
|
21602
|
+
title: "Caps on what Google Ads rules may raise",
|
|
21603
|
+
description: "The account's caps on automated rules, enforced at EVERY run: the most one budget/bid may rise in a day (default 100%), the most rules may add to budgets in a day, the highest budget a rule may set. Tightening needs nothing; loosening needs confirm:true. Free.",
|
|
21604
|
+
inputSchema: {
|
|
21605
|
+
customerId: z.string().optional().describe("Google Ads customer id from list_google_ads_campaigns; omit when only one is shared with this brand"),
|
|
21606
|
+
loginCustomerId: z.string().optional().describe("manager (MCC) id, only when the account is reached through a manager; stored on the rule"),
|
|
21607
|
+
maxRaisePercentPerDay: z.number().nullable().optional().describe("the most rules may raise ONE budget or bid in a day, percent (default 100); null removes it"),
|
|
21608
|
+
maxRaiseAmountPerDay: z.number().nullable().optional().describe("the most rules may ADD to budgets across the account in a day, account currency; null = no cap"),
|
|
21609
|
+
maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
|
|
21610
|
+
confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
|
|
21611
|
+
},
|
|
21612
|
+
annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false},
|
|
21613
|
+
}, wrap(async (a) => {
|
|
21614
|
+
const d = await apiPost('/api/google-ads/rules/limits', a);
|
|
21615
|
+
return ok(adRuleText(d), d);
|
|
21616
|
+
}));
|
|
20950
21617
|
// ══ OFFLINE EVENTS + CRM EVENT SETS (2026-08-19) ═══════════════════════════════════════════════════════════════
|
|
20951
21618
|
// send_tiktok_ads_events has offered eventSource "offline" and "crm" since the day it shipped, and their
|
|
20952
21619
|
// eventSourceId is an Offline / CRM Event Set ID that NOTHING IN HERMOSO COULD MINT — two branches of a live
|
|
@@ -21657,13 +22324,28 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21657
22324
|
snapProfileId: z.string().optional().describe('REQUIRED (or profileId): the Snapchat Public Profile the ad posts as. Snapchat has rejected every creative without one since 2024-02-26. From list_snapchat_ads_profiles, or Snapchat Ads Manager.'),
|
|
21658
22325
|
profileId: z.string().optional().describe('older name for snapProfileId (MCP only)'),
|
|
21659
22326
|
creative: z.boolean().optional().describe('set false to upload the MEDIA ONLY and build the creative yourself — the default true is what an ad actually needs'),
|
|
22327
|
+
aiGenerated: z.boolean().optional().describe("Snapchat's AI-content self-declaration on the media. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, your own upload_file file or an external URL is not. true or false overrides; on a reused mediaId only an explicit value changes it"),
|
|
21660
22328
|
},
|
|
21661
|
-
outputSchema: { adAccountId: z.string().optional(), mediaId: z.string().optional(), creativeId: z.string().optional(), kind: z.string().optional(), name: z.string().optional(), bytes: z.number().optional(), reusedMedia: z.boolean().optional(), creativeType: z.string().optional(), landingPageUrl: z.string().optional(), leadGenerationFormId: z.string().optional(), note: z.string().optional() },
|
|
22329
|
+
outputSchema: { aiContentSource: z.string().nullable().optional(), aiDeclared: z.boolean().optional(), aiDeclarationNote: z.string().optional(), adAccountId: z.string().optional(), mediaId: z.string().optional(), creativeId: z.string().optional(), kind: z.string().optional(), name: z.string().optional(), bytes: z.number().optional(), reusedMedia: z.boolean().optional(), creativeType: z.string().optional(), landingPageUrl: z.string().optional(), leadGenerationFormId: z.string().optional(), note: z.string().optional() },
|
|
21662
22330
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
21663
22331
|
}, wrap(async (a) => {
|
|
21664
22332
|
const d = await apiPost('/api/snapchat-ads/upload', a);
|
|
21665
22333
|
return ok(`Uploaded to Snapchat ad account ${d.adAccountId} — mediaId ${d.mediaId}${d.creativeId ? `, creativeId ${d.creativeId}` : ''} (${d.reusedMedia ? 'reused existing media' : `${Math.round((d.bytes || 0) / 1024)}KB ${d.kind}`}). ${d.note}`, d);
|
|
21666
22334
|
}));
|
|
22335
|
+
server.registerTool('set_snapchat_ads_ai_declaration', {
|
|
22336
|
+
title: 'Add or remove the AI-content declaration on Snapchat media',
|
|
22337
|
+
description: "Add or remove Snapchat's AI-content self-declaration (ai_content_source USER_AI_GEN) on media already uploaded to the ad account. declared:true adds it, false removes it. The reply is what Snapchat stored, read back. A SNAP_AI_GENERATED mark from Snapchat's own AI tools can only be changed by Snapchat. upload_snapchat_ads_creative already declares a Hermoso render when it uploads it. Free.",
|
|
22338
|
+
inputSchema: {
|
|
22339
|
+
adAccountId: z.string().optional().describe('Snapchat ad account id shared with this profile; omit when only one is shared'),
|
|
22340
|
+
mediaId: z.string().describe('the media id upload_snapchat_ads_creative returned'),
|
|
22341
|
+
declared: z.boolean().describe('true adds the declaration, false removes it'),
|
|
22342
|
+
},
|
|
22343
|
+
outputSchema: { mediaId: z.string().optional(), aiContentSource: z.string().nullable().optional(), aiDeclared: z.boolean().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
22344
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
22345
|
+
}, wrap(async (a) => {
|
|
22346
|
+
const d = await apiPost('/api/snapchat-ads/media/ai-declaration', a);
|
|
22347
|
+
return ok(d.note, d);
|
|
22348
|
+
}));
|
|
21667
22349
|
server.registerTool('create_snapchat_ads_campaign', {
|
|
21668
22350
|
title: 'Create a Snapchat campaign (forced paused)',
|
|
21669
22351
|
description: 'Create the top tier of a Snapchat ad — the campaign. CREATED PAUSED AND THERE IS NO OVERRIDE; it spends nothing until set_snapchat_ads_status(confirm:true). A CAMPAIGN ALONE CAN NEVER SERVE: it needs an ad squad and then an ad under it, and every tier must be ACTIVE before one impression is shown. Objectives are Snapchat’s CURRENT objective_v2 set — AWARENESS_AND_ENGAGEMENT, SALES, TRAFFIC, APP_PROMOTION, LEADS. A LEGACY objective name (BRAND_AWARENESS, WEB_CONVERSION and the rest) is REFUSED BY NAME rather than silently mapped onto a v2 value, because mapping one onto the other would optimise the campaign for something the user did not ask for. The result is READ BACK from Snapchat and the note says so when the read-back could not be run.',
|
|
@@ -22056,8 +22738,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22056
22738
|
postUrn: z.string().describe('the post being commented on'),
|
|
22057
22739
|
text: z.string().describe('the comment text'),
|
|
22058
22740
|
parentComment: z.string().optional().describe('to REPLY to an existing comment, its commentUrn — omit for a top-level comment'),
|
|
22741
|
+
mentionAuthor: z.boolean().optional().describe('with parentComment: @mention the author of that comment in the reply, read from LinkedIn (they are notified)'),
|
|
22742
|
+
mentionName: z.string().optional().describe('with mentionAuthor: the name to show as the mention, e.g. their full name; the reply opens with it unless the text already contains it'),
|
|
22059
22743
|
},
|
|
22060
|
-
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), commentUrn: z.string().optional(), postUrn: z.string().optional(), text: z.string().optional(), note: z.string().optional() },
|
|
22744
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), commentUrn: z.string().optional(), postUrn: z.string().optional(), text: z.string().optional(), mentioned: z.string().optional(), note: z.string().optional() },
|
|
22061
22745
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22062
22746
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/comment', a); return ok(d.note, d); }));
|
|
22063
22747
|
|
|
@@ -22693,16 +23377,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22693
23377
|
server.group('channel_admin');
|
|
22694
23378
|
server.registerTool('manage_meta_post', {
|
|
22695
23379
|
title: 'Edit or delete a published post',
|
|
22696
|
-
description: 'Edit the text of, or delete, a published post. target:"facebook" -> edit the message (action:"edit", message
|
|
23380
|
+
description: 'Edit the text of, or delete, a published post. target:"facebook" -> edit the message and/or Meta\u2019s AI-generated label (action:"edit", message, aiGenerated) OR delete (action:"delete"), and on a Facebook Reel list or invite collaborator Pages (action:"collaborators"); target:"threads" -> delete only (Threads has no edit API); target:"instagram" -> DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings > Connectors > Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.',
|
|
22697
23381
|
inputSchema: {
|
|
22698
23382
|
...MANAGE_BRAND,
|
|
22699
23383
|
postId: z.string().describe('the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media'),
|
|
22700
|
-
action: z.enum(['edit', 'delete', 'cover']).describe('edit the text (FB only), delete the post,
|
|
23384
|
+
action: z.enum(['edit', 'delete', 'cover', 'collaborators']).describe('edit the text or AI label (FB only), delete the post, cover: replace the cover of a published Facebook video or Reel (postId = its video id), or collaborators: list or invite collaborator Pages on a Facebook Reel'),
|
|
22701
23385
|
target: z.enum(['facebook', 'threads', 'instagram']).optional().describe('default facebook'),
|
|
22702
23386
|
coverAtMs: z.number().optional().describe('action:"cover" — the new cover frame, in milliseconds from the start (Facebook only; Instagram, LinkedIn and Pinterest cannot change a published cover)'),
|
|
22703
23387
|
coverImageUrl: z.string().optional().describe('action:"cover" — a Hermoso-hosted picture as the new cover, instead of a frame (a render, or any file through upload_file)'),
|
|
22704
23388
|
videoUrl: z.string().optional().describe('action:"cover" with coverAtMs — the Hermoso file the video was published from, to cut the frame from (else Facebook\u2019s own copy is used)'),
|
|
22705
23389
|
message: z.string().optional().describe('the new post text (action:"edit" on facebook)'),
|
|
23390
|
+
aiGenerated: z.boolean().optional().describe('action:"edit" on facebook: true adds Meta\u2019s AI-generated label to the post, false clears it, omit to leave it'),
|
|
23391
|
+
invitePageIds: z.array(z.string()).optional().describe('action:"collaborators": Facebook Page ids to invite as collaborators on the Reel (10 invitations per Page per day); omit to list the current ones'),
|
|
22706
23392
|
pageId: z.string().optional().describe('which Page to use — needed when the post id has no page prefix, or when the profile has several Pages and you are deleting an Instagram post'),
|
|
22707
23393
|
confirm: z.boolean().optional().describe('REQUIRED true to delete (permanent)'),
|
|
22708
23394
|
confirmName: z.string().optional().describe('Instagram only: the post’s exact caption line, exactly as the unconfirmed call reported it — required once the post has any likes or comments'),
|
|
@@ -24140,7 +24826,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24140
24826
|
board: z.boolean().optional().describe('paint the storyboard board first (default: on for UGC and cinematic spots, off for a product-only commercial); false = render from the text shot list'),
|
|
24141
24827
|
foundations: z.enum(['choose']).optional().describe("product commercial / cinematic: 'choose' paints ONLY the foundations (identity sheet + four-up moodboard, or hero + location) and returns them, no video — then call again with moodboardPick + foundationImages"),
|
|
24142
24828
|
moodboardPick: z.number().int().min(1).max(4).optional().describe('which moodboard panel (1-4, left to right, top to bottom) the storyboard follows; default 1'),
|
|
24143
|
-
foundationImages: z.object({ identitySheet: z.string().optional().describe("identity sheet image URL from a foundations:'choose' call; reused as-is"), moodboard: z.string().optional().describe("moodboard image URL from a foundations:'choose' call; reused as-is"), hero: z.string().optional().describe("hero image URL from a foundations:'choose' call; reused as-is"), location: z.string().optional().describe("location image URL from a foundations:'choose' call; reused as-is") }).optional().describe("images a foundations:'choose' call returned, reused as-is (not repainted)"),
|
|
24829
|
+
foundationImages: z.object({ identitySheet: z.string().optional().describe("identity sheet image URL from a foundations:'choose' call; reused as-is"), moodboard: z.string().optional().describe("moodboard image URL from a foundations:'choose' call; reused as-is"), hero: z.string().optional().describe("hero image URL from a foundations:'choose' call; reused as-is"), location: z.string().optional().describe("location image URL from a foundations:'choose' call; reused as-is"), boards: z.array(z.string()).max(16).optional() }).optional().describe("images a foundations:'choose' call returned, reused as-is (not repainted)"),
|
|
24144
24830
|
lockup: z.boolean().optional().describe('brand wordmark + tagline composited over the closing seconds. DEFAULT FALSE — set true ONLY when the user asks for branding on the close'),
|
|
24145
24831
|
textStyle: TEXT_STYLE_Z.optional().describe('THE LOOK of captions and the end card, only with captions or endCard and only when the user described one: the look in WORDS ("chunky yellow comic letters, purple outline"), a preset (editorial: big serif title + small italic line; bold: condensed caps, outline; minimal; handwritten; boxed; pill, the default), or fields. "TITLE · small line" puts the part after the dot on a second line.'),
|
|
24146
24832
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
@@ -24472,7 +25158,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24472
25158
|
server.registerTool('edit_timeline', {
|
|
24473
25159
|
title: 'Compose an edit (timeline)',
|
|
24474
25160
|
description: "Compose ANY edit or transition yourself; there is no preset list. Segments on an output timeline (later ones drawn on top; overlapping ones ARE the transition), each animated by keyframes (scale, x, y, rotate, opacity, blur, mblur, exposure, speed ramps, holds, reverse), plus your own HTML overlays and masks, compiled server-side into one render. Whip pan, zoom through, push, spin, slow motion, freeze frame, J-cut / L-cut, wipes, split screen, picture in picture: all composed from the fields. Local render at the flat post_edit price (~2 credits, overlays included); a `generate` segment (a paid generated transition shot) only when the user explicitly asks. "
|
|
24475
|
-
+ "SOURCES: src is 'this' (videoUrl), a Library / render URL, a direct file or a PUBLIC TikTok / Reel / Facebook / X post link (a YouTube link is refused up front, free: YouTube does not let us download videos, so upload the file). in/out are SOURCE seconds; out:'payoff' cuts just before a hook's payoff. Every seam is matched automatically (grade, level, grain)
|
|
25161
|
+
+ "SOURCES: src is 'this' (videoUrl), a Library / render URL, a direct file or a PUBLIC TikTok / Reel / Facebook / X post link (a YouTube link is refused up front, free: YouTube does not let us download videos, so upload the file). in/out are SOURCE seconds; out:'payoff' cuts just before a hook's payoff. Every seam is matched automatically (grade, level, grain). Present the render as it came back: never review, critique or re-run it on your own. review:true (only when the user asks for a check of the transitions) adds a vision REVIEW of each seam (about 2 credits) plus the seam frames; share it and let the user decide on a re-run. video_frames shows exact frames free: look before you cut. Follow-ups ('cut earlier', 'whip pan instead') re-run with the same sources and the one change. Refusals are free and name the field. "
|
|
24476
25162
|
+ "READ THE MANUAL FIRST: call get_skill('hermoso-edit-timeline') before composing. It holds the transition recipes with their exact keyframes (whip pan, zoom through, push, spin, speed ramp, freeze, wipes, split screen), how to link two unrelated clips with a match cut, the viral-hook-plus-product method, audio payoff and L/J-cut rules, and the pro-not-iMovie checklist.",
|
|
24477
25163
|
inputSchema: {
|
|
24478
25164
|
videoUrl: z.string().optional().describe("the video 'this' refers to (optional when every segment names its own src)"),
|
|
@@ -24506,7 +25192,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24506
25192
|
size: z.string().optional().describe("canvas: '9:16' default, '1:1', '4:5', '16:9', any 'W:H' (1:3 to 3:1, on a 1080 short side) or exact 'WxH' pixels"),
|
|
24507
25193
|
fps: z.number().optional().describe('the frame rate, a whole number from 24 to 60 (24, 25, 30, 60 are the usual ones). Leave it out: the canvas takes the rate of its own footage (the rate of most of it on screen), so no frames are invented; a faster clip is thinned, a slower one that does not divide it is motion-interpolated'),
|
|
24508
25194
|
motionBlur: z.boolean().optional().describe('default true: anything that moves gets a real shutter blur along its path'),
|
|
24509
|
-
review: z.boolean().optional().describe('default true: a vision read of each seam (about 2 credits) comes back with the render, with the seam frames'),
|
|
25195
|
+
review: z.boolean().optional().describe('default false. true ONLY when the user asks for a check of the transitions: a vision read of each seam (about 2 credits) comes back with the render, with the seam frames'),
|
|
24510
25196
|
match: SEAM_MATCH.optional().describe("every cut: 'auto' default"),
|
|
24511
25197
|
reframe: REFRAME_OPT.optional().describe("every cut: 'auto' default"),
|
|
24512
25198
|
},
|
|
@@ -24517,12 +25203,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24517
25203
|
const r = await renderJob('postedit', { ...(a.videoUrl ? { videoUrl: a.videoUrl } : {}), ops: [op] }, 'MCP timeline');
|
|
24518
25204
|
const tl = r?.raw?.timeline;
|
|
24519
25205
|
const gen = Array.isArray(r?.raw?.generatedShots) && r.raw.generatedShots.length ? `\nGENERATED SHOT: ${r.raw.generatedShots.map((g) => `${g.model} ${g.seconds}s ${abs(g.video)}`).join('; ')} (billed as its own render)` : '';
|
|
24520
|
-
// THE
|
|
24521
|
-
//
|
|
25206
|
+
// THE SEAM REVIEW (review:true only) comes back WITH the render: the verdict, the fixes, and the seam frames the
|
|
25207
|
+
// reviewer saw, inline, for the caller to share with the user.
|
|
24522
25208
|
const rv = r?.raw?.review;
|
|
24523
25209
|
// STILL RENDERING AFTER THE WAIT (a prod timeline takes ~3 minutes; the hosted wait is 45 s): the job id and the
|
|
24524
|
-
// polling instruction, plus where
|
|
24525
|
-
if (r?.stillRendering && !hostRendersWidgets()) return ok(`${stillMsg(r)} A timeline edit usually takes about 3 minutes. When get_job(${r.jobId}) reports done, its answer carries the
|
|
25210
|
+
// polling instruction, plus where a requested seam review will arrive, so the caller does not re-render.
|
|
25211
|
+
if (r?.stillRendering && !hostRendersWidgets()) return ok(`${stillMsg(r)} A timeline edit usually takes about 3 minutes. When get_job(${r.jobId}) reports done, its answer carries the edited video${a.review === true ? ' and the SEAM REVIEW you asked for, with the seam frames' : ''}.`, r);
|
|
24526
25212
|
const rvText = timelineReviewText(rv);
|
|
24527
25213
|
const res = await okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.filter((x) => !/^seams matched:/.test(x)).join('; ')}` : ''}${tl ? `\nRESOLVED: ${tl.segments.map((sg) => `[${sg.i}] ${sg.in != null ? `${sg.in}-${sg.out}s` : `${sg.seconds}s`} @${sg.at}s`).join(', ')} (${tl.seconds}s)` : ''}${gen}${seamsText(r?.raw)}${rvText} [job ${r.jobId}]`, r);
|
|
24528
25214
|
const sheet = rv?.seamSheets?.[0]?.url ? await imageBlock(abs(rv.seamSheets[0].url)) : null;
|
|
@@ -24640,11 +25326,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24640
25326
|
auto: z.enum(['sentences', 'words']).optional().describe('words (default) or sentences: timing unit for transcribed subtitles'), wordsPerCue: z.number().optional().describe('words per subtitle line when timed by words'),
|
|
24641
25327
|
cues: z.array(z.object({ text: z.string().describe('subtitle line text, max 90 characters, no emoji'), start: z.number().describe('second the line appears'), end: z.number().describe('second the line disappears') })).max(400).optional()
|
|
24642
25328
|
.describe('your own lines in seconds, no overlap, max 90 chars, no emoji'),
|
|
25329
|
+
script: z.string().max(8000).optional().describe('the words actually spoken, when you have them: the subtitles then use these words and take only the timing from the audio (a video we rendered already knows its script)'),
|
|
24643
25330
|
},
|
|
24644
25331
|
outputSchema: { ...JOB_OUT },
|
|
24645
25332
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
24646
25333
|
}, wrap(async (a) => {
|
|
24647
|
-
const r = await renderJob('subtitles', { video: a.video, textStyle: a.textStyle, burn: a.burn, ...(a.cues ? { cues: a.cues } : {}), ...(a.auto ? { auto: a.auto } : {}), ...(a.wordsPerCue != null ? { wordsPerCue: a.wordsPerCue } : {}) }, 'MCP subtitles');
|
|
25334
|
+
const r = await renderJob('subtitles', { video: a.video, textStyle: a.textStyle, burn: a.burn, ...(a.cues ? { cues: a.cues } : {}), ...(a.auto ? { auto: a.auto } : {}), ...(a.wordsPerCue != null ? { wordsPerCue: a.wordsPerCue } : {}), ...(a.script ? { script: a.script } : {}) }, 'MCP subtitles');
|
|
24648
25335
|
if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the result when it lands
|
|
24649
25336
|
const raw = r?.raw || {};
|
|
24650
25337
|
// THE HEADLINE IS THE READ-BACK: "burned" only when the returned file really carries the subtitles.
|
|
@@ -24859,6 +25546,129 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24859
25546
|
return ok(`Series started: ${jobs.length} episode${jobs.length === 1 ? '' : 's'} rendering (${secs}s each, a few minutes per episode).\n${jobs.map((j) => `${j.episode}. ${j.title} [job ${j.jobId}]`).join('\n')}\n${styleLine}${watched}\nPoll get_job for each id. To schedule them once done, call make_series with jobIds and schedule {channels, at, everyHours}. For more episodes later, call it with moreOf set to this plan.`, { jobs, plan });
|
|
24860
25547
|
}));
|
|
24861
25548
|
|
|
25549
|
+
// STORY FILMS (2026-11-11, lib/story-cards.mjs on the server): a story card is a proven short-film narrative (8 styles, 44
|
|
25550
|
+
// cards, 40-240 s, 9:16) whose turn is the brand's product. The server owns the catalog, the run and every request body;
|
|
25551
|
+
// these tools only run each stage against the ordinary billed routes and record it, so a stage costs exactly what the same
|
|
25552
|
+
// render costs anywhere else. Ask first (the default) stops before every paid stage with its live quote; autoRun stops once,
|
|
25553
|
+
// after the screenplay, with the price of everything left, then runs the rest straight through.
|
|
25554
|
+
const storyStageLine = (run) => (run?.stages || []).map((s) => `${s.label}: ${s.status}${s.error ? ` (${s.error})` : ''}`).join(' · ');
|
|
25555
|
+
const storyScenesText = (c) => ((c?.video_storyboard?.scenes) || []).map((sc, i) => `${i + 1}. ${Math.round(+sc.seconds || 0)}s${sc.arc_role ? ` ${sc.arc_role}` : ''}: ${String(sc.visual || '').replace(/\s+/g, ' ').slice(0, 160)}${sc.voiceover ? ` | "${String(sc.voiceover).slice(0, 140)}"` : ''}`).join('\n');
|
|
25556
|
+
const storyQuoteOf = (q) => (q && Number.isFinite(+q.credits) && q.credits != null ? +(q.priceCredits > q.credits ? q.priceCredits : q.credits) : null);
|
|
25557
|
+
server.registerTool('list_story_cards', {
|
|
25558
|
+
title: 'List story film cards',
|
|
25559
|
+
description: 'Free. The story film catalog: 8 styles (micro drama, talking object, cinematic story, cartoon, claymation, inside the body, doll, crochet) and 44 story cards, each a short film with a fixed beat structure where the brand\'s product turns the story, 40-240 seconds, 9:16. Filter by style or by a length that must fit. Pick one, read it with get_story_card, start it with start_story_film.',
|
|
25560
|
+
inputSchema: {
|
|
25561
|
+
style: z.string().optional().describe('one style id: micro_drama, talking_object, cinematic_story, cartoon, claymation, inside_body, doll, crochet'),
|
|
25562
|
+
maxSeconds: z.number().optional().describe('only cards that can run this short'),
|
|
25563
|
+
},
|
|
25564
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
25565
|
+
}, wrap(async (a) => {
|
|
25566
|
+
const q = new URLSearchParams(); if (a.style) q.set('style', a.style); if (+a.maxSeconds > 0) q.set('maxSeconds', String(Math.round(+a.maxSeconds)));
|
|
25567
|
+
const d = await apiGet(`/api/story/cards${q.toString() ? `?${q}` : ''}`);
|
|
25568
|
+
const styles = (d.styles || []).filter((s) => !a.style || s.id === a.style);
|
|
25569
|
+
const lines = styles.map((s) => `${s.label} (${s.id}, ${s.lengthRange[0]}-${s.lengthRange[1]}s): ${s.blurb}\n${(d.cards || []).filter((c) => c.style === s.id).map((c) => ` • ${c.id}: "${c.title}" ${c.lengthSeconds.default}s — ${c.logline}`).join('\n')}`);
|
|
25570
|
+
return ok(`Story films, 0 credits to browse. ${(d.cards || []).length} card${(d.cards || []).length === 1 ? '' : 's'}, all ${d.aspectRatio}, up to ${d.maxSeconds}s:\n${lines.join('\n')}\nStages of a run: ${(d.stages || []).map((s) => s.label).join(' → ')}. A card is a starting point: start_story_film also takes your own card.`, d);
|
|
25571
|
+
}));
|
|
25572
|
+
server.registerTool('get_story_card', {
|
|
25573
|
+
title: 'Read a story card recipe',
|
|
25574
|
+
description: 'Free. One story card\'s full recipe: its style and look, beats (role, share of the film, what each must show), cast slots (product, presenter, rival, location), the location rule, and the outline at a length (each beat in seconds and the acts it is filmed as).',
|
|
25575
|
+
inputSchema: {
|
|
25576
|
+
cardId: z.string().describe('a card id from list_story_cards'),
|
|
25577
|
+
lengthSeconds: z.number().optional().describe('outline it at this length (20-240); default the card\'s own'),
|
|
25578
|
+
},
|
|
25579
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
25580
|
+
}, wrap(async (a) => {
|
|
25581
|
+
const d = await apiGet(`/api/story/cards/${encodeURIComponent(a.cardId)}${+a.lengthSeconds > 0 ? `?lengthSeconds=${Math.round(+a.lengthSeconds)}` : ''}`);
|
|
25582
|
+
const ol = d.outline || {};
|
|
25583
|
+
return ok(`"${d.title}" (${d.styleLabel}, ${ol.seconds}s, written as ${ol.actCount} scenes of up to 15s, ${d.aspectRatio}). ${d.logline}\nLook: ${d.look}\nLocation: ${d.locationRule}\nBeats:\n${(ol.beats || []).map((b) => ` ${b.role} ${b.start}-${b.end}s: ${b.must}`).join('\n')}\nScenes: ${(ol.acts || []).map((x) => `${x.k} ${x.seconds}s (${x.beats.join('+')})`).join('; ')} (the render joins them into the fewest takes its engine films)\n0 credits. Start it with start_story_film({cardId:"${d.id}"}).`, d);
|
|
25584
|
+
}));
|
|
25585
|
+
server.registerTool('start_story_film', {
|
|
25586
|
+
title: 'Start a story film',
|
|
25587
|
+
description: 'Free. Starts a staged story film run from a card (cardId from list_story_cards, or your own `card` object with the same fields as get_story_card returns) for the brand\'s product: it writes the outline, nothing is billed. Then call advance_story_film to run each stage: screenplay, boards (cast, location and a board per act), clips, final cut. autoRun:true runs every stage after the screenplay straight through once its price is shown; the default asks before each paid stage.',
|
|
25588
|
+
inputSchema: {
|
|
25589
|
+
cardId: z.string().optional().describe('a card id from list_story_cards'),
|
|
25590
|
+
card: z.object({}).passthrough().optional().describe('your own card instead: {id, style, title, logline, lengthSeconds:{min,max,default}, beats:[{role, share, must}], ...}; shares add up to 1'),
|
|
25591
|
+
product: z.string().optional().describe('the product that turns the story, in words (default: the brand\'s own)'),
|
|
25592
|
+
lengthSeconds: z.number().optional().describe('total length, 20-240 seconds; default the card\'s own. A named length is used as asked'),
|
|
25593
|
+
creator: z.string().optional().describe('the hero: a saved creator id or name from list_creators (live-action styles)'),
|
|
25594
|
+
rival: z.string().optional().describe('the rival or second character, in words'),
|
|
25595
|
+
location: z.string().optional().describe('where it happens, in words; default the card\'s location rule'),
|
|
25596
|
+
autoRun: z.boolean().optional().describe('true = stop once after the screenplay with the price of the rest, then run it all'),
|
|
25597
|
+
resolution: z.enum(['480p', '720p', '1080p']).optional().describe('render tier; default the standard tier'),
|
|
25598
|
+
},
|
|
25599
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
25600
|
+
}, wrap(async (a) => {
|
|
25601
|
+
const d = await apiPost('/api/story/runs', { cardId: a.cardId, card: a.card, product: a.product, lengthSeconds: a.lengthSeconds, presenter: a.creator, rival: a.rival, location: a.location, autoRun: a.autoRun === true, resolution: a.resolution });
|
|
25602
|
+
const run = d.run || {}; const ol = (run.stages || []).find((s) => s.id === 'outline')?.outline || {};
|
|
25603
|
+
return ok(`Story film run ${run.id} started: "${run.title}", ${run.lengthSeconds}s written as ${ol.actCount} scenes, ${run.aspectRatio}. 0 credits so far.${d.lengthNote ? ` ${d.lengthNote}` : ''}\nOutline:\n${(ol.beats || []).map((b) => ` ${b.role} ${b.start}-${b.end}s: ${b.must}`).join('\n')}\nNext: the screenplay (${run.next?.billing || 'billed at its writing tokens'}). Call advance_story_film({runId:"${run.id}", approve:true}) to write it.`, d);
|
|
25604
|
+
}));
|
|
25605
|
+
server.registerTool('advance_story_film', {
|
|
25606
|
+
title: 'Run the next stage of a story film',
|
|
25607
|
+
description: 'Runs the next stage of a story film run and stops for approval. Without approve it only reads the run and quotes the next stage (free). approve:true runs it: screenplay (billed at its writing tokens), boards (cast, location and one board per act, each paint billed as an image), clips (every act filmed from the approved boards and joined in order, reserved at the quote), final cut (free). Pass `creative` to replace the screenplay with an edited one (boards are repainted for it), or lengthSeconds to re-outline. A stage still rendering says so; call again to pick it up.',
|
|
25608
|
+
inputSchema: {
|
|
25609
|
+
runId: z.string().describe('the run id start_story_film returned'),
|
|
25610
|
+
approve: z.boolean().optional().describe('run the next stage (the user saw its quote and said yes)'),
|
|
25611
|
+
creative: z.object({}).passthrough().optional().describe('an edited screenplay (the creative this tool returned, changed) to use instead'),
|
|
25612
|
+
lengthSeconds: z.number().optional().describe('re-outline at this length (throws away the screenplay and boards)'),
|
|
25613
|
+
},
|
|
25614
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
25615
|
+
}, wrap(async (a) => {
|
|
25616
|
+
const id = encodeURIComponent(a.runId);
|
|
25617
|
+
let d = (a.creative || +a.lengthSeconds > 0) ? await apiPost(`/api/story/runs/${id}/edit`, { ...(a.creative ? { creative: a.creative } : {}), ...(+a.lengthSeconds > 0 ? { lengthSeconds: +a.lengthSeconds } : {}) }) : await apiGet(`/api/story/runs/${id}`);
|
|
25618
|
+
let run = d.run; let approved = a.approve === true; const said = [];
|
|
25619
|
+
for (let guard = 0; guard < 6; guard++) {
|
|
25620
|
+
const st = (run.stages || []).find((s) => s.id === run.stage);
|
|
25621
|
+
if (run.stage === 'done') { const f = (run.stages || []).find((s) => s.id === 'stitch'); return ok(`${said.join('\n')}${said.length ? '\n' : ''}Story film done: ${abs(f?.video) || '(no file)'} [run ${run.id}]\n${storyStageLine(run)}`, { run }); }
|
|
25622
|
+
if (st && st.status === 'running') return ok(`${said.join('\n')}${said.length ? '\n' : ''}The ${st.label.toLowerCase()} stage is still rendering [job ${st.jobId}]. Call advance_story_film({runId:"${run.id}"}) again in a minute or two to pick it up; nothing more is charged by asking.\n${storyStageLine(run)}`, { run, stillRendering: true, jobId: st.jobId });
|
|
25623
|
+
const next = run.next || {};
|
|
25624
|
+
if (next.kind === 'create') {
|
|
25625
|
+
if (!approved) return ok(`${said.join('\n')}${said.length ? '\n' : ''}Next: the screenplay for "${run.title}" (${run.lengthSeconds}s). ${next.billing}; usually a few credits. Call again with approve:true to write it.\n${storyStageLine(run)}`, { run });
|
|
25626
|
+
const c = await apiPost('/api/create', d.screenplayBody || {});
|
|
25627
|
+
const creative = c?.creative || c;
|
|
25628
|
+
if (!creative?.video_storyboard) return ok(`The planner did not return a screenplay: ${c?.message || c?.error || 'no storyboard'}. Nothing was recorded; call again to retry.`, { run, planner: c });
|
|
25629
|
+
d = await apiPost(`/api/story/runs/${id}/record`, { stage: 'screenplay', creative }); run = d.run;
|
|
25630
|
+
said.push(`SCREENPLAY written (${(creative.video_storyboard.scenes || []).length} scenes, ${Math.round(+creative.render_plan?.duration_seconds || 0)}s):\n${storyScenesText(creative)}`);
|
|
25631
|
+
approved = false; // the next paid stage is quoted before it runs (autoRun included: its price is shown once, here)
|
|
25632
|
+
continue;
|
|
25633
|
+
}
|
|
25634
|
+
if (next.kind === 'render') {
|
|
25635
|
+
const qa = await apiPost('/api/render/assemble', { ...next.body, dryRun: true });
|
|
25636
|
+
if (qa?.boardsUnavailable) { d = await apiPost(`/api/story/runs/${id}/record`, { stage: 'boards', skipped: true, reason: qa.reason }); run = d.run; said.push(`BOARDS skipped: ${qa.reason}. The clips are filmed from the screenplay's shot list.`); continue; }
|
|
25637
|
+
const credits = storyQuoteOf(qa?.quote);
|
|
25638
|
+
if (qa?.needsProductPhoto) return ok(`${said.join('\n')}${said.length ? '\n' : ''}NOTHING WAS PAINTED OR RENDERED and nothing was charged: this brand has no photo of the product, so the story would invent it. Lock a real photo with set_product_image, then call advance_story_film again.`, { run, needsProductPhoto: true });
|
|
25639
|
+
if (credits == null) return ok(`${said.join('\n')}${said.length ? '\n' : ''}The ${next.stage} stage would not start${qa?.quote?.refused ? `: ${qa.quote.refused}` : ''}. Nothing was charged.`, { run, quote: qa?.quote || null });
|
|
25640
|
+
// autoRun: after the screenplay, ONE quote for everything left (the clips quote prices the boards it would paint too).
|
|
25641
|
+
if (!approved && run.autoRun && next.stage === 'boards') {
|
|
25642
|
+
const qc = await apiPost('/api/render/assemble', { ...next.body, boards: undefined, dryRun: true });
|
|
25643
|
+
const all = storyQuoteOf(qc?.quote);
|
|
25644
|
+
return ok(`${said.join('\n')}${said.length ? '\n' : ''}AUTO RUN: everything left (boards, then ${run.lengthSeconds}s of clips, then the final cut) is estimated at about ${all ?? credits} credits. ${PRICE_ESTIMATE_LINE} Call again with approve:true and it runs straight through.\n${storyStageLine(run)}`, { run, quote: qc?.quote || qa.quote, credits: all ?? credits });
|
|
25645
|
+
}
|
|
25646
|
+
if (!approved) return ok(`${said.join('\n')}${said.length ? '\n' : ''}Next: ${next.stage === 'boards' ? 'the boards (the cast, the location and one board per act, for approval before anything is filmed)' : `the clips (${run.lengthSeconds}s, every act filmed from the approved boards and joined in order)`}, estimated at about ${credits} credits. ${PRICE_ESTIMATE_LINE} Call again with approve:true to run it.\n${storyStageLine(run)}`, { run, quote: qa.quote, credits });
|
|
25647
|
+
const asm = await apiPost('/api/render/assemble', next.body);
|
|
25648
|
+
if (asm?.boardsUnavailable) { d = await apiPost(`/api/story/runs/${id}/record`, { stage: 'boards', skipped: true, reason: asm.reason }); run = d.run; continue; }
|
|
25649
|
+
const r = await renderJob(asm.jobType === 'stitch' ? 'stitch' : 'video', asm.input, `Story film · ${run.title} · ${next.stage}`);
|
|
25650
|
+
d = await apiPost(`/api/story/runs/${id}/record`, { stage: next.stage, jobId: r.jobId }); run = d.run;
|
|
25651
|
+
const sd = (run.stages || []).find((s) => s.id === next.stage) || {};
|
|
25652
|
+
if (sd.status === 'error') return ok(`${said.join('\n')}${said.length ? '\n' : ''}The ${next.stage} stage failed: ${sd.error}`, { run });
|
|
25653
|
+
if (next.stage === 'boards' && sd.status === 'done') said.push(`BOARDS painted (${(sd.boards || []).length}, ${sd.credits || 0} credits): ${(sd.boards || []).map((b) => abs(b.image)).join(' , ')}${Object.entries(sd.foundations || {}).filter(([, v]) => typeof v === 'string' && v).map(([k, v]) => `\n ${k}: ${abs(v)}`).join('')}`);
|
|
25654
|
+
if (next.stage === 'clips' && sd.status === 'done') said.push(`CLIPS rendered: ${abs(sd.video)} (${sd.segments} acts, ${sd.durationSeconds}s${sd.credits ? `, ${sd.credits} credits` : ''}). Any act can be redone with fix_beat before you approve the cut.`);
|
|
25655
|
+
approved = run.autoRun && a.approve === true; // autoRun keeps going after its one approval; ask-first stops here
|
|
25656
|
+
// ASK FIRST: stop with the result AND the next stage's live quote, so the next approve:true is approving a price the
|
|
25657
|
+
// user has already seen (never a render nobody priced).
|
|
25658
|
+
if (!approved) continue;
|
|
25659
|
+
continue;
|
|
25660
|
+
}
|
|
25661
|
+
if (next.kind === 'approve') {
|
|
25662
|
+
const cl = (run.stages || []).find((s) => s.id === 'clips') || {};
|
|
25663
|
+
if (!approved) return ok(`${said.join('\n')}${said.length ? '\n' : ''}The cut: ${abs(cl.video)} (${cl.durationSeconds}s). Approve it (free) with advance_story_film({runId:"${run.id}", approve:true}), or redo an act first with fix_beat.\n${storyStageLine(run)}`, { run });
|
|
25664
|
+
d = await apiPost(`/api/story/runs/${id}/record`, { stage: 'stitch', approve: true }); run = d.run;
|
|
25665
|
+
continue;
|
|
25666
|
+
}
|
|
25667
|
+
return ok(`This run is at a stage this tool does not know (${run.stage}).`, { run });
|
|
25668
|
+
}
|
|
25669
|
+
return ok(`${said.join('\n')}\n${storyStageLine(run)}`, { run });
|
|
25670
|
+
}));
|
|
25671
|
+
|
|
24862
25672
|
// THE EFFECTS, FOR AGENTS (2026-11-01; held since the 2026-10-09 parity batch): the same presets as the /tools effect pages, the
|
|
24863
25673
|
// same two jobs (still, then clip), each with its own free quote and its own confirm. The server writes the prompt from the id.
|
|
24864
25674
|
const effectQuote = (q, what) => { const c = q && (q.priceCredits || q.credits); return c != null && Number.isFinite(+c) ? `QUOTE (nothing made, nothing charged): ${what} is about ${c} credits. ${PRICE_ESTIMATE_LINE} Add confirm:true to make it.` : `${what} would not start${q && q.refused ? `: ${q.refused}` : ', so it has no price'}. Nothing was charged.`; };
|
|
@@ -25967,9 +26777,13 @@ function memoryNoteVerdict(text) {
|
|
|
25967
26777
|
+ gap.map(c => ` • ${c.scopeDriftNote || `${c.provider}: missing ${(c.scopeDrift.missing || []).join(', ')}`}`).join('\n')
|
|
25968
26778
|
+ '\nThis is not something an agent can fix: re-authorizing is an OAuth consent screen, so the user has to do it in a browser — Workspace ▸ Connectors ▸ the connector ▸ Reconnect. Everything the connection already carries keeps working until then.' + gap.map(c => `\n Reconnect ${c.provider}: ${linkFor(c.provider)}`).join('')
|
|
25969
26779
|
: '';
|
|
26780
|
+
// A DEAD CONNECTION IS LEFT OUT OF `on` (status revoked), so without this it read as "not connected" (2026-11-12).
|
|
26781
|
+
const broken = await brokenConnectorNotes();
|
|
26782
|
+
const brokenNote = broken.length ? `\n\n⚠ ${broken.length} connection${broken.length === 1 ? '' : 's'} stopped working and need${broken.length === 1 ? 's' : ''} the user:\n` + broken.map((i) => ` • ${i.agentNote}`).join('\n') : '';
|
|
25970
26783
|
const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
|
|
26784
|
+
if (broken.length) safe.needsAttention = broken.map(({ provider, label, who, reason, steps, connectUrl, postsWaiting, nextPostAt, agentNote }) => ({ provider, label, who, reason, steps, connectUrl, postsWaiting, nextPostAt, note: agentNote }));
|
|
25971
26785
|
const onBrand = d.brand && typeof d.brand.profileId === 'string' ? `On ${d.brand.name ? `"${d.brand.name}" (${d.brand.profileId})` : `profile ${d.brand.profileId}`}${d.brand.shared ? ', shared with you' : ''}. ` : '';
|
|
25972
|
-
return ok(`${onBrand}${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under Workspace ▸ Connectors (keys are never pasted into this chat)' : 'connectable from here with connect_connector or in the app'}: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : (widget ? emptyWidget : empty)}${gapNote}${waitingNote}${postingNote}`, safe);
|
|
26786
|
+
return ok(`${onBrand}${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under Workspace ▸ Connectors (keys are never pasted into this chat)' : 'connectable from here with connect_connector or in the app'}: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : (widget ? emptyWidget : empty)}${brokenNote}${gapNote}${waitingNote}${postingNote}`, safe);
|
|
25973
26787
|
}));
|
|
25974
26788
|
// ── ALERTS (2026-10-27): Slack / Discord / a signed generic webhook, for renders, scheduled posts, drafts waiting for
|
|
25975
26789
|
// approval, competitor-watch findings, new leads and low credits. Rules: lib/alert-sinks.mjs; routes /api/alerts*.
|
|
@@ -25992,8 +26806,8 @@ function memoryNoteVerdict(text) {
|
|
|
25992
26806
|
}));
|
|
25993
26807
|
server.registerTool('set_alert_events', {
|
|
25994
26808
|
title: 'Choose which alerts a channel receives',
|
|
25995
|
-
description: "Choose which event groups one alert channel receives. provider: slack, discord or webhook. events: any of renders (a render finished or failed), posts (a scheduled post published, partly published or failed), approvals (autopilot drafts waiting for review), watch (new competitor ads found by the competitor watch), credits (credits running low), leads (a new lead-form lead from Meta, LinkedIn or Snapchat), connections (an account connected to or disconnected from the brand: account.connected / account.disconnected), inbox (a new Facebook or Instagram comment or direct message: comment.received / message.received; opt-in, never on unless named) — or \"all\". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.",
|
|
25996
|
-
inputSchema: { provider: alertSinkEnum.describe('alert channel to configure: slack, discord or webhook'), events: z.union([z.array(z.enum(['renders', 'posts', 'approvals', 'watch', 'credits', 'leads', 'connections', 'inbox', 'all'])), z.literal('all')]).describe('event groups, or "all"') },
|
|
26809
|
+
description: "Choose which event groups one alert channel receives. provider: slack, discord or webhook. events: any of renders (a render finished or failed), posts (a scheduled post published, partly published or failed), approvals (autopilot drafts waiting for review), watch (new competitor ads found by the competitor watch), credits (credits running low), leads (a new lead-form lead from Meta, LinkedIn or Snapchat), connections (an account connected to or disconnected from the brand: account.connected / account.disconnected), adrules (an automated Google Ads rule with a notify action matched), inbox (a new Facebook or Instagram comment or direct message: comment.received / message.received; opt-in, never on unless named) — or \"all\". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.",
|
|
26810
|
+
inputSchema: { provider: alertSinkEnum.describe('alert channel to configure: slack, discord or webhook'), events: z.union([z.array(z.enum(['renders', 'posts', 'approvals', 'watch', 'credits', 'leads', 'connections', 'adrules', 'inbox', 'all'])), z.literal('all')]).describe('event groups, or "all"') },
|
|
25997
26811
|
outputSchema: { provider: z.string().optional(), events: z.array(z.string()).nullable().optional(), note: z.string().optional() },
|
|
25998
26812
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
25999
26813
|
}, wrap(async (a) => { const d = await apiPost('/api/alerts/events', a); return ok(d.note, d); }));
|
|
@@ -26105,6 +26919,14 @@ function memoryNoteVerdict(text) {
|
|
|
26105
26919
|
identities: (d) => (d.accounts || []).map(a => ({ id: String(a.adAccountId), name: a.name, type: 'ad_account', selected: !!a.selected, detail: a.orgId ? `org ${a.orgId}` : '' })),
|
|
26106
26920
|
write: (ids) => ['/api/apple-ads/scope', { adAccountIds: ids }],
|
|
26107
26921
|
},
|
|
26922
|
+
// AMAZON ADS (2026-11-12). One Login with Amazon reaches every advertiser profile its user holds a role on, one per
|
|
26923
|
+
// advertiser per MARKETPLACE, so an agency login carries every client's profiles. The marketplace and currency ride
|
|
26924
|
+
// `detail` because the same advertiser name appears once per country.
|
|
26925
|
+
amazon_ads: {
|
|
26926
|
+
label: 'Amazon Ads', read: '/api/amazon-ads/accounts',
|
|
26927
|
+
identities: (d) => (d.profiles || []).map(p => ({ id: String(p.profileId), name: p.name || p.accountId || p.profileId, type: 'advertiser_profile', selected: !!p.selected, detail: [p.countryCode, p.currencyCode, p.type, p.region].filter(Boolean).join(' · ') })),
|
|
26928
|
+
write: (ids) => ['/api/amazon-ads/scope', { profileIds: ids }],
|
|
26929
|
+
},
|
|
26108
26930
|
microsoft_ads: {
|
|
26109
26931
|
label: 'Microsoft Ads', read: '/api/microsoft-ads/accounts',
|
|
26110
26932
|
identities: (d) => (d.accounts || []).map(a => ({ id: String(a.accountId), name: a.name, type: 'ad_account', selected: !!a.selected, detail: a.customerId ? `customer ${a.customerId}` : '' })),
|
|
@@ -27983,7 +28805,7 @@ function memoryNoteVerdict(text) {
|
|
|
27983
28805
|
// motion and the ORIGINAL AUDIO untouched. The plan is one server call; every variant is a `multiply` job (opening-frame keyframe edit → motion transfer driven by the source, so length, cut, motion and audio are the original's).
|
|
27984
28806
|
server.registerTool('multiply_ad', {
|
|
27985
28807
|
title: 'Multiply an ad',
|
|
27986
|
-
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and
|
|
28808
|
+
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and the source's own captions and on-screen text stay exactly as they are. Pass the source video URL (a render, job result, list_library, or the top performer from post_performance / meta_insights). HOW: motion transfer driven by the source from one image-edited opening frame, so length, cut and audio are the source's. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). ONE JOB PER VARIANT: get_job each until done; never describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. images takes up to 9 of the user's product, people, clothes or places, and change names them @Image1, @Image2 in order ('she holds the bottle in @Image1'); the saved brand's product photo is read as a reference to its real look (useBrand:false leaves it out). No ad: list_winning_ads. New words: rewrite_ad_line. A TikTok, Reel, Facebook or X post link works as the source (not YouTube: upload the file): one paid lookup reads it into a file. quality: 1080p (default) or 720p; quoteOnly prices both. regions:['Berlin','Tokyo'] restyles per market; translation: dub_video on a finished variant. `change` is what should change, in the user's own words ('older women', 'winter streets'): every variant applies it and the variants still differ around it; a part of it about the voice, words, language, music, length or captions cannot change here, and the reply says so and names the tool that can.",
|
|
27987
28809
|
inputSchema: {
|
|
27988
28810
|
video: z.string().optional().describe('the source video: a file URL, or a TikTok, Reel, Facebook or X post link (not YouTube)'),
|
|
27989
28811
|
start: z.string().optional().describe('instead of video: an id from list_winning_ads'),
|
|
@@ -28027,14 +28849,14 @@ function memoryNoteVerdict(text) {
|
|
|
28027
28849
|
const lines = (p.variants || []).map((v, i) => `${i + 1}. ${v.label} — ${['character', 'outfit', 'location', 'objects'].filter(a => v[a] && v[a] !== 'same').map(a => a + ': ' + v[a]).join('; ')}`);
|
|
28028
28850
|
const _nV = (p.variants || []).length;
|
|
28029
28851
|
const _planPaid = hasSaved ? 0 : (Number(p.planCreditsUsed) || 0); // only the read this call paid for; a saved plan was paid for by the dry run that made it
|
|
28030
|
-
const quote = `${p.perVariantCredits} credits per variant${p.quality === '720p' ? ' at 720p' : ''}, ${p.perVariantCredits * _nV} credits for ${_nV}${p.perVariantMaxCredits ? ` (up to ${p.perVariantMaxCredits} per variant
|
|
28852
|
+
const quote = `${p.perVariantCredits} credits per variant${p.quality === '720p' ? ' at 720p' : ''}, ${p.perVariantCredits * _nV} credits for ${_nV}${p.perVariantMaxCredits ? ` (up to ${p.perVariantMaxCredits} per variant when the source has several shots, one reference still each)` : ''}${_planPaid ? ` (the planning read just billed ${_planPaid} credits)` : ''}`;
|
|
28031
28853
|
if (dryRun) return { content: [{ type: 'text', text: `Plan (nothing rendered). Source: ${Object.entries(p.source || {}).map(([k, v]) => k + ': ' + v).join('; ')}\n${lines.join('\n')}${changeLine}${_srcLine}\n${quote}. ${PRICE_ESTIMATE_LINE} Call again with plan set to this plan (and no dryRun) to render exactly these without paying to plan again.` }], structuredContent: { plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.perVariantCredits * _nV, planCreditsUsed: _planPaid, dryRun: true } };
|
|
28032
28854
|
const jobs = [];
|
|
28033
28855
|
for (const v of (p.variants || [])) {
|
|
28034
28856
|
const job = await submitJob('multiply', { video: _jobSrc, instruction: v.instruction, label: v.label, ...((p.images || []).length ? { images: p.images } : {}), ...(p.quality ? { quality: p.quality } : {}) }, { label: `Multiply · ${v.label}` });
|
|
28035
28857
|
jobs.push({ id: job.id, label: v.label });
|
|
28036
28858
|
}
|
|
28037
|
-
return { content: [{ type: 'text', text: `Multiplying — ${jobs.length} variant(s) queued, original audio kept on all of them. ${quote}.${changeLine}${_srcLine}\n${jobs.map((j, i) => `${i + 1}. ${j.label} → job ${j.id}`).join('\n')}\nEach variant is
|
|
28859
|
+
return { content: [{ type: 'text', text: `Multiplying — ${jobs.length} variant(s) queued, original audio kept on all of them. ${quote}.${changeLine}${_srcLine}\n${jobs.map((j, i) => `${i + 1}. ${j.label} → job ${j.id}`).join('\n')}\nEach variant is an edit of the whole source video, guided by an edited still of each shot (2-6 minutes), and comes back the SAME length as the source with its own sound and its own captions. Call get_job with each id until it reports done; a variant has NO file until then.` }], structuredContent: { jobs, plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.perVariantCredits * _nV, planCreditsUsed: _planPaid } };
|
|
28038
28860
|
}));
|
|
28039
28861
|
|
|
28040
28862
|
// START FROM A WINNING AD (2026-11-08, lib/winning-ads.mjs): the multiplier's shelf, free. Held out of the default list
|