hermoso 0.1.161 → 0.1.162
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 +53 -9
- package/mcp/http.mjs +1 -2
- package/mcp/roster-scope.mjs +5 -5
- package/mcp/tools.mjs +31 -31
- package/mcp/well-formed.mjs +1 -1
- package/package.json +1 -1
- package/skills/hermoso-ad-from-brand/SKILL.md +1 -1
- package/skills/hermoso-generate/SKILL.md +1 -1
- package/skills/hermoso-product-photoshoot/SKILL.md +1 -1
- package/skills/hermoso-research/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -32,14 +32,14 @@ Two shapes, and the right one is decided by **what your client can do**, not by
|
|
|
32
32
|
| **Runs in a browser** — Claude.ai, ChatGPT, Claude Desktop | the hosted connector `https://app.hermoso.ai/mcp` | It cannot spawn a local process, so a URL is the only shape it has. Nothing to install, no key to paste, and the full toolset arrives with your saved brand context. This is the right answer for these clients, not a lesser one. |
|
|
33
33
|
| **Can run a shell** — Claude Code, Cursor, Codex, Cline, OpenClaw, Hermes, your own scripts | the CLI, `npm install -g hermoso` | A tool manifest is loaded into every session whether or not a tool is called. A shell command costs nothing until it runs, and it reaches **every** tool rather than the default roster. |
|
|
34
34
|
|
|
35
|
-
**The measured difference** (2026-08-
|
|
35
|
+
**The measured difference** (2026-08-27, counted as real tool definitions rather than estimated from bytes):
|
|
36
36
|
|
|
37
37
|
| | tools in range | loaded per session |
|
|
38
38
|
| --- | --- | --- |
|
|
39
|
-
| Hosted connector, default roster |
|
|
40
|
-
| Hosted connector, `?tools=all` |
|
|
41
|
-
| stdio server (`npx -y hermoso mcp`) |
|
|
42
|
-
| **CLI** | **all
|
|
39
|
+
| Hosted connector, default roster | 306 | **181,713 tokens** |
|
|
40
|
+
| Hosted connector, `?tools=all` | 718 | **472,062 tokens** |
|
|
41
|
+
| stdio server (`npx -y hermoso mcp`) | 306 | **181,713 tokens** |
|
|
42
|
+
| **CLI** | **all 718** | **0** |
|
|
43
43
|
|
|
44
44
|
The CLI answers the same questions on demand instead, and only when asked:
|
|
45
45
|
|
|
@@ -50,7 +50,7 @@ npx -y hermoso call plan_ad --json '{"product":"…"}' # run it
|
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
So a terminal agent reaches its first call in roughly **3.4K tokens with the whole roster in range**, against
|
|
53
|
-
**
|
|
53
|
+
**182K for a fraction of it**. `tools` and `tools <name>` read a registry bundled in the package — no key, no
|
|
54
54
|
network, no sign-in — so an agent can browse the entire product before anyone signs in. Only `call` spends, and
|
|
55
55
|
only that needs `hermoso auth login` once.
|
|
56
56
|
|
|
@@ -64,6 +64,49 @@ calls into one area. `enable_tools({groups:['ads']})` turns campaign management
|
|
|
64
64
|
tools are then native — no shell quoting, structured results. One shell round trip beats loading a 221K-token
|
|
65
65
|
group for a single tool; the reverse is true once a session settles into that area.
|
|
66
66
|
|
|
67
|
+
## Your agent can sign itself up
|
|
68
|
+
|
|
69
|
+
An agent with no Hermoso account can provision one, get its own key, and be rendering ads in the same session.
|
|
70
|
+
No human at a browser, no ticket, no waiting.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# 1. Start a signup. This call takes no credential, because the credential is what it creates.
|
|
74
|
+
curl -sX POST https://app.hermoso.ai/v1/signup \
|
|
75
|
+
-H 'content-type: application/json' \
|
|
76
|
+
-d '{"plan":"pro","period":"mo"}'
|
|
77
|
+
# -> { "id": "cs_...", "checkout_url": "https://checkout.stripe.com/...", "claim_token": "hsc_..." }
|
|
78
|
+
|
|
79
|
+
# 2. Pay at checkout_url. Store claim_token first: it is returned only in that response.
|
|
80
|
+
|
|
81
|
+
# 3. Claim it. Poll until status is "ready".
|
|
82
|
+
curl -sX POST https://app.hermoso.ai/v1/signup/cs_.../claim \
|
|
83
|
+
-H 'content-type: application/json' \
|
|
84
|
+
-d '{"claim_token":"hsc_..."}'
|
|
85
|
+
# -> { "status": "ready", "api_key": "hmk_...", "credits": 3000 }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That `hmk_` key is the same credential everything else on this page takes: `/v1`, the MCP server, the CLI. Point
|
|
89
|
+
your client at it and the full surface is open.
|
|
90
|
+
|
|
91
|
+
**Paying is something a browser-capable agent can already do itself.** Checkout is Stripe's own hosted page, so
|
|
92
|
+
Claude in Chrome and clients like it complete it unattended today. Everything else is a one-click handoff: send
|
|
93
|
+
`checkout_url` to whoever holds the card. The same shape covers you later, once you are running: `buy_credits`
|
|
94
|
+
and `upgrade_plan` mint a ready-to-pay link for more credits or a bigger plan, and `billing_status` reads the
|
|
95
|
+
balance any time.
|
|
96
|
+
|
|
97
|
+
**The agentic path takes a paid plan.** Any of them. The free plan is there for a person signing up at
|
|
98
|
+
[app.hermoso.ai](https://app.hermoso.ai), and asking for it here returns a refusal that says so. Nothing is
|
|
99
|
+
created until the payment completes, so an unpaid signup leaves no account behind and charges nothing.
|
|
100
|
+
|
|
101
|
+
**One thing still wants a person, and it is worth knowing up front.** Connecting a social or ad account means an
|
|
102
|
+
OAuth consent screen, and a consent screen cannot be completed headlessly on any platform. `list_connectors`
|
|
103
|
+
shows what is already connected and what is not. Everything else runs with no browser at all: research,
|
|
104
|
+
generation, publishing to a channel that is already connected, campaign builds, reporting.
|
|
105
|
+
|
|
106
|
+
Full request and response shapes, plus every other endpoint, are in the OpenAPI document at
|
|
107
|
+
[app.hermoso.ai/openapi.json](https://app.hermoso.ai/openapi.json), served live from the same table that mounts
|
|
108
|
+
the routes.
|
|
109
|
+
|
|
67
110
|
## Instant: the hosted Claude.ai connector
|
|
68
111
|
|
|
69
112
|
Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
|
|
@@ -72,7 +115,8 @@ your Hermoso account, done — the full toolset with your saved brand context, b
|
|
|
72
115
|
## Quickstart for Claude Code (one line)
|
|
73
116
|
|
|
74
117
|
1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
|
|
75
|
-
same ones the web Studio uses.
|
|
118
|
+
same ones the web Studio uses. Or skip the browser entirely and let your agent sign itself up on a paid plan
|
|
119
|
+
with `POST /v1/signup` (above).
|
|
76
120
|
2. **Run one line.** Your browser opens once to sign in. Nothing to paste, and no key lands in `.claude.json`:
|
|
77
121
|
|
|
78
122
|
```bash
|
|
@@ -124,7 +168,7 @@ Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
|
124
168
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
125
169
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
126
170
|
social (`search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`, `search_threads`);
|
|
127
|
-
`
|
|
171
|
+
`fetch_social_data`, `mine_angles`, `analyze_video`, `check_ad_policy`, `list_skills` / `get_skill`.
|
|
128
172
|
|
|
129
173
|
**Create** — `draft_brand` → `plan_ad` → `render_ad` (the Studio quality pipeline: composited text, clean speech,
|
|
130
174
|
music, brand end card), or `generate_image` / `generate_video` / `generate_avatar` (UGC creators + lip-sync).
|
|
@@ -221,7 +265,7 @@ Render jobs queue server-side and poll to completion, returning a served URL.
|
|
|
221
265
|
|
|
222
266
|
## 2. CLI — the token-cheap path for terminal agents
|
|
223
267
|
|
|
224
|
-
`bin/hermoso.mjs`
|
|
268
|
+
`bin/hermoso.mjs` exposes the full MCP toolset as subprocess commands, so an agent can shell out instead of carrying a
|
|
225
269
|
fat tool manifest.
|
|
226
270
|
|
|
227
271
|
```bash
|
package/mcp/http.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
2
2
|
// REMOTE MCP CONNECTOR — DEFERRED. This is the Claude.ai "custom connector" surface (https://<host>/mcp):
|
|
3
|
-
// a Streamable-HTTP MCP transport + OAuth so any Claude.ai / Cursor user can connect Hermoso by URL and sign in
|
|
4
|
-
// exactly like Higgsfield's mcp.higgsfield.ai/mcp.
|
|
3
|
+
// a Streamable-HTTP MCP transport + OAuth so any Claude.ai / Cursor user can connect Hermoso by URL and sign in.
|
|
5
4
|
//
|
|
6
5
|
// It is written so the cloud step is a CONFIG FLIP, not a rewrite — but it is intentionally OFF and will REFUSE
|
|
7
6
|
// to mount until BOTH are true:
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -34,16 +34,16 @@
|
|
|
34
34
|
// rsync'd into a published package that has no lib/ and no repo around it.
|
|
35
35
|
// RESEARCH IS NEVER GATED, AND THIS LIST IS THE REASON THE WHOLE CHANGE IS SAFE.
|
|
36
36
|
//
|
|
37
|
-
// The ad libraries and organic social search run on OUR
|
|
37
|
+
// The ad libraries and organic social search run on OUR OWN research key, not on the user's connection — a brand
|
|
38
38
|
// with nothing connected can and must still spy on its competitors' Meta ads. But their NAMES look exactly like
|
|
39
39
|
// connector tools: `search_meta_ads` contains `_meta_`, `search_youtube` contains `youtube`. Six of the nine would
|
|
40
40
|
// have been silently gated by the rules below, which would have broken the product's single most-used feature for
|
|
41
41
|
// every new account — the exact users this change exists to protect.
|
|
42
42
|
//
|
|
43
43
|
// DERIVED, NOT HAND-LISTED. `tools/studio-roster-check.mjs` asserts this set is a SUPERSET of server.js's own
|
|
44
|
-
// `SC_WINDOW_TOOLS` — the set the route already uses to decide which tools take a
|
|
45
|
-
// So a tenth
|
|
46
|
-
// accounts. Anything
|
|
44
|
+
// `SC_WINDOW_TOOLS` — the set the route already uses to decide which tools take a research-balance window.
|
|
45
|
+
// So a tenth research-backed tool added there fails the suite rather than quietly losing research for zero-connector
|
|
46
|
+
// accounts. Anything our own research key pays for, the user reaches without connecting anything.
|
|
47
47
|
//
|
|
48
48
|
// NOTE what is deliberately NOT here: `search_instagram_hashtag`, `instagram_profile`, `search_threads_keyword`,
|
|
49
49
|
// `discover_tiktok_creators`, `tiktok_creator_info`. Those read the PLATFORM's data through the USER'S token
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
// connection, so gating them is correct.
|
|
52
52
|
export const NEVER_GATE = new Set([
|
|
53
53
|
'search_meta_ads', 'search_google_ads', 'search_linkedin_ads', 'search_tiktok',
|
|
54
|
-
'search_instagram', 'search_youtube', 'search_reddit', 'search_threads', '
|
|
54
|
+
'search_instagram', 'search_youtube', 'search_reddit', 'search_threads', 'fetch_social_data',
|
|
55
55
|
]);
|
|
56
56
|
|
|
57
57
|
// Tool-name → connector provider. ORDERED: the first match wins, so the more specific pattern must come first.
|
package/mcp/tools.mjs
CHANGED
|
@@ -151,7 +151,7 @@ export const CAPABILITY_MAP = [
|
|
|
151
151
|
'What Hermoso can do — the full agent surface (every tool below runs over this MCP):',
|
|
152
152
|
// SECOND LINE, deliberately: the map below is a menu, and a menu read as a sequence is the whole defect.
|
|
153
153
|
INDEPENDENCE,
|
|
154
|
-
'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) ·
|
|
154
|
+
'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).',
|
|
155
155
|
'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) · remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
156
156
|
'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.',
|
|
157
157
|
'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).',
|
|
@@ -225,7 +225,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
225
225
|
// discoverable is the other half of the fix, so the reasons to call it are spelled out rather than merely permitted.
|
|
226
226
|
'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.',
|
|
227
227
|
'Capability map:',
|
|
228
|
-
'• 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;
|
|
228
|
+
'• 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.',
|
|
229
229
|
'• 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); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
|
|
230
230
|
'• 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.',
|
|
231
231
|
'• 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.',
|
|
@@ -487,7 +487,7 @@ const HOOK_ATTR = {
|
|
|
487
487
|
hook: z.string().optional().describe('WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.'),
|
|
488
488
|
subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.'),
|
|
489
489
|
};
|
|
490
|
-
//
|
|
490
|
+
// The "Duration to boards" table in one line — fill every act to the model max, remainder LAST, and pull the
|
|
491
491
|
// deficit off the previous act when the remainder would fall under the provider floor (their own 18 -> 14+4). Mirrors
|
|
492
492
|
// hfClipDurations in acts-packing.mjs, which is what actually packs the render; here it only makes the refusal concrete.
|
|
493
493
|
const hfSplitHint = (total, max = VIDEO_SINGLE_CLIP_CEILING, min = 4) => {
|
|
@@ -2029,7 +2029,7 @@ function replayTools(rawServer, opts, canon) {
|
|
|
2029
2029
|
// THEIR key — with no recourse on their side and, because it never touches our error ledger, no visibility on ours.
|
|
2030
2030
|
// The same tool answers the same way on every retry, so it is a deterministic loop wearing a transient's clothes.
|
|
2031
2031
|
//
|
|
2032
|
-
// AND WE ARE THE PRODUCER, NOT THE VENDOR. 14,579 live
|
|
2032
|
+
// AND WE ARE THE PRODUCER, NOT THE VENDOR. 14,579 live upstream strings held 570 real surrogate PAIRS and
|
|
2033
2033
|
// ZERO lone ones; `clip()` manufactures them by truncating at a CHARACTER COUNT (server.js now truncates on a code
|
|
2034
2034
|
// POINT boundary, which removes the source — this stays as the boundary that catches everything else, including
|
|
2035
2035
|
// the 1,166 other numeric `.slice(0, N)` sites and any vendor that hands us pre-broken text).
|
|
@@ -5106,7 +5106,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5106
5106
|
}));
|
|
5107
5107
|
server.registerTool('list_bluesky_convos', {
|
|
5108
5108
|
title: 'List Bluesky direct-message conversations',
|
|
5109
|
-
description: 'Read the connected Bluesky account\u2019s DM conversations \u2014 who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no
|
|
5109
|
+
description: 'Read the connected Bluesky account\u2019s DM conversations \u2014 who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no credits, no vendor approval: AT Protocol app passwords are not scoped. It DOES need a PRIVILEGED app password \u2014 one created with direct-message access ticked \u2014 and says so precisely if the saved one cannot chat, which is a property of the password and NOT a broken connection. Filters: readState "unread", status "request" or "accepted", kind "direct" or "group". An unknown filter value is refused by name, never silently dropped.',
|
|
5110
5110
|
inputSchema: {
|
|
5111
5111
|
limit: z.number().optional().describe('how many conversations, 1\u2013100 (default 25)'),
|
|
5112
5112
|
cursor: z.string().optional().describe('walk further back \u2014 pass the cursor from a previous call'),
|
|
@@ -14137,7 +14137,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14137
14137
|
server.group('create');
|
|
14138
14138
|
server.registerTool('plan_ad', {
|
|
14139
14139
|
title: 'Plan an ad concept',
|
|
14140
|
-
description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends
|
|
14140
|
+
description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends credits.',
|
|
14141
14141
|
inputSchema: {
|
|
14142
14142
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
|
|
14143
14143
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
@@ -14540,11 +14540,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14540
14540
|
|
|
14541
14541
|
server.registerTool('make_explainer', {
|
|
14542
14542
|
title: 'Make an explainer video',
|
|
14543
|
-
description: "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE
|
|
14543
|
+
description: "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE — a slow move on a still shimmers), then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds narration-timed CAPS (see both). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
|
|
14544
14544
|
inputSchema: {
|
|
14545
14545
|
topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
|
|
14546
14546
|
durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
|
|
14547
|
-
frameDensity: z.enum(['standard', 'lean', 'minimal']).optional().describe("how many pictures per second of narration, and therefore what it costs. 'standard' (default) is a frame about every 1.5s — the density
|
|
14547
|
+
frameDensity: z.enum(['standard', 'lean', 'minimal']).optional().describe("how many pictures per second of narration, and therefore what it costs. 'standard' (default) is a frame about every 1.5s — the density a stills film needs to read as a film rather than a slideshow; 'lean' is one about every 2.5s (the longest hold that still reads as a film, ~40% of the frames and ~40% of the cost); 'minimal' is ONE picture per narration section, which is cheapest and is frankly a slideshow. Only drop below the default if the user asked for something cheaper."),
|
|
14548
14548
|
aspectRatio: z.enum(['9:16', '16:9', '1:1', '4:5', '3:4']).optional().describe("'9:16' default"),
|
|
14549
14549
|
style: z.enum(['cinematic', 'editorial_collage', 'flat_vector', 'stickman', 'whiteboard', 'ink_marker', 'silhouette', 'storybook', 'paper_diorama', 'isometric', 'claymation', 'pixel_art', 'watercolor', 'fluffy_toy', 'low_poly', 'stylized_3d', 'studio_3d', 'mannequin']).optional().describe("visual style. 'cinematic' (default) is photoreal; the rest are non-photoreal styled looks — editorial_collage (halftone cutouts + marker accents), flat_vector, stickman, whiteboard, ink_marker, silhouette, storybook (gouache), paper_diorama, isometric, claymation, pixel_art, watercolor, fluffy_toy (felted plush), low_poly, stylized_3d (matte clay render), studio_3d (preschool toy 3D on a white sweep — the Kids default), mannequin (clay-render reenactment figures — a History alternate). Ask the user which they want rather than picking silently; a styled pick costs more (see the cost note)."),
|
|
14550
14550
|
channel: z.enum(['explainer', 'history', 'kids', 'fairytale']).optional().describe("the CHANNEL TYPE — it sets the pacing, the narration register and the default look, and is orthogonal to `style` (a named style always wins): explainer (casual second-person, fast cuts), history (witty chronological retelling / documentary), kids (fastest, question-first, warm teacher), fairytale (slow, atmospheric myth or folklore). Default 'explainer'."),
|
|
@@ -14781,7 +14781,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14781
14781
|
return ok(wireText, { ...j, url });
|
|
14782
14782
|
}));
|
|
14783
14783
|
|
|
14784
|
-
// ---------- skills (
|
|
14784
|
+
// ---------- skills (workflows ship as SKILL.md bundles) ----------
|
|
14785
14785
|
server.group('create');
|
|
14786
14786
|
// The bundle dirs/content may still carry the pre-rename brand — always serve them under the product name.
|
|
14787
14787
|
const brandSkillText = (s) => String(s).replace(/HEIST_/g, 'HERMOSO_').replace(/heist-/g, 'hermoso-').replace(/Heist/g, 'Hermoso').replace(/\bheist\b/g, 'hermoso');
|
|
@@ -15655,7 +15655,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15655
15655
|
server.group('research');
|
|
15656
15656
|
server.registerTool('find_competitors', {
|
|
15657
15657
|
title: 'Find competitors',
|
|
15658
|
-
description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0
|
|
15658
|
+
description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 credits.",
|
|
15659
15659
|
inputSchema: {
|
|
15660
15660
|
domain: z.string().describe('the brand domain, e.g. flourish.com'),
|
|
15661
15661
|
mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
|
|
@@ -15674,7 +15674,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15674
15674
|
server.registerTool('pull_competitor_ads', {
|
|
15675
15675
|
_meta: openaiMeta(AD_SPY_URI, 'Pulling their live ads…', 'Competitor ads pulled'),
|
|
15676
15676
|
title: 'Pull competitor ads',
|
|
15677
|
-
description: 'THE FAST PATH for "show me the ads <brand> is running" \u2014 one named brand\u2019s real live ads from the META (Facebook/Instagram) ad library, deduped, sorted, with the right page resolved. A single call, back in a few seconds. Prefer this over research_ads whenever the brand is named. Meta only, deliberately: it has by far the richest creative and is what people mean by "their ads". For Google or LinkedIn specifically, use search_google_ads or search_linkedin_ads. Spends
|
|
15677
|
+
description: 'THE FAST PATH for "show me the ads <brand> is running" \u2014 one named brand\u2019s real live ads from the META (Facebook/Instagram) ad library, deduped, sorted, with the right page resolved. A single call, back in a few seconds. Prefer this over research_ads whenever the brand is named. Meta only, deliberately: it has by far the richest creative and is what people mean by "their ads". For Google or LinkedIn specifically, use search_google_ads or search_linkedin_ads. Spends credits.',
|
|
15678
15678
|
inputSchema: {
|
|
15679
15679
|
companyName: z.string().optional().describe('the advertiser name'),
|
|
15680
15680
|
domain: z.string().optional().describe('the advertiser domain'),
|
|
@@ -15750,7 +15750,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15750
15750
|
const cards = adSpyCards(rows);
|
|
15751
15751
|
const modelRows = widget ? stripAdMedia(rows) : rows;
|
|
15752
15752
|
// ON A WIDGET HOST, THE ANSWER IS THE CARD — SO STOP ASKING THE MODEL TO RE-TYPE IT (2026-08-24). Measured on
|
|
15753
|
-
// this exact path: our fan-out
|
|
15753
|
+
// this exact path: our fan-out is ~5s, and the user waits ~30. The rest is the model
|
|
15754
15754
|
// reading sixteen ad rows and then WRITING a bullet for every one of them, next to a card that is already
|
|
15755
15755
|
// showing all sixteen with their creative. That enumeration is the latency, it is duplicated effort, and we
|
|
15756
15756
|
// invite it by handing over the rows at all.
|
|
@@ -15782,7 +15782,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15782
15782
|
description: 'Set (or STOP) this workspace\'s standing COMPETITOR WATCH — the weekly job that re-checks each named brand\'s ad libraries and reports what is NEW since last time. The same watch the web app\'s Ad Spy ▸ Watching tab manages, and the same one the weekly digest email is sent from (turn that email on/off with update_settings({watchEmail})). '
|
|
15783
15783
|
+ 'This REPLACES the whole watched list, it does not add to it — pass every brand you want watched, every time. Max 5 brands (the server trims past that). Pass an EMPTY list to stop the watch entirely, which also clears the findings. '
|
|
15784
15784
|
+ 'Give a `domain` wherever you know one: Google Ads Transparency is looked up BY DOMAIN and is skipped for a brand without one, and the domain is what resolves the right Meta page for a brand with an ambiguous name. '
|
|
15785
|
-
+ 'The run itself spends
|
|
15785
|
+
+ 'The run itself spends credits against the ad libraries (roughly 3 per brand on Meta, 1 each on Google and LinkedIn) and is hard-capped per run server-side, so an oversized watch is trimmed rather than allowed to run away. Setting the list is free; only a run spends. '
|
|
15786
15786
|
+ 'runNow:true runs it once IMMEDIATELY (a background job — it spends now) and then keeps the weekly cadence; leave it off and the first check is a week out. '
|
|
15787
15787
|
+ 'The country and the platform mix are NOT settable here — a re-set inherits whatever the pending run already carried (US / Meta for a watch that has never been configured otherwise). Read the findings back with list_watch_findings.',
|
|
15788
15788
|
inputSchema: {
|
|
@@ -15883,7 +15883,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15883
15883
|
server.registerTool('research_ads', {
|
|
15884
15884
|
_meta: openaiMeta(AD_SPY_URI, 'Researching ads…', 'Ad research ready'),
|
|
15885
15885
|
title: 'Research ads',
|
|
15886
|
-
description: 'Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand\u2019s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads \u2014 both are a single call and return in a few seconds. Spends
|
|
15886
|
+
description: 'Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand\u2019s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads \u2014 both are a single call and return in a few seconds. Spends credits — an agentic loop, so a handful rather than the one-call cost of a targeted search.',
|
|
15887
15887
|
inputSchema: {
|
|
15888
15888
|
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
15889
15889
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object to tailor the research to; omit to use the workspace’s saved brand'),
|
|
@@ -15970,7 +15970,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15970
15970
|
server.registerTool('search_meta_ads', {
|
|
15971
15971
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Meta ads…', 'Found Meta ads'),
|
|
15972
15972
|
title: 'Search Meta ads',
|
|
15973
|
-
description: "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends
|
|
15973
|
+
description: "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends a credit or two.",
|
|
15974
15974
|
inputSchema: {
|
|
15975
15975
|
query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
|
|
15976
15976
|
companyName: z.string().optional().describe('one advertiser’s ads by brand name'),
|
|
@@ -16040,7 +16040,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16040
16040
|
server.registerTool('search_linkedin_ads', {
|
|
16041
16041
|
_meta: openaiMeta(AD_SPY_URI, 'Searching LinkedIn ads…', 'Found LinkedIn ads'),
|
|
16042
16042
|
title: 'Search LinkedIn ads',
|
|
16043
|
-
description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends
|
|
16043
|
+
description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends about a credit.",
|
|
16044
16044
|
inputSchema: {
|
|
16045
16045
|
company: z.string().optional().describe('advertiser company name'),
|
|
16046
16046
|
keyword: z.string().optional().describe('keyword across all advertisers'),
|
|
@@ -16075,7 +16075,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16075
16075
|
server.registerTool('search_tiktok', {
|
|
16076
16076
|
_meta: openaiMeta(AD_SPY_URI, 'Searching TikTok videos…', 'Found TikTok videos'),
|
|
16077
16077
|
title: 'Search TikTok',
|
|
16078
|
-
description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends
|
|
16078
|
+
description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends about a credit.",
|
|
16079
16079
|
inputSchema: {
|
|
16080
16080
|
query: z.string().describe('keyword or hashtag (no # needed)'),
|
|
16081
16081
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
@@ -16101,7 +16101,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16101
16101
|
server.registerTool('search_instagram', {
|
|
16102
16102
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Instagram reels…', 'Found Instagram reels'),
|
|
16103
16103
|
title: 'Search Instagram',
|
|
16104
|
-
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search —
|
|
16104
|
+
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — our only IG keyword surface; profile/hashtag pulls go through fetch_social_data with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends about a credit.",
|
|
16105
16105
|
inputSchema: {
|
|
16106
16106
|
query: z.string().describe('keyword to search reels for'),
|
|
16107
16107
|
limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
|
|
@@ -16129,7 +16129,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16129
16129
|
server.registerTool('search_youtube', {
|
|
16130
16130
|
_meta: openaiMeta(AD_SPY_URI, 'Searching YouTube videos…', 'Found YouTube videos'),
|
|
16131
16131
|
title: 'Search YouTube',
|
|
16132
|
-
description: "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends
|
|
16132
|
+
description: "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends about a credit.",
|
|
16133
16133
|
inputSchema: {
|
|
16134
16134
|
query: z.string().describe('keyword to search videos for'),
|
|
16135
16135
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
@@ -16150,7 +16150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16150
16150
|
|
|
16151
16151
|
server.registerTool('search_reddit', {
|
|
16152
16152
|
title: 'Search Reddit',
|
|
16153
|
-
description: "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends
|
|
16153
|
+
description: "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends about a credit.",
|
|
16154
16154
|
inputSchema: {
|
|
16155
16155
|
query: z.string().describe('what to search Reddit for'),
|
|
16156
16156
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
@@ -16173,7 +16173,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16173
16173
|
server.registerTool('search_threads', {
|
|
16174
16174
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Threads posts…', 'Found Threads posts'),
|
|
16175
16175
|
title: 'Search Threads',
|
|
16176
|
-
description: "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends
|
|
16176
|
+
description: "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends about a credit.",
|
|
16177
16177
|
inputSchema: {
|
|
16178
16178
|
query: z.string().describe('keyword to search Threads for'),
|
|
16179
16179
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
@@ -16197,11 +16197,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16197
16197
|
return adsOut('posts', all.length, all.slice(0, nAds(limit)), '', 'threads');
|
|
16198
16198
|
}));
|
|
16199
16199
|
|
|
16200
|
-
server.registerTool('
|
|
16201
|
-
title: 'Fetch
|
|
16202
|
-
description: "Generic
|
|
16200
|
+
server.registerTool('fetch_social_data', {
|
|
16201
|
+
title: 'Fetch social data',
|
|
16202
|
+
description: "Generic escape hatch for any ALLOWLISTED long-tail social/web endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends credits.",
|
|
16203
16203
|
inputSchema: {
|
|
16204
|
-
path: z.string().describe("exact
|
|
16204
|
+
path: z.string().describe("exact endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
|
|
16205
16205
|
params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
|
|
16206
16206
|
},
|
|
16207
16207
|
outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
|
|
@@ -16234,7 +16234,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16234
16234
|
|
|
16235
16235
|
server.registerTool('draft_brand', {
|
|
16236
16236
|
title: 'Draft brand profile',
|
|
16237
|
-
description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0
|
|
16237
|
+
description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
|
|
16238
16238
|
inputSchema: {
|
|
16239
16239
|
domain: z.string().optional().describe('a website to scrape'),
|
|
16240
16240
|
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
@@ -16337,11 +16337,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16337
16337
|
return ok(`Asset: ${absolute}\nDownload: ${dl}`, { url: absolute, downloadUrl: dl });
|
|
16338
16338
|
}));
|
|
16339
16339
|
|
|
16340
|
-
// ---------- post-production & analysis (
|
|
16340
|
+
// ---------- post-production & analysis (each wraps an EXISTING worker/route) ----------
|
|
16341
16341
|
server.group('create');
|
|
16342
16342
|
server.registerTool('analyze_video', {
|
|
16343
16343
|
title: 'Analyze video',
|
|
16344
|
-
description: "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call
|
|
16344
|
+
description: "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call.",
|
|
16345
16345
|
inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
|
|
16346
16346
|
outputSchema: {
|
|
16347
16347
|
durationSeconds: z.number().optional().describe('the video length in seconds'),
|
|
@@ -16518,7 +16518,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16518
16518
|
|
|
16519
16519
|
server.registerTool('competitor_teardown', {
|
|
16520
16520
|
title: 'Competitor teardown',
|
|
16521
|
-
description: "Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass `competitor` {name, domain?}. CONTRACT: supply `ads` (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit `ads` and this pulls the competitor's real Meta ads first (spends
|
|
16521
|
+
description: "Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass `competitor` {name, domain?}. CONTRACT: supply `ads` (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit `ads` and this pulls the competitor's real Meta ads first (spends a credit or two, longest-running = proven winners). Auto-tailors the white space + counter-plays to YOUR saved brand. Spends credits (free when you pass ads).",
|
|
16522
16522
|
inputSchema: {
|
|
16523
16523
|
competitor: z.object({ name: z.string().describe('the competitor brand name'), domain: z.string().optional().describe('their domain — sharpens the auto-pull page match') }).describe('the competitor to tear down'),
|
|
16524
16524
|
ads: z.array(z.object({}).passthrough()).optional().describe('ad objects to tear down (from pull_competitor_ads / search_meta_ads). Omit to auto-pull their Meta ads first.'),
|
|
@@ -16600,7 +16600,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16600
16600
|
|
|
16601
16601
|
server.registerTool('mine_angles', {
|
|
16602
16602
|
title: 'Mine customer angles',
|
|
16603
|
-
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
|
|
16603
|
+
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.",
|
|
16604
16604
|
inputSchema: {
|
|
16605
16605
|
brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
|
|
16606
16606
|
},
|
|
@@ -16760,7 +16760,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16760
16760
|
inputSchema: {
|
|
16761
16761
|
channel: z.string().optional().describe('restrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest)'),
|
|
16762
16762
|
authentic: z.boolean().optional().describe('true if the planned ad is an authentic/UGC/creator-register render — on-screen-text hooks are then reported unusable, with the reason'),
|
|
16763
|
-
category: z.string().optional().describe("the product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting
|
|
16763
|
+
category: z.string().optional().describe("the product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting our Location x Tier matrix puts that category in, with the reason"),
|
|
16764
16764
|
tier: z.enum(['luxury', 'premium', 'drugstore']).optional().describe('product tier, used with category — changes the FINISH of the room, never the room. Default premium.'),
|
|
16765
16765
|
},
|
|
16766
16766
|
outputSchema: { hooks: z.array(z.any()).optional(), settings: z.array(z.any()).optional(), patterns: z.array(z.any()).optional(), patternRule: z.string().optional(), suggestedSetting: z.any().optional(), evidence: z.any().optional(), ranked: z.any().optional() },
|
package/mcp/well-formed.mjs
CHANGED
|
@@ -126,7 +126,7 @@ export function wellFormedValue(v) {
|
|
|
126
126
|
* the thing that manufactures lone surrogates in the first place.
|
|
127
127
|
*
|
|
128
128
|
* `.slice(0, n)` counts code units, so it cuts an astral character in half whenever the boundary lands mid-pair.
|
|
129
|
-
* Measured on live vendor text: 14,579
|
|
129
|
+
* Measured on live vendor text: 14,579 upstream strings held 570 real PAIRS and ZERO lone surrogates — the
|
|
130
130
|
* vendor is not emitting broken text, our own truncation is creating it, at 1 caption length in every 21.
|
|
131
131
|
*
|
|
132
132
|
* THE LENGTH CONTRACT IS PRESERVED: the result is never LONGER than `.slice(0, n)` would be — at worst one code
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.162",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI ad studio and marketing MCP server with 718 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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",
|
|
@@ -14,7 +14,7 @@ allowed-tools: Bash
|
|
|
14
14
|
Drive the **Hermoso CLI** to go from a brand to a finished ad in three steps. Report the final media URL.
|
|
15
15
|
|
|
16
16
|
## Setup
|
|
17
|
-
- `hermoso version` to confirm the CLI; `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**..
|
|
17
|
+
- `hermoso version` to confirm the CLI; `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app 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, no browser needed; see the Hermoso README.
|
|
18
18
|
|
|
19
19
|
## Procedure
|
|
20
20
|
1. **Onboard the brand** (skip if the user already gave full brand details):
|
|
@@ -16,7 +16,7 @@ You drive the **Hermoso CLI** (`hermoso`) to render images and videos. Always re
|
|
|
16
16
|
|
|
17
17
|
## Setup (once)
|
|
18
18
|
1. Ensure the CLI is available. From the Hermoso repo: `node bin/hermoso.mjs version` (or `hermoso version` if globally installed via `npm i -g`).
|
|
19
|
-
2. `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.
|
|
19
|
+
2. `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app 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, no browser needed; see the Hermoso README.
|
|
20
20
|
|
|
21
21
|
## Procedure
|
|
22
22
|
1. **Always run `hermoso capabilities` first.** It lists the valid image/video **model ids**, their credit costs, aspect ratios, video durations, and the recipe ids. Never guess a model id.
|
|
@@ -15,7 +15,7 @@ Use Hermoso's reference-image compositing so the real product (label, colours, s
|
|
|
15
15
|
scene around it is generated. Drive the **Hermoso CLI**.
|
|
16
16
|
|
|
17
17
|
## Setup
|
|
18
|
-
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.; run `hermoso capabilities` once to see image model ids + recipes.
|
|
18
|
+
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.; run `hermoso capabilities` once to see image model ids + recipes. No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai, no browser needed; see the Hermoso README.
|
|
19
19
|
|
|
20
20
|
## Procedure
|
|
21
21
|
1. Get the **product image** path/URL from the user. This is the `--ref` — it forces product-accurate compositing.
|
|
@@ -14,7 +14,7 @@ allowed-tools: Bash
|
|
|
14
14
|
This is Hermoso's discovery half (which most generators don't have). Drive the **Hermoso CLI**.
|
|
15
15
|
|
|
16
16
|
## Setup
|
|
17
|
-
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**..
|
|
17
|
+
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app 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, no browser needed; see the Hermoso README.
|
|
18
18
|
|
|
19
19
|
## Procedure
|
|
20
20
|
Pick the tool that fits the ask:
|
|
@@ -27,5 +27,5 @@ Pick the tool that fits the ask:
|
|
|
27
27
|
4. **Synthesize**: report the strongest hooks, angles, formats, and what's worth copying — be specific (quote the actual headlines/angles). If the user then wants to build one, hand off to `hermoso-ad-from-brand` / `hermoso-generate`.
|
|
28
28
|
|
|
29
29
|
## Notes
|
|
30
|
-
- Research spends
|
|
30
|
+
- Research spends credits (ad-library calls); keep platform scope to what's asked.
|
|
31
31
|
- Add `--json` for the raw ad objects (URLs, copy, run dates) when the user wants the data, not a summary.
|