hermoso 0.1.255 → 0.1.258
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 +26 -7
- package/mcp/http.mjs +17 -5
- package/mcp/tools.mjs +187 -15
- package/package.json +2 -2
- package/skills/hermoso-marketing/SKILL.md +67 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**847 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
@@ -33,8 +33,9 @@ just download it. Use the one piece you need, or all of it together.
|
|
|
33
33
|
## Install in one command
|
|
34
34
|
|
|
35
35
|
This repo is a plugin marketplace, a Gemini CLI extension and a skills package at once, so a coding agent takes
|
|
36
|
-
Hermoso in one line. Every install brings the same
|
|
37
|
-
`hermoso-ad-from-brand`, `hermoso-product-photoshoot`), and the skills drive the `hermoso`
|
|
36
|
+
Hermoso in one line. Every install brings the same five skills (`hermoso-research`, `hermoso-generate`,
|
|
37
|
+
`hermoso-ad-from-brand`, `hermoso-product-photoshoot`, `hermoso-marketing`), and the skills drive the `hermoso`
|
|
38
|
+
CLI through `npx`. No
|
|
38
39
|
tool list is loaded into your session: a CLI command costs nothing until it runs, and it reaches every tool.
|
|
39
40
|
The first time, your agent runs `npx -y hermoso auth login`, which opens a browser to sign in.
|
|
40
41
|
|
|
@@ -163,7 +164,7 @@ handshake is open; "None" would leave every tool call unauthenticated), approve
|
|
|
163
164
|
1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai). The free tier is included; plans and credits
|
|
164
165
|
are the same ones the web Studio uses. Or skip the browser entirely and let your agent sign itself up on a paid
|
|
165
166
|
plan with `POST /v1/signup` (above).
|
|
166
|
-
2. **Install the plugin.** It adds the
|
|
167
|
+
2. **Install the plugin.** It adds the five Hermoso skills, which drive the `hermoso` CLI through `npx`:
|
|
167
168
|
|
|
168
169
|
```bash
|
|
169
170
|
claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
|
|
@@ -215,7 +216,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
215
216
|
|
|
216
217
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
217
218
|
|
|
218
|
-
### What the
|
|
219
|
+
### What the 847 tools cover
|
|
219
220
|
|
|
220
221
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
221
222
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -349,8 +350,15 @@ the package, so they need no key, no network and no sign-in.
|
|
|
349
350
|
|
|
350
351
|
## 3. Skills: what a coding agent installs
|
|
351
352
|
|
|
352
|
-
`skills/` holds
|
|
353
|
-
|
|
353
|
+
`skills/` holds five installable skills:
|
|
354
|
+
|
|
355
|
+
| Skill | What it does |
|
|
356
|
+
| --- | --- |
|
|
357
|
+
| `hermoso-research` | Find competitors, pull their real running ads, surface the hooks worth copying |
|
|
358
|
+
| `hermoso-generate` | A prompt to a finished image, video, avatar clip or stitched cut |
|
|
359
|
+
| `hermoso-ad-from-brand` | A brand or domain to one finished, on-brand ad, concept and copy included |
|
|
360
|
+
| `hermoso-product-photoshoot` | A real product photo composited into studio, lifestyle or hero scenes |
|
|
361
|
+
| `hermoso-marketing` | The whole loop: research, create, publish and schedule, paid campaigns, measure |
|
|
354
362
|
|
|
355
363
|
They are what every one-command install at the top of this page adds, in Claude Code, Codex, Gemini CLI and
|
|
356
364
|
through `npx skills add`. From a clone, copying works too:
|
|
@@ -361,6 +369,17 @@ cp -r skills/* ~/.claude/skills/
|
|
|
361
369
|
|
|
362
370
|
Then invoke `/hermoso-ad-from-brand an ad for yourbrand.com, our hero product`.
|
|
363
371
|
|
|
372
|
+
### Ask for it in your own words
|
|
373
|
+
|
|
374
|
+
The skills pick themselves. These are whole prompts, not commands:
|
|
375
|
+
|
|
376
|
+
- *Find my top 5 competitors for [product + URL], pull their best Meta and TikTok ads from the last 90 days, and tell me the 3 hooks and 2 formats worth stealing, with the evidence.*
|
|
377
|
+
- *Make this week's ads for [brand]: two hooks in two formats, one UGC and one product visual, 9:16. Score them and policy check them before I ship.*
|
|
378
|
+
- *Here is a competitor ad: [link]. Break down why it works, then rebuild the structure with my product, my branding and a fresh hook.*
|
|
379
|
+
- *Fill my social queue for the next 7 days across Instagram, TikTok, X and LinkedIn, repurposed from my best post, with per-channel captions.*
|
|
380
|
+
- *Take last week's winner and build a $20/day test campaign on Meta and TikTok, three ad sets, one angle each. Leave it paused and read the tree back so I can check it.*
|
|
381
|
+
- *Pull last month's campaign performance across every platform. Which two creatives won, why, and what should next month's brief say?*
|
|
382
|
+
|
|
364
383
|
## Configuration
|
|
365
384
|
|
|
366
385
|
| Env | Meaning |
|
package/mcp/http.mjs
CHANGED
|
@@ -226,6 +226,15 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
226
226
|
// resources are the ChatGPT Apps SDK ui:// widget templates (static HTML, zero spend) — so resources/read is
|
|
227
227
|
// pre-auth too, letting ChatGPT/scanners fetch the templates. tools/call remains strictly bearer-gated.
|
|
228
228
|
'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'triggers/list']);
|
|
229
|
+
// A HOST THAT DECIDES "NO AUTH" FROM A SUCCESSFUL HANDSHAKE MUST BE CHALLENGED ON IT (2026-09-18, measured).
|
|
230
|
+
// Grok's custom connectors (grok.com/connectors, client `grok-connectors-manager`) choose OAuth or no-auth when the
|
|
231
|
+
// connector is ADDED: our open discovery handshake answered 200, so it saved Hermoso as a no-auth connector, and
|
|
232
|
+
// every tool call after that got our 401 and was never followed to /.well-known/oauth-protected-resource — the
|
|
233
|
+
// chat said "I'll send a connect card" and none ever came. Its catalog connectors (Stripe, Notion, Vercel) work
|
|
234
|
+
// because their servers challenge the first request. So that client, and any caller that asks with
|
|
235
|
+
// `?auth=required`, gets the challenge instead of the anonymous preview; everyone else keeps discovery.
|
|
236
|
+
const signinUpfront = (req) => /^grok-connectors-manager\b/i.test(String(req.headers['user-agent'] || ''))
|
|
237
|
+
|| String(req.query?.auth || '').toLowerCase() === 'required';
|
|
229
238
|
const isAllPreauth = (body) => {
|
|
230
239
|
const arr = Array.isArray(body) ? body : [body];
|
|
231
240
|
const methods = arr.map((m) => m && m.method).filter((v) => typeof v === 'string');
|
|
@@ -234,10 +243,13 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
234
243
|
// `?tools=research,create` narrows the roster this connection advertises (see registerTools). Read here rather
|
|
235
244
|
// than inside registerTools so BOTH the anonymous discovery handshake and a real session honour the same query,
|
|
236
245
|
// and so an unknown group is refused at the door with the valid list instead of silently serving every group.
|
|
237
|
-
// ABSENT, an
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
246
|
+
// ABSENT, an authenticated session resolves to `defaultToolGroups` in tools.mjs, which is the FULL roster unless
|
|
247
|
+
// this process sets MCP_CORE_FIRST=1 — and production does not set it (measured 2026-09-20 off the Cloud Run
|
|
248
|
+
// service env). This comment used to say an authenticated session "resolves to the CORE-FIRST default" in one
|
|
249
|
+
// sentence and "the default is the full roster" in the next; the second one is the true one. When core-first IS
|
|
250
|
+
// on, the list is the core tools plus a few that make the connection drivable, with everything else held out of
|
|
251
|
+
// the LIST on size and reachable through find_tools + call_tool. `?tools=all` restores the full roster for one
|
|
252
|
+
// connection. The anonymous discovery path above is
|
|
241
253
|
// deliberately NOT core-first — see the comment on its registerTools call.
|
|
242
254
|
// The scope fixed here is the STARTING roster, not a cage: `enable_tools` widens it mid-session and the SDK
|
|
243
255
|
// notifies the client. That is deliberate — the old comment's "tools/list must not change under a live client"
|
|
@@ -292,7 +304,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
292
304
|
const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => null) : null;
|
|
293
305
|
if (!user) {
|
|
294
306
|
// No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
|
|
295
|
-
if (req.method === 'POST' && isAllPreauth(req.body)) {
|
|
307
|
+
if (req.method === 'POST' && isAllPreauth(req.body) && !signinUpfront(req)) {
|
|
296
308
|
const scope = scopeFor(req, res);
|
|
297
309
|
if (scope === false) return; // unknown group — already answered 400
|
|
298
310
|
return serveAnonDiscovery(req, res, scope).catch(() => { try { challenge(res); } catch {} });
|
package/mcp/tools.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
|
|
5
5
|
// needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
|
|
7
|
+
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -192,7 +192,7 @@ export const CAPABILITY_MAP = [
|
|
|
192
192
|
// SECOND LINE, deliberately: the map below is a menu, and a menu read as a sequence is the whole defect.
|
|
193
193
|
INDEPENDENCE,
|
|
194
194
|
'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) · fetch_social_data (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
|
|
195
|
-
'B) CREATE — finished, 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 brand workspaces — an agency runs every client through here; each has its own brand, 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) · clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
195
|
+
'B) CREATE — finished, 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 brand workspaces — an agency runs every client through here; each has its own brand, 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) · clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
196
196
|
'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.',
|
|
197
197
|
'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, plan and answer is written 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_team / invite_member / remove_member / set_role (who else can work in this brand).',
|
|
198
198
|
'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 brand 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 (revoke and drop a connection; 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_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · 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 brand 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) · 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 — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · 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 — the step that finishes the default publish flow; 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 + 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_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 brand 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 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 brand 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_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) · 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) · 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) · 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, and until now the one platform Hermoso could not moderate. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this brand can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 4 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 brand 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 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 brand 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 and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · 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_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_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 BRAND, 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 brand 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). 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) · 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) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
@@ -274,7 +274,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
274
274
|
'ACT ON THE REQUEST, DO NOT SURVEY IT: when the user asks for something to be made, make it. generate_image, generate_video and render_ad all run with `model` omitted, and an unnamed render goes to the server’s own default model, which is a sound general-purpose pick, so there is nothing you have to look up before rendering. Call hermoso_capabilities (free) when you actually need what it holds: a specific model id, an exact credit cost, a model’s live durations / aspect ratios / resolutions, or whether a capability is enabled on this account. Reporting the model catalog back is never the answer to a request to create something.',
|
|
275
275
|
'Capability map:',
|
|
276
276
|
'• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
|
|
277
|
-
'• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
|
|
277
|
+
'• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
278
278
|
'• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
|
|
279
279
|
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here, each with its own brand, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
|
|
280
280
|
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by pasting an API key; ONE creative format, a text plus image card — no video; Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder (full CRUD over the user’s OneDrive); MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this brand may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). Full read+write control over the user’s own channels, not just generation. LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
@@ -1892,7 +1892,7 @@ export const DEFAULT_TOOL_GROUPS = TOOL_GROUP_NAMES.filter((g) => !OPT_IN_TOOL_G
|
|
|
1892
1892
|
// ── CORE-FIRST IS THE DEFAULT ROSTER NOW (2026-09-17) ──────────────────────────────────────────────────────────
|
|
1893
1893
|
//
|
|
1894
1894
|
// THE MEASUREMENT THAT DECIDED IT. Running this file's own registerTools and sizing each tool the way a host
|
|
1895
|
-
// receives it: the whole registry is
|
|
1895
|
+
// receives it: the whole registry is larger still (tools/mcp-parity.mjs prints the measured count); the roster above (every group but the three opt-in ones) is what
|
|
1896
1896
|
// every authenticated session was handed on tools/list, and four tools in it — create_meta_ad, schedule_post,
|
|
1897
1897
|
// create_meta_adset, reschedule_post — weigh more than fourteen core rosters. Re-measure with
|
|
1898
1898
|
// `node tools/core-first-roster-check.mjs`, never off this comment: the numbers move with every tool added.
|
|
@@ -2052,6 +2052,7 @@ export const WITHHELD_FROM_DIRECTORY = new Set([
|
|
|
2052
2052
|
'generate_image', 'generate_video', 'generate_avatar', 'generate_voice', 'render_ad', 'plan_ad', 'plan_variations',
|
|
2053
2053
|
'make_thumbnail', 'make_explainer', 'product_sizzle', 'dub_video', 'change_voice', 'edit_video', 'recast_motion',
|
|
2054
2054
|
'upscale_video', 'reframe_video', 'multiply_ad', 'clone_static', 'remix_static', 'stitch_video', 'fix_beat',
|
|
2055
|
+
'hook_variants',
|
|
2055
2056
|
]);
|
|
2056
2057
|
// TWO DIRECTORY MODES (Dave, 2026-09-02, after the directory turned out to list Tofu Ads — an AI ad-image generator
|
|
2057
2058
|
// — under the policy's design-asset carve-out): `directory` is the fully scoped cage above; `directory-full` keeps
|
|
@@ -3112,6 +3113,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3112
3113
|
captions: z.array(z.record(z.any())).optional().describe("Up to 20 WebVTT caption tracks: [{lang:'en', url:'https://…/en.vtt'}] or [{lang:'en', content:'WEBVTT\\n\\n00:00…'}]. Each file is capped at 20000 bytes."),
|
|
3113
3114
|
langs: z.array(z.string()).optional().describe("BCP-47 language tags, e.g. ['en']."),
|
|
3114
3115
|
linkCard: z.union([z.boolean(), z.record(z.any())]).optional().describe('Rich link card (`app.bsky.embed.external`). OMIT for the default (a card is built automatically when the post has a URL and no media). `false` never builds one. `true` builds one from the first URL in the text. An object {uri,title,description,thumbUrl} overrides any field — supply BOTH title and description and the page is never fetched. Cannot be combined with imageUrls/videoUrl: a post has ONE embed, so an explicit linkCard beside media is refused.'),
|
|
3116
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Bluesky 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 Bluesky 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.'),
|
|
3115
3117
|
},
|
|
3116
3118
|
outputSchema: { url: z.string().optional(), uri: z.string().optional(), handle: z.string().optional(), note: z.string() },
|
|
3117
3119
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -3168,6 +3170,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3168
3170
|
videoUrl: z.string().optional().describe('one video (≤50MB). Passed alongside imageUrls it joins the album as one more item.'),
|
|
3169
3171
|
disablePreview: z.boolean().optional().describe('suppress the link-preview card on a text-only post. Default is Telegram’s own behaviour (previews on).'),
|
|
3170
3172
|
silent: z.boolean().optional().describe('deliver without a notification sound (Telegram’s disable_notification). This is NOT a visibility setting — the message is just as visible.'),
|
|
3173
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (Telegram\u2019s in-chat video cover). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
3171
3174
|
},
|
|
3172
3175
|
outputSchema: { ok: z.boolean().optional(), chatId: z.string().optional(), chatTitle: z.string().optional(), messageId: z.number().optional(), url: z.string().nullable().optional(), album: z.boolean().optional(), slides: z.number().optional(), video: z.boolean().optional(), note: z.string().optional() },
|
|
3173
3176
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -4595,6 +4598,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4595
4598
|
// Instagram" as a fact.
|
|
4596
4599
|
crossreshareToIg: z.boolean().optional().describe('THREADS ONLY — ALSO share this Threads post to the linked Instagram account AS A STORY (not a feed post), in the same publish. NOT available on a Threads CAROUSEL, which is refused by name rather than silently dropped. THERE IS NO CONFIRMATION: Threads returns no field saying whether the Story was created, so report it as REQUESTED and tell the user to check their Instagram Stories — never that it is live.'),
|
|
4597
4600
|
crossreshareDarkMode: z.boolean().optional().describe('THREADS ONLY — render that Instagram Story in dark mode. Only meaningful alongside crossreshareToIg; on its own it is refused rather than silently ignored, because a parameter that never reaches the wire must not look accepted.'),
|
|
4601
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (Instagram Reel: thumb_offset; Facebook video/Reel: an uploaded cover image) \u2014 and on Threads, which has no cover setting, a blank first frame is replaced on a copy sent to Threads only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
4598
4602
|
},
|
|
4599
4603
|
outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), url: z.string().optional(), target: z.string().optional(), page: z.string().optional(), account: z.string().optional() },
|
|
4600
4604
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -4675,7 +4679,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4675
4679
|
disableComment: z.boolean().optional().describe('TIKTOK — turn comments off on this post.'),
|
|
4676
4680
|
disableDuet: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Duets. TikTok’s photo-post API has no Duets, so this is refused on a photo/slideshow item rather than silently dropped.'),
|
|
4677
4681
|
disableStitch: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet.'),
|
|
4678
|
-
coverTimestampMs: z.number().optional().describe('TIKTOK VIDEO ONLY — which frame TikTok uses as the cover, in milliseconds from the start. Omit and TikTok uses the first frame.'),
|
|
4682
|
+
coverTimestampMs: z.number().optional().describe('TIKTOK VIDEO ONLY — which frame TikTok uses as the cover, in milliseconds from the start. Omit and Hermoso uses the video\u2019s best frame (platformCover:true leaves it to TikTok, which uses the first frame).'),
|
|
4679
4683
|
topicType: z.enum(['STANDARD', 'EVENT', 'OFFER', 'ALERT']).optional().describe('GOOGLE BUSINESS — the KIND of Post. STANDARD is the default; EVENT and OFFER both REQUIRE `event` (title + start date), and OFFER also takes `offer`.'),
|
|
4680
4684
|
actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE.'),
|
|
4681
4685
|
event: z.object({ title: z.string().optional(), startDate: z.string().optional(), startTime: z.string().optional(), endDate: z.string().optional(), endTime: z.string().optional() }).optional().describe('GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:"YYYY-MM-DD", endDate, startTime:"HH:MM", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them.'),
|
|
@@ -4729,6 +4733,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4729
4733
|
visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional().describe("how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason."),
|
|
4730
4734
|
visibilityByChannel: z.record(z.string()).optional().describe('override visibility for one channel, e.g. { "tiktok": "draft" } to go live everywhere but stage TikTok for review'),
|
|
4731
4735
|
optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.'),
|
|
4736
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover on every channel that allows one (Instagram, Facebook, TikTok direct posts, LinkedIn Pages, Pinterest, Telegram, YouTube) \u2014 and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
4732
4737
|
},
|
|
4733
4738
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), label: z.string().optional() },
|
|
4734
4739
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -4887,6 +4892,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4887
4892
|
locationId: z.string().optional().describe('GOOGLE BUSINESS — a different listing (list_business_locations)'),
|
|
4888
4893
|
visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional().describe('NOTE: changing this without also naming visibilityByChannel clears any per-channel overrides, so "make it all draft" is not a no-op'),
|
|
4889
4894
|
visibilityByChannel: z.record(z.string()).optional(),
|
|
4895
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover on every channel that allows one \u2014 and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
4890
4896
|
},
|
|
4891
4897
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), visibility: z.string().optional() },
|
|
4892
4898
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -5084,6 +5090,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5084
5090
|
quotePostId: z.string().optional().describe('numeric id of a post to QUOTE \u2014 X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post\u2019s t.co URL whatever your text says. Same X rule as replyToId: a quote of a post that does not mention this account is refused by X.'),
|
|
5085
5091
|
communityId: z.string().optional().describe('publish into an X COMMUNITY instead of the main timeline \u2014 the number in the community\u2019s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
|
|
5086
5092
|
paidPartnership: z.boolean().optional().describe('label the post a PAID PARTNERSHIP on X \u2014 the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user\u2019s behalf.'),
|
|
5093
|
+
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.'),
|
|
5087
5094
|
},
|
|
5088
5095
|
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(), 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() },
|
|
5089
5096
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -5371,6 +5378,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5371
5378
|
slideText: z.array(z.object({ title: z.string().optional(), description: z.string().optional(), link: z.string().optional() })).optional().describe('PINTEREST CAROUSEL ONLY \u2014 per-slide title, description and destination LINK, one object per slide in slide order. This is the ONLY genuine per-slide caption on any channel Hermoso publishes to: slide 4 can send people to the product ON slide 4, where every other platform gives a carousel one shared caption. Omit any field to leave it unset; the Pin\u2019s own title/description/link still describe the Pin as a whole. Pinterest publishes no length limit on these, so nothing is truncated. More entries than slides is refused rather than dropped.'),
|
|
5372
5379
|
coverImageUrl: z.string().optional().describe('video Pins only — a render to use as the cover frame'),
|
|
5373
5380
|
boardSectionId: z.string().optional().describe('optional section within the board'),
|
|
5381
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (Pinterest\u2019s cover key frame, to the whole second). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
5374
5382
|
},
|
|
5375
5383
|
outputSchema: { ok: z.boolean().optional(), id: z.string().nullable().optional(), url: z.string().nullable().optional(), boardId: z.string().optional(), title: z.string().optional(), kind: z.string().optional() },
|
|
5376
5384
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -5756,6 +5764,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5756
5764
|
publishAt: z.string().optional().describe('SCHEDULE the publish — ISO 8601, e.g. "2026-09-01T15:00:00Z", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:"private" (or leave privacy unset) — asking for a scheduled "unlisted" or "public" post is refused rather than half-honoured.'),
|
|
5757
5765
|
notifySubscribers: z.boolean().optional().describe('THE DEFAULT FOLLOWS PRIVACY. privacy:"public" NOTIFIES the channel\'s subscribers — that is YouTube\'s own default and normally what someone publishing publicly wants. privacy:"unlisted" and "private" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody\'s whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why.'),
|
|
5758
5766
|
aiGenerated: z.boolean().optional().describe('YouTube\u2019s \u201caltered or synthetic content\u201d declaration (containsSyntheticMedia). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video the user uploaded through upload_file or from an external URL is NOT \u2014 real footage must not carry the label. true/false overrides.'),
|
|
5767
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (YouTube custom thumbnail; the same as thumbnailUrl:"auto" when true). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
5759
5768
|
},
|
|
5760
5769
|
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), url: z.string().optional(), privacy: z.string().optional(), requestedPrivacy: z.string().optional(), categoryId: z.string().optional(), categoryName: z.string().optional(), publishAt: z.string().optional(), scheduled: z.boolean().optional(), notifySubscribers: z.boolean().optional(), notifyNote: z.string().optional(), warning: z.string().optional(), scheduleWarning: z.string().optional(), thumbnailSet: z.boolean().optional(), thumbnailSource: z.string().nullable().optional(), thumbnailReadBack: z.string().nullable().optional(), thumbnailNote: z.string().optional(), title: z.string().optional() },
|
|
5761
5770
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -6528,6 +6537,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6528
6537
|
aiGenerated: z.boolean().optional().describe('TikTok\u2019s is_aigc AI-generated-content label (video posts). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video that came through upload_file or an external URL (the user\u2019s own footage) is NOT. true/false overrides.'),
|
|
6529
6538
|
brandedContent: z.boolean().optional().describe('discloses a paid partnership — cannot be combined with SELF_ONLY privacy'),
|
|
6530
6539
|
yourBrand: z.boolean().optional().describe('discloses that this promotes the creator’s own brand'),
|
|
6540
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (TikTok video_cover_timestamp_ms, on a direct post \u2014 a draft takes no cover, you pick it in the TikTok app). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
6531
6541
|
},
|
|
6532
6542
|
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional() },
|
|
6533
6543
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -7125,6 +7135,34 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7125
7135
|
const lines = (d.items || []).map(o => `• ${o.name} (${o.id}) — ${o.effective_status || o.status}${o.dailyBudgetUsd ? `, $${o.dailyBudgetUsd}/day` : ''}${o.objective ? `, ${o.objective}` : ''}`);
|
|
7126
7136
|
return ok(`${d.count} ${d.level}${d.count === 1 ? '' : 's'}:\n${lines.join('\n') || '(none)'}`, d);
|
|
7127
7137
|
}));
|
|
7138
|
+
// ---------- CAMPAIGN ANALYST ----------
|
|
7139
|
+
// The Monday-morning question, answered once across every connected ad platform instead of eleven times.
|
|
7140
|
+
// Read-only by construction: it names the tool call that would apply each recommendation and calls none of them.
|
|
7141
|
+
server.group('ads');
|
|
7142
|
+
server.registerTool('analyze_campaigns', {
|
|
7143
|
+
title: 'What to scale, pause, fix and test next',
|
|
7144
|
+
description: 'ONE verdict across EVERY ad platform this workspace has connected. It pulls each platform\'s own report (Meta, Google Ads, ChatGPT Ads, TikTok, LinkedIn, Microsoft Advertising, Pinterest, Reddit, Snapchat, X, Apple Ads), normalises every campaign / ad set / ad into ONE table — spend, impressions, clicks, CTR, CPC, conversions, CPA, ROAS and frequency where the platform reports them — and answers with four lists: `scale`, `pause`, `fix` and `test_next`, plus `insufficient_data`. Each recommendation carries the row\'s OWN numbers as evidence, the reason, a concrete next action, and the EXACT Hermoso tool call that would apply it (for example set_meta_campaign_status with confirm) — which this tool never runs. FOUR REFUSALS TO REPEAT RATHER THAN PAPER OVER: (1) A ROW BELOW THE DATA FLOOR IS NEVER JUDGED — the minimums are stated in the reply, and an under-powered row lands in insufficient_data with what it lacks, never in "pause"; a row whose conversions are too few for a CPA verdict can still be read on CTR and CPC and says so. (2) A PLATFORM WHOSE READ FAILED IS NAMED AS UNREADABLE and its numbers are MISSING, never zero — never move budget on the strength of an absence. (3) A META AD SET IN THE LEARNING PHASE IS HELD BACK, because editing it restarts learning. (4) A CONVERSION COLUMN THE PLATFORM DOES NOT PUBLISH READS UNMEASURED, not zero (Reddit, Snapchat and X in this report). Conversion definitions differ per platform and travel with every row, and each platform\'s days are its own account\'s days, so a cross-platform CPA comparison must carry that caveat. Args: days (complete days ending yesterday, default 14, max 90), platforms (omit for every connected one), goal ("lowest CPA", "ROAS", "leads under $30"), level (campaign | ad_group | ad — a platform with no report at that tier is read at campaign level and says so). Reads only; spends no ad money and no scrape credits, and bills one model call over the table.',
|
|
7145
|
+
inputSchema: {
|
|
7146
|
+
days: z.number().optional().describe('how many complete days back, ending yesterday (default 14, max 90)'),
|
|
7147
|
+
platforms: z.array(z.string()).optional().describe('meta, google_ads, openai_ads, tiktok_ads, linkedin, microsoft_ads, pinterest_ads, reddit_ads, snapchat_ads, x_ads, apple_ads — omit for every connected ad platform'),
|
|
7148
|
+
goal: z.string().optional().describe('what to optimise for, in the user\'s own words'),
|
|
7149
|
+
level: z.enum(['campaign', 'ad_group', 'ad']).optional().describe('default campaign'),
|
|
7150
|
+
},
|
|
7151
|
+
outputSchema: {
|
|
7152
|
+
window: z.any().optional(), level: z.string().optional(), goal: z.string().nullable().optional(),
|
|
7153
|
+
platforms: z.array(z.any()).optional().describe('every platform READ, with its row count, its day boundary and its conversion definition'),
|
|
7154
|
+
unreadable: z.array(z.any()).optional().describe('platforms that could not be read, BY NAME — their numbers are missing, never zero'),
|
|
7155
|
+
rows: z.array(z.any()).optional().describe('the normalised table, each row with its ref, metrics and data verdict'),
|
|
7156
|
+
benchmarks: z.array(z.any()).optional(), thresholds: z.any().optional(),
|
|
7157
|
+
analysis: z.any().optional().describe('{summary, scale[], pause[], fix[], test_next[], insufficient_data[], moved[], dropped[]} — each recommendation with evidence, reason, next_action and the apply call'),
|
|
7158
|
+
text: z.string().optional(), note: z.string().optional(), notes: z.array(z.string()).optional(),
|
|
7159
|
+
},
|
|
7160
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
7161
|
+
}, wrap(async (a) => {
|
|
7162
|
+
const d = await apiPost('/api/ads/analyze', a);
|
|
7163
|
+
if (!d.analysis) return ok(d.note || 'Nothing delivered in that window.', d);
|
|
7164
|
+
return ok(d.text, d);
|
|
7165
|
+
}));
|
|
7128
7166
|
server.registerTool('meta_insights', {
|
|
7129
7167
|
title: 'Meta ad performance metrics',
|
|
7130
7168
|
description: 'Pull performance INSIGHTS (spend, impressions, reach, clicks, CTR, CPC, CPM, conversions) for a connected ad account, or a specific campaign / ad set / ad. Pass adAccountId (for auth); optionally objectId to scope to one object and level to break the numbers down. 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" / "dma" (where), "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.',
|
|
@@ -11746,7 +11784,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11746
11784
|
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.'),
|
|
11747
11785
|
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.'),
|
|
11748
11786
|
excludedCustomAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s excluded custom audiences — same wholesale rule as customAudienceIds.'),
|
|
11749
|
-
conversionEventSettingIds: z.array(z.string()).optional().describe('
|
|
11787
|
+
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.'),
|
|
11750
11788
|
contextHints: z.array(z.string()).optional().describe('REPLACES the existing list'),
|
|
11751
11789
|
maxBid: z.number().optional(), billingEvent: z.enum(['click', 'impression']).optional().describe('required alongside maxBid — bidding is replaced wholesale'),
|
|
11752
11790
|
bidStrategy: z.enum(['fixed_bid', 'maximize_clicks', 'maximize_conversions']).optional().describe('CHANGE HOW THIS AD GROUP BIDS (OpenAI’s “Maximize results”). BIDDING IS REPLACED WHOLESALE, so a patch that moves the bid without restating the strategy would DEMOTE a maximize_* ad group to a fixed bid, and one that sets a strategy without restating maxBid DELETES the cap — both are refused by name with what would have been lost.'),
|
|
@@ -15457,6 +15495,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15457
15495
|
videoThumbnailUrl: z.string().optional().describe('the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user\u2019s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card.'),
|
|
15458
15496
|
visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
|
|
15459
15497
|
targetAudience: z.object({ geoLocations: z.array(z.string()).optional(), industries: z.array(z.string()).optional(), seniorities: z.array(z.string()).optional(), jobFunctions: z.array(z.string()).optional(), staffCountRanges: z.array(z.enum(['SIZE_1', 'SIZE_2_TO_10', 'SIZE_11_TO_50', 'SIZE_51_TO_200', 'SIZE_201_TO_500', 'SIZE_501_TO_1000', 'SIZE_1001_TO_5000', 'SIZE_5001_TO_10000', 'SIZE_10001_OR_MORE'])).optional(), degrees: z.array(z.string()).optional(), fieldsOfStudy: z.array(z.string()).optional(), organizations: z.array(z.string()).optional() }).optional().describe('LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets (URNs or bare numeric ids; search_linkedin_ads_targeting finds them). LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted.'),
|
|
15498
|
+
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (LinkedIn\u2019s video thumbnail upload). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
15460
15499
|
},
|
|
15461
15500
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), organizationId: z.string().optional(), videoExtras: z.object({ captions: z.boolean().optional(), thumbnail: z.boolean().optional() }).optional(), note: z.string().optional() },
|
|
15462
15501
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -16585,6 +16624,84 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16585
16624
|
return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}${switchNote({ raw: d })}${d.productNote ? `\n${d.productNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
16586
16625
|
}));
|
|
16587
16626
|
|
|
16627
|
+
// ---------- Static-ad edits + variants (2026-09-18) ----------
|
|
16628
|
+
// edit_image was a Studio-only tool; headline/resize/localize are the static presets of the "Paid Ads" bundle.
|
|
16629
|
+
// Each takes the FINISHED ad image (a URL, a Library item, an upload_file URL or a local path) and returns new
|
|
16630
|
+
// images; the source is never touched. Every batch is priced before it runs and refused for free when the
|
|
16631
|
+
// balance cannot cover it.
|
|
16632
|
+
const staticItemsText = (d, label) => {
|
|
16633
|
+
const items = Array.isArray(d.items) ? d.items : [];
|
|
16634
|
+
const rows = items.map((x, i) => `${i + 1}. ${x[label] || ''}${x.image ? ` — ${abs(x.image)}` : ` — not delivered: ${x.error || 'failed'}`}${x.textCheck && x.textCheck.ok === false ? `\n ⚠ ${x.textCheck.note}` : ''}`);
|
|
16635
|
+
return `${rows.join('\n')}\n${d.note || ''}`;
|
|
16636
|
+
};
|
|
16637
|
+
const staticImages = async (d) => (await Promise.all((Array.isArray(d.items) ? d.items : []).filter(x => x.image).slice(0, 4).map(x => imageBlock(abs(x.image))))).filter(Boolean);
|
|
16638
|
+
const staticItemsOut = z.array(z.any()).optional().describe('one entry per output: the image URL (or an error), what changed, and a textCheck flag when the rendered text may not match');
|
|
16639
|
+
server.registerTool('edit_image', {
|
|
16640
|
+
title: 'Edit an image',
|
|
16641
|
+
description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's ✎ Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo is attached when the instruction asks for the logo. Set removal:true when the edit STRIPS text, branding or an object, so nothing branded is put back. For a precise region, pass `mask` (see generate_image). One image edit's credits; returns the new image URL. For a new image from a prompt use generate_image; to rebuild a competitor's ad for your brand use clone_static.",
|
|
16642
|
+
inputSchema: {
|
|
16643
|
+
image: z.string().describe('the image to edit: URL, Library item URL, upload_file URL or local path'),
|
|
16644
|
+
instruction: z.string().describe('the change to make, in plain words (pass the user’s own words for a removal or plain photo edit)'),
|
|
16645
|
+
removal: z.boolean().optional().describe('true when the edit REMOVES text, branding, a logo, a watermark, a person or an object, so the brand name and logo are not re-added'),
|
|
16646
|
+
mask: z.string().optional().describe('optional mask image (URL or local path) marking the region to change: transparent = change, or white = change on an opaque mask'),
|
|
16647
|
+
},
|
|
16648
|
+
outputSchema: {
|
|
16649
|
+
image: z.string().optional().describe('the served absolute URL of the edited image'),
|
|
16650
|
+
model: z.string().optional().describe('the model label that rendered it'),
|
|
16651
|
+
},
|
|
16652
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16653
|
+
}, wrap(async ({ image, instruction, removal, mask }) => {
|
|
16654
|
+
const src = await toRef(image);
|
|
16655
|
+
const maskRef = mask ? await toRef(mask) : undefined;
|
|
16656
|
+
const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}) });
|
|
16657
|
+
const img = await imageBlock(abs(d.image));
|
|
16658
|
+
return { content: [{ type: 'text', text: `Edited image: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
16659
|
+
}));
|
|
16660
|
+
server.registerTool('headline_variants', {
|
|
16661
|
+
title: 'Headline variants of a static ad',
|
|
16662
|
+
description: "Turn ONE finished static ad into several copies that differ ONLY in the headline, for an A/B test: same picture, product, layout, colours and every other line. Pass `image`, and either `headlines` (your own, up to 10) or `count` (default 5, max 10) to have distinct angles written for you in the saved brand's voice (never inventing numbers, prices, ratings or claims the ad or brand does not state); `brief` steers what to test. The ad's text is read first (3 credits), then one image edit per headline; each output is proofread and flagged (textCheck) if the rendered words do not match, never silently re-rendered. The whole batch is priced before anything runs. Returns each headline, its angle and its image URL.",
|
|
16663
|
+
inputSchema: {
|
|
16664
|
+
image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
|
|
16665
|
+
headlines: z.array(z.string()).optional().describe('your own headlines to test (up to 10); omit to have them written'),
|
|
16666
|
+
count: z.number().int().min(1).max(10).optional().describe('how many headlines to write when `headlines` is omitted (default 5)'),
|
|
16667
|
+
brief: z.string().optional().describe('what to test, e.g. "price-led vs outcome-led" or "speak to busy parents"'),
|
|
16668
|
+
},
|
|
16669
|
+
outputSchema: { items: staticItemsOut, note: z.string().optional(), original: z.string().optional().describe('the headline read off the source ad') },
|
|
16670
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16671
|
+
}, wrap(async ({ image, headlines, count, brief }) => {
|
|
16672
|
+
const d = await apiPost('/api/static/headlines', { image: await toRef(image), ...(headlines?.length ? { headlines } : {}), ...(count ? { count } : {}), ...(brief ? { brief } : {}) });
|
|
16673
|
+
const imgs = await staticImages(d);
|
|
16674
|
+
return { content: [{ type: 'text', text: `Headline variants (original: “${d.original || ''}”):\n${staticItemsText(d, 'headline')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
|
|
16675
|
+
}));
|
|
16676
|
+
server.registerTool('resize_ad', {
|
|
16677
|
+
title: 'Resize a static ad for other placements',
|
|
16678
|
+
description: "Re-lay out ONE finished static ad for other placements: the same ad, product, copy (word for word), logo and style, recomposed natively for each canvas rather than cropped. Pass `image` and optionally `aspectRatios` from 1:1, 4:5, 9:16, 16:9, 3:4, 4:3 (default 1:1, 4:5 and 9:16; the ad's own ratio is skipped). Reads the ad's text first (3 credits) so every line survives, then one image edit per canvas. Priced before it runs. For VIDEO use reframe_video.",
|
|
16679
|
+
inputSchema: {
|
|
16680
|
+
image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
|
|
16681
|
+
aspectRatios: z.array(z.enum(['1:1', '4:5', '9:16', '16:9', '3:4', '4:3'])).optional().describe('target canvases (default 1:1, 4:5, 9:16)'),
|
|
16682
|
+
},
|
|
16683
|
+
outputSchema: { items: staticItemsOut, note: z.string().optional(), sourceAspectRatio: z.string().optional() },
|
|
16684
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16685
|
+
}, wrap(async ({ image, aspectRatios }) => {
|
|
16686
|
+
const d = await apiPost('/api/static/resize', { image: await toRef(image), ...(aspectRatios?.length ? { aspectRatios } : {}) });
|
|
16687
|
+
const imgs = await staticImages(d);
|
|
16688
|
+
return { content: [{ type: 'text', text: `Resized (source ${d.sourceAspectRatio || '?'}):\n${staticItemsText(d, 'aspectRatio')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
|
|
16689
|
+
}));
|
|
16690
|
+
server.registerTool('localize_ad', {
|
|
16691
|
+
title: 'Localize a static ad into other languages',
|
|
16692
|
+
description: "Translate the on-image text of ONE finished static ad into other languages and keep everything else: same picture, layout, typeface, colours, logo and product. Pass `image` and `languages` (up to 5, e.g. [\"Spanish\", \"German\", \"French (Canada)\"]). The ad's text is read (3 credits), translated the way a native copywriter in each market would write it (brand and product names, URLs and prices kept as written), then one image edit per language; each output is proofread and flagged (textCheck) if the words do not match, never silently re-rendered. Priced before it runs. For a VIDEO use dub_video.",
|
|
16693
|
+
inputSchema: {
|
|
16694
|
+
image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
|
|
16695
|
+
languages: z.array(z.string()).min(1).max(5).describe('target languages, by name'),
|
|
16696
|
+
},
|
|
16697
|
+
outputSchema: { items: staticItemsOut, note: z.string().optional(), source: z.array(z.string()).optional().describe('the lines read off the source ad') },
|
|
16698
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16699
|
+
}, wrap(async ({ image, languages }) => {
|
|
16700
|
+
const d = await apiPost('/api/static/localize', { image: await toRef(image), languages });
|
|
16701
|
+
const imgs = await staticImages(d);
|
|
16702
|
+
return { content: [{ type: 'text', text: `Localized:\n${staticItemsText(d, 'language')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
|
|
16703
|
+
}));
|
|
16704
|
+
|
|
16588
16705
|
// ---------- YouTube / social thumbnails + video covers ----------
|
|
16589
16706
|
server.group('create');
|
|
16590
16707
|
server.registerTool('make_thumbnail', {
|
|
@@ -19121,6 +19238,43 @@ function memoryNoteVerdict(text) {
|
|
|
19121
19238
|
return { content: [{ type: 'text', text: `Multiplying — ${jobs.length} variant(s) queued, original audio kept on all of them. ${quote}.\n${jobs.map((j, i) => `${i + 1}. ${j.label} → job ${j.id}`).join('\n')}\nEach variant is one keyframe edit plus a motion transfer of the whole source (2-6 minutes) and comes back the SAME length as the source. 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.totalCredits } };
|
|
19122
19239
|
}));
|
|
19123
19240
|
|
|
19241
|
+
// HOOK MULTIPLIER (2026-09-18): ONE finished video ad → N complete versions with NEW opening hooks, the rest of the ad and
|
|
19242
|
+
// its whole soundtrack untouched. One server call plans (a small paid read) and queues one `fixbeat` job per version
|
|
19243
|
+
// (the fix_beat worker at 0s: the hook renders silent and is spliced on the picture only). lib/hook-variants.mjs.
|
|
19244
|
+
server.registerTool('hook_variants', {
|
|
19245
|
+
title: 'New opening hooks for a video',
|
|
19246
|
+
description: "HOOK MULTIPLIER: give ONE finished video ad N NEW OPENING HOOKS and get N complete edited versions to A/B test. WHAT CHANGES: a new opening shot over roughly the first 1.5-4 seconds (the hook ends at the source's first shot cut in that range, else at 3s; hookSeconds overrides). WHAT STAYS: everything after that point is the original footage, and the ENTIRE original soundtrack (voiceover, music, sound) plays under every version unchanged, so every version is the SAME length, aspect ratio and resolution as the source. Because the audio is kept, each hook is a VISUAL hook built to play under the words the source already says there: nobody in it talks to camera, it carries no on-screen text, and it does NOT write a new spoken hook line. Every version uses a DIFFERENT named hook mechanic chosen for the product (open mid-problem, before/after snap, object into frame, satisfying macro, pattern interrupt, POV, whip/snap-zoom, unexpected place, countdown to reveal); list_hooks describes them. Pass the video's file URL (a previous render, a job result, list_library, or upload_file for a local file). 1-5 versions, default 3. REFUSED FOR FREE, before anything is billed: a source over 120 seconds (trim it with post_edit first), one too short to leave 2 seconds of the original after a 1.5 second hook, an unreadable file, or a link to a social post rather than a video file (use clone_video to remake someone else's ad). COST: a small planning read, then each version is billed like fix_beat for the hook's seconds; the reply quotes credits per version, and dryRun:true returns the plan and the quote without rendering (pass that `plan` back to render exactly those hooks without planning again). Returns ONE JOB PER VERSION; call get_job on each until it reports done, and never describe a version before its URL arrives. Uses the workspace brand's product photo as a reference in hooks that show the product (productImage overrides; useBrand:false sends none).",
|
|
19247
|
+
inputSchema: {
|
|
19248
|
+
video: z.string().describe('the finished video to give new hooks: its served file URL'),
|
|
19249
|
+
count: z.number().optional().describe('how many hook versions, 1-5 (default 3)'),
|
|
19250
|
+
notes: z.string().optional().describe('anything the hooks must respect, e.g. "keep it calm", "show the product in every hook"'),
|
|
19251
|
+
hookSeconds: z.number().optional().describe('where the CURRENT hook ends, in seconds (1.5-4). Omit to use the first shot cut.'),
|
|
19252
|
+
resolution: z.enum(['480p', '720p', '1080p']).optional().describe('render tier for the new opening. Defaults to the source’s OWN tier so the hook matches the rest of the ad; a lower tier costs a lot less and is scaled into the source’s canvas (visibly softer for the first seconds). dryRun quotes whichever you pick.'),
|
|
19253
|
+
productImage: z.string().optional().describe('product photo URL used as a reference in hooks that show the product (defaults to the workspace brand’s first product photo)'),
|
|
19254
|
+
useBrand: z.boolean().optional().describe('false = send no brand name or product photo (for a video that is not this workspace brand’s)'),
|
|
19255
|
+
plan: z.any().optional().describe('the `plan` object a previous dryRun returned, to render exactly those hooks without planning again'),
|
|
19256
|
+
dryRun: z.boolean().optional().describe('true = return the plan and the quote, render nothing'),
|
|
19257
|
+
},
|
|
19258
|
+
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), hookSeconds: z.number().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
|
|
19259
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
19260
|
+
}, wrap(async ({ video, count, notes, hookSeconds, resolution, productImage, useBrand, plan, dryRun }) => {
|
|
19261
|
+
const src = String(video || '').trim();
|
|
19262
|
+
if (!src) return { content: [{ type: 'text', text: 'Pass the finished video as a URL: a previous render, a job result, an entry from list_library, or an upload_file link.' }], isError: true };
|
|
19263
|
+
let b = {};
|
|
19264
|
+
if (useBrand !== false) { try { b = (await readStore('heist.brand.v1')) || {}; } catch { b = {}; } }
|
|
19265
|
+
const prod = String(productImage || '').trim() || (useBrand !== false && Array.isArray(b.productImages) ? String(b.productImages[0] || '') : '');
|
|
19266
|
+
const body = { video: src, ...(count != null ? { count } : {}), ...(notes ? { notes } : {}), ...(hookSeconds != null ? { hookSeconds } : {}), ...(resolution ? { resolution } : {}), ...(prod ? { productImage: prod } : {}), ...(useBrand !== false && b.name ? { brand: { name: b.name, sells: b.sells || b.description || '' } } : {}), ...(plan ? { plan } : {}), ...(dryRun ? { dryRun: true } : {}) };
|
|
19267
|
+
const r = await apiPost('/api/hooks/variants', body);
|
|
19268
|
+
const p = r?.data || r;
|
|
19269
|
+
const vs = Array.isArray(p.variants) ? p.variants : [];
|
|
19270
|
+
const lines = vs.map((v, i) => `${i + 1}. ${v.label} [${v.mechanicLabel || v.mechanic}]: ${v.shot}`);
|
|
19271
|
+
const head = `Source: ${p.source?.durationSeconds}s at ${p.source?.width}x${p.source?.height}. New hooks replace 0-${p.hookSeconds}s (${p.cutAligned ? 'ending on the source’s own first cut' : 'no cut in range, so the hook ends mid-shot'}); the rest of the video and all of its audio stay as they are.`;
|
|
19272
|
+
const quote = `~${p.perVariantCredits} credits per version · ~${p.totalCredits} for ${(p.jobs || []).length || vs.length}`;
|
|
19273
|
+
if (dryRun) return { content: [{ type: 'text', text: `Plan (nothing rendered). ${head}\n${lines.join('\n')}\n${quote}. Run again with plan set to this plan (and no dryRun) to render exactly these.${p.note ? '\nNOTE: ' + p.note : ''}` }], structuredContent: { plan: p.plan, hookSeconds: p.hookSeconds, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits, dryRun: true } };
|
|
19274
|
+
const jobs = (p.jobs || []).map(j => ({ id: j.id, label: j.label, mechanic: j.mechanic }));
|
|
19275
|
+
return { content: [{ type: 'text', text: `${head}\nQueued ${jobs.length} hook version${jobs.length === 1 ? '' : 's'}. ${quote}.\n${jobs.map((j, i) => `${i + 1}. ${j.label} [${vs[i]?.mechanicLabel || j.mechanic}] → job ${j.id}`).join('\n')}\nEach renders the new opening and splices it in (usually 2-5 minutes). Call get_job with each id until it reports done; a version has NO file until then.${p.note ? '\nNOTE: ' + p.note : ''}` }], structuredContent: { jobs, plan: p.plan, hookSeconds: p.hookSeconds, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits } };
|
|
19276
|
+
}));
|
|
19277
|
+
|
|
19124
19278
|
server.registerTool('dub_video', {
|
|
19125
19279
|
title: 'Dub video',
|
|
19126
19280
|
description: "Localize a finished video into another language WITHOUT re-rendering it: the spoken track is transcribed, translated, re-voiced and lip-synced back onto the SAME footage, so the visuals, timing and edit are untouched. Just pass the video and the language — the script is read off the source automatically (pass `script` only to override what it heard). Paid; returns the served URL of the localized video.",
|
|
@@ -19314,24 +19468,42 @@ function memoryNoteVerdict(text) {
|
|
|
19314
19468
|
|
|
19315
19469
|
server.registerTool('mine_angles', {
|
|
19316
19470
|
title: 'Mine customer angles',
|
|
19317
|
-
description: "Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. Spends a few credits.",
|
|
19471
|
+
description: "Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. YOUR OWN REVIEWS: pass `reviews` (a list of review texts, or one pasted block: one per line, numbered, blank-line separated, or a CSV with a review column) and/or `reviewsUrl` (a CSV, TXT or JSON file from upload_file, or a review page; on a local CLI a file path works too). They are first-class evidence: every quote from them is checked word for word against what you sent and labelled 'your reviews', and a quote that is not verbatim is dropped and counted. useOwnReviewsOnly:true mines only your reviews and gathers nothing public. Limits: 300 reviews, 2,000 characters each, 40,000 in total; over that it is refused at no cost, so send fewer or split into batches. Each angle comes back with `next`: the exact plan_variations and generate_image arguments that turn it into finished statics (one generate_image per angle = statics with distinct angles). Spends a few credits.",
|
|
19318
19472
|
inputSchema: {
|
|
19319
19473
|
brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
|
|
19474
|
+
reviews: z.union([z.array(z.string()), z.string()]).optional().describe('your own customer reviews: a list of review texts, or one pasted block (one per line, numbered, blank-line separated, or CSV with a review column)'),
|
|
19475
|
+
reviewsUrl: z.string().optional().describe('a URL of your reviews: an uploaded CSV, TXT or JSON file (from upload_file) or a review page. On a local CLI a file path also works.'),
|
|
19476
|
+
useOwnReviewsOnly: z.boolean().optional().describe('true = mine only your reviews, no public search (no search credits). Default false = merge with public customer language.'),
|
|
19320
19477
|
},
|
|
19321
19478
|
outputSchema: {
|
|
19322
|
-
angles: z.array(z.any()).optional().describe('the ranked angle bank ({category, angle, audience, score, hook_draft, proof_quotes}) — `audience` names WHO each angle is for, so a fan-out can vary on the buyer and not only on the hook'),
|
|
19323
|
-
sourceCount: z.number().optional().describe('how many customer sources were mined'),
|
|
19324
|
-
|
|
19479
|
+
angles: z.array(z.any()).optional().describe('the ranked angle bank ({category, angle, audience, score, hook_draft, proof_quotes, proof:[{quote, source}], next:{plan_variations, generate_image}}) — `audience` names WHO each angle is for, so a fan-out can vary on the buyer and not only on the hook; `proof[].source` is \'your reviews\' for a quote matched word for word in your reviews'),
|
|
19480
|
+
sourceCount: z.number().optional().describe('how many customer sources were mined (your reviews included)'),
|
|
19481
|
+
ownReviewCount: z.number().optional().describe('how many of your own reviews were read'),
|
|
19482
|
+
ownQuoteCount: z.number().optional().describe('how many proof quotes come word for word from your reviews'),
|
|
19483
|
+
droppedQuotes: z.number().optional().describe('quotes the model returned that were not verbatim in the material, dropped'),
|
|
19484
|
+
note: z.string().optional().describe('what was read, what was dropped, and the next step; or why no angles were returned'),
|
|
19325
19485
|
},
|
|
19326
19486
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
19327
|
-
}, wrap(async ({ brandId }) => {
|
|
19487
|
+
}, wrap(async ({ brandId, reviews, reviewsUrl, useOwnReviewsOnly }) => {
|
|
19328
19488
|
const brand = await activeBrand(brandId);
|
|
19329
|
-
|
|
19330
|
-
|
|
19489
|
+
const own = {};
|
|
19490
|
+
if (reviews != null) own.reviews = reviews;
|
|
19491
|
+
if (reviewsUrl) {
|
|
19492
|
+
// A local file on a stdio CLI is read here and sent as the pasted block; the hosted connector cannot see a disk.
|
|
19493
|
+
const v = localRefVerdict(reviewsUrl, { remote: isRemote() });
|
|
19494
|
+
if (v.action === 'read') {
|
|
19495
|
+
let t; try { t = await readFile(String(reviewsUrl).trim(), 'utf8'); } catch (e) { throw Object.assign(new Error(`I couldn't open \`${String(reviewsUrl).slice(0, 120)}\` (${e?.code || 'the read failed'}). Nothing was mined and nothing was charged. Check the path, or paste the reviews as \`reviews\`.`), { status: 400, _userInput: true }); }
|
|
19496
|
+
own.reviews = own.reviews == null ? t : [].concat(own.reviews, t);
|
|
19497
|
+
} else if (v.action === 'refuse') throw Object.assign(new Error(`reviewsUrl \`${String(reviewsUrl).slice(0, 120)}\` is ${v.reason === 'hosted' ? 'a local path, and the hosted connector cannot see your disk' : `a ${v.scheme}: URI`}. Upload the file with upload_file (dataUri or url) and pass the url it returns, or paste the reviews as \`reviews\`. Nothing was mined and nothing was charged.`), { status: 400, _userInput: true });
|
|
19498
|
+
else if (v.action === 'pass') own.reviewsUrl = v.value;
|
|
19499
|
+
}
|
|
19500
|
+
if (useOwnReviewsOnly === true) own.useOwnReviewsOnly = true;
|
|
19501
|
+
if (!brand && own.reviews == null && !own.reviewsUrl) throw new Error('No saved brand to mine angles for. Onboard one with draft_brand, pass a brandId from list_brands, or pass your own reviews.');
|
|
19502
|
+
const d = await apiPost('/api/research/angles', { brand: brand || {}, ...own });
|
|
19331
19503
|
const angles = d.angles || [];
|
|
19332
19504
|
if (!angles.length) return ok(d.note || 'Not enough public customer language surfaced to mine reliable angles yet.', d);
|
|
19333
|
-
const text = angles.map((a, i) => `${i + 1}. [${a.category}] ${a.angle} (score ${a.score})${a.audience ? `\n For: ${a.audience}` : ''}\n Hook: ${a.hook_draft || ''}\n Proof: ${(a.proof_quotes || []).map(q =>
|
|
19334
|
-
return ok(`Mined ${angles.length} angles from ${d.sourceCount} customer sources:\n${text}`, d);
|
|
19505
|
+
const text = angles.map((a, i) => `${i + 1}. [${a.category}] ${a.angle} (score ${a.score})${a.audience ? `\n For: ${a.audience}` : ''}\n Hook: ${a.hook_draft || ''}\n Proof: ${(a.proof || (a.proof_quotes || []).map(q => ({ quote: q }))).map(p => `“${p.quote}”${p.source ? ` (${p.source})` : ''}`).join(' · ')}${a.next?.generate_image ? `\n Next: generate_image(${JSON.stringify(a.next.generate_image)})` : ''}`).join('\n');
|
|
19506
|
+
return ok(`Mined ${angles.length} angles from ${d.sourceCount} customer sources:\n${text}${d.note ? `\n\n${d.note}` : ''}`, d);
|
|
19335
19507
|
}));
|
|
19336
19508
|
|
|
19337
19509
|
// ---------- product-photo tools (Studio-chat parity) ----------
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.258",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "Marketing on autopilot, run from your own AI agent.
|
|
5
|
+
"description": "Marketing on autopilot, run from your own AI agent. 847 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hermoso-marketing
|
|
3
|
+
description: >-
|
|
4
|
+
Run a brand's whole marketing operation with Hermoso: research the ads already winning, generate the
|
|
5
|
+
creative, publish and schedule it to the brand's own channels, build and manage the paid campaigns behind
|
|
6
|
+
it, and read back what worked. Use when the user asks for "this week's ads", "run my marketing", "post
|
|
7
|
+
this everywhere", "launch a campaign", "which ads are working", or any request that spans more than one of
|
|
8
|
+
those. NOT for: a single render from a prompt (use hermoso-generate), one finished ad for a brand (use
|
|
9
|
+
hermoso-ad-from-brand), product photography (use hermoso-product-photoshoot), or research alone (use
|
|
10
|
+
hermoso-research).
|
|
11
|
+
argument-hint: "[what you want done, e.g. 'this week's ads for yourbrand.com, posted to TikTok and Instagram']"
|
|
12
|
+
allowed-tools: Bash
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Hermoso: the whole marketing loop
|
|
16
|
+
|
|
17
|
+
Drive the **Hermoso CLI** across all five areas: research, create, publish, paid campaigns, measure.
|
|
18
|
+
|
|
19
|
+
## Setup (once)
|
|
20
|
+
- Run the CLI through npx: `npx -y hermoso <command>`. The commands below are written `hermoso …`; if `hermoso` is not on PATH (`npm install -g hermoso` puts it there), prefix them with `npx -y`. No MCP server is needed.
|
|
21
|
+
- Sign in once: `npx -y hermoso auth login` (opens a browser; nothing to paste). On a machine with no browser: `npx -y hermoso auth login --token <your key>`, using a key from app.hermoso.ai under **MCP & CLI**. No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai; see the Hermoso README.
|
|
22
|
+
- Every tool is reachable, including the ones this file does not name: `hermoso tools --search <what you want>` finds the tool, `hermoso tools <name>` prints its arguments, `hermoso call <name> --json '{...}'` runs it. A tool you cannot see is still callable, so a name you do not recognise never means a missing feature.
|
|
23
|
+
|
|
24
|
+
## Five independent areas, not five steps
|
|
25
|
+
Nothing has to come first. Publish media the user already has, research a market with no brand set up, build a
|
|
26
|
+
campaign around creative you did not make here, or render one file and hand back the URL. Use one area, several,
|
|
27
|
+
or all of them. Researching first is a good habit when the user is starting from nothing, because winning ads are
|
|
28
|
+
found rather than invented, but it is never a prerequisite. Do what was asked and only that.
|
|
29
|
+
|
|
30
|
+
### 1. Research
|
|
31
|
+
- `hermoso competitors <domain> --json` finds the rival brands. `hermoso ads pull --company "<name>" --json` pulls their real running ads from the Meta, Google and LinkedIn libraries.
|
|
32
|
+
- `hermoso research "<open question>"` answers a natural-language brief over the ad libraries plus organic TikTok.
|
|
33
|
+
- Going deeper: `hermoso call competitor_teardown`, `mine_angles`, `search_meta_ads`, `search_google_ads`, `search_linkedin_ads`, and the organic side `search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`, `search_threads`.
|
|
34
|
+
- Research spends credits, so keep the platform scope to what was asked.
|
|
35
|
+
|
|
36
|
+
### 2. Create
|
|
37
|
+
- `hermoso capabilities` first when you need an exact model id, credit cost or duration. Do not guess one, and do not run it just to answer a request to make something: the create commands pick a sound default on their own.
|
|
38
|
+
- From a brand: `hermoso brand draft --domain <domain> --json`, then `hermoso create --brand "<name>" --product "<what to advertise>" --json` for the concept and copy.
|
|
39
|
+
- Render: `hermoso generate image --prompt "…" [--ref ./product.png]`, `hermoso generate video --prompt "…" --duration 8 --aspect 9:16 --wait`, `hermoso generate avatar --image ./face.png --script "…" --wait`. For a finished video ad through the Studio pipeline, `hermoso call render_ad`.
|
|
40
|
+
- Copy an ad that already works: `clone_static` rebuilds a competitor's static ad on brand, `clone_video` remakes a video from its TikTok, Reel, Facebook, X or YouTube link.
|
|
41
|
+
- Variants of one finished ad: `headline_variants` (same picture, new headline), `resize_ad` (recomposed for other placements, not cropped), `localize_ad` (the on-image text translated), `multiply_ad` (one winning video, new cast and set, same cut and audio), `hook_variants` (new openings on the same video), `edit_image` (a plain-language change to an image).
|
|
42
|
+
- Post-production: `stitch_video`, `reframe_video`, `upscale_video`, `dub_video`, `change_voice`, `recast_motion`, `finish_video`, `fix_beat`. Native HTML formats: `make_template_ad`.
|
|
43
|
+
- Video, avatar and stitch are job-based: keep `--wait` so the command prints the final URL, or poll with `hermoso jobs get <id> --wait`. Never report a render as done before the job finishes.
|
|
44
|
+
|
|
45
|
+
### 3. Publish and schedule
|
|
46
|
+
- Ten channels: `post_to_meta` (Facebook, Instagram, Threads), `post_to_tiktok`, `post_to_youtube`, `post_to_x`, `post_to_linkedin`, `post_to_pinterest`, `post_to_bluesky`, `post_to_telegram`. `schedule_post` queues one for later and `list_scheduled` shows the queue.
|
|
47
|
+
- `hermoso call list_connectors` says what is actually connected. A channel with no connection is not a missing feature: tell the user to connect it in the app under Settings, Connectors.
|
|
48
|
+
- Show the exact caption and the exact target account before publishing, and get a yes. Never rewrite or truncate what the user wrote: a channel that cannot take it refuses and says so.
|
|
49
|
+
|
|
50
|
+
### 4. Paid campaigns
|
|
51
|
+
- Eleven ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads. Find the tool with `hermoso tools --group ads --search <platform>`.
|
|
52
|
+
- Campaigns are created **paused** and read back from the platform. Report what the platform returned, never what you sent. Activating one spends real money, so it is confirm-gated and the user has to say yes.
|
|
53
|
+
- `check_ad_policy` before you spend, and `score_ad` on the creative, so a rejection costs nothing.
|
|
54
|
+
|
|
55
|
+
### 5. Measure
|
|
56
|
+
- `post_performance` ranks the organic posts by hook, subject, format or channel, and compares within a channel rather than across channels.
|
|
57
|
+
- `analyze_campaigns` reads every connected ad platform and answers with what to scale, pause, fix and test next, each row carrying its own numbers. It reads only and changes nothing itself, so apply a recommendation yourself once the user agrees.
|
|
58
|
+
- A platform whose read failed is unreadable, not zero. Say which one, and do not move budget on the strength of an absence.
|
|
59
|
+
- Feed the winners back into the next round.
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
- Publishing, scheduling, campaign management and analytics cost no credits. Credits are spent on running a model and on research. State a render's cost before you run it.
|
|
63
|
+
- Text baked into an AI video frame comes out garbled, so `render_ad` composites it in post. Captions, end cards and brand lockups are **opt-in**: leave them off unless the user asked for them.
|
|
64
|
+
- One real product photo is enough. `--ref` on an image render, or `list_product_photos` / `set_product_image` to manage the brand's own.
|
|
65
|
+
- `get_brand` shows what the workspace already knows, and omitting `brand` on a create call uses it.
|
|
66
|
+
- `upload_file` turns any local or external file into a URL that every publish, schedule and ad-build tool accepts, so the user's own creative goes out through the same path.
|
|
67
|
+
- Report the served URL for anything rendered, and offer one concrete next step.
|