hermoso 0.1.161 → 0.1.163
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 +43 -34
- 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.',
|
|
@@ -355,7 +355,16 @@ const wrap = (fn) => {
|
|
|
355
355
|
// connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
|
|
356
356
|
// in-agent) — Dave 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
|
|
357
357
|
else msg += notConnectedHint(e, msg);
|
|
358
|
-
|
|
358
|
+
// ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
|
|
359
|
+
// `msg` above is a SENTENCE, built for a model to read. `/v1/tools` — the REST passthrough — has to answer
|
|
360
|
+
// the same failure with an HTTP status and the v1 error envelope, and deriving one from prose is exactly
|
|
361
|
+
// the over-match that made every Meta not-found read as a permission problem, four separate times. So the
|
|
362
|
+
// two facts this codebase already treats as structured truth ride out beside the sentence: `err.status`
|
|
363
|
+
// and `err.connector`, the ONE not-connected shape. Nothing is parsed anywhere.
|
|
364
|
+
// `_meta` is MCP's own sanctioned extension point (spec: any result MAY carry it), so every MCP client
|
|
365
|
+
// sees an ordinary error result and ignores a key it does not recognise. `publishWrap` spreads the result,
|
|
366
|
+
// so its ambiguous-publish advice keeps the marker rather than dropping it.
|
|
367
|
+
return { content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '' } } };
|
|
359
368
|
}
|
|
360
369
|
};
|
|
361
370
|
return outer;
|
|
@@ -454,7 +463,7 @@ const geoLine = (r) => {
|
|
|
454
463
|
// always measured against the live catalog, never against this number. It deliberately stays at the length the
|
|
455
464
|
// ORDINARY models render (15s) even though the catalog now carries a 30s single-take model: raising it to 30
|
|
456
465
|
// would mean a 20s ask skips the probe entirely, and on an account where that model is not configured (no
|
|
457
|
-
//
|
|
466
|
+
// a render-provider key) the ask would sail through to a model that cannot render it. Low = one extra free round trip on a
|
|
458
467
|
// long ask. High = a silently-truncated render. So it stays low, and the live catalog stays the authority.
|
|
459
468
|
// AD_LENGTH_MAX — the longest STITCHED spot the planner can build: 12 acts (KEYFRAME_CAP) × 15s.
|
|
460
469
|
const VIDEO_SINGLE_CLIP_CEILING = 15;
|
|
@@ -487,7 +496,7 @@ const HOOK_ATTR = {
|
|
|
487
496
|
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
497
|
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
498
|
};
|
|
490
|
-
//
|
|
499
|
+
// The "Duration to boards" table in one line — fill every act to the model max, remainder LAST, and pull the
|
|
491
500
|
// deficit off the previous act when the remainder would fall under the provider floor (their own 18 -> 14+4). Mirrors
|
|
492
501
|
// hfClipDurations in acts-packing.mjs, which is what actually packs the render; here it only makes the refusal concrete.
|
|
493
502
|
const hfSplitHint = (total, max = VIDEO_SINGLE_CLIP_CEILING, min = 4) => {
|
|
@@ -2029,7 +2038,7 @@ function replayTools(rawServer, opts, canon) {
|
|
|
2029
2038
|
// THEIR key — with no recourse on their side and, because it never touches our error ledger, no visibility on ours.
|
|
2030
2039
|
// The same tool answers the same way on every retry, so it is a deterministic loop wearing a transient's clothes.
|
|
2031
2040
|
//
|
|
2032
|
-
// AND WE ARE THE PRODUCER, NOT THE VENDOR. 14,579 live
|
|
2041
|
+
// AND WE ARE THE PRODUCER, NOT THE VENDOR. 14,579 live upstream strings held 570 real surrogate PAIRS and
|
|
2033
2042
|
// ZERO lone ones; `clip()` manufactures them by truncating at a CHARACTER COUNT (server.js now truncates on a code
|
|
2034
2043
|
// POINT boundary, which removes the source — this stays as the boundary that catches everything else, including
|
|
2035
2044
|
// the 1,166 other numeric `.slice(0, N)` sites and any vendor that hands us pre-broken text).
|
|
@@ -5106,7 +5115,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5106
5115
|
}));
|
|
5107
5116
|
server.registerTool('list_bluesky_convos', {
|
|
5108
5117
|
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
|
|
5118
|
+
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
5119
|
inputSchema: {
|
|
5111
5120
|
limit: z.number().optional().describe('how many conversations, 1\u2013100 (default 25)'),
|
|
5112
5121
|
cursor: z.string().optional().describe('walk further back \u2014 pass the cursor from a previous call'),
|
|
@@ -14137,7 +14146,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14137
14146
|
server.group('create');
|
|
14138
14147
|
server.registerTool('plan_ad', {
|
|
14139
14148
|
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
|
|
14149
|
+
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
14150
|
inputSchema: {
|
|
14142
14151
|
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
14152
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
@@ -14540,11 +14549,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14540
14549
|
|
|
14541
14550
|
server.registerTool('make_explainer', {
|
|
14542
14551
|
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
|
|
14552
|
+
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
14553
|
inputSchema: {
|
|
14545
14554
|
topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
|
|
14546
14555
|
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
|
|
14556
|
+
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
14557
|
aspectRatio: z.enum(['9:16', '16:9', '1:1', '4:5', '3:4']).optional().describe("'9:16' default"),
|
|
14549
14558
|
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
14559
|
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 +14790,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14781
14790
|
return ok(wireText, { ...j, url });
|
|
14782
14791
|
}));
|
|
14783
14792
|
|
|
14784
|
-
// ---------- skills (
|
|
14793
|
+
// ---------- skills (workflows ship as SKILL.md bundles) ----------
|
|
14785
14794
|
server.group('create');
|
|
14786
14795
|
// The bundle dirs/content may still carry the pre-rename brand — always serve them under the product name.
|
|
14787
14796
|
const brandSkillText = (s) => String(s).replace(/HEIST_/g, 'HERMOSO_').replace(/heist-/g, 'hermoso-').replace(/Heist/g, 'Hermoso').replace(/\bheist\b/g, 'hermoso');
|
|
@@ -15655,14 +15664,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15655
15664
|
server.group('research');
|
|
15656
15665
|
server.registerTool('find_competitors', {
|
|
15657
15666
|
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
|
|
15667
|
+
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
15668
|
inputSchema: {
|
|
15660
15669
|
domain: z.string().describe('the brand domain, e.g. flourish.com'),
|
|
15661
15670
|
mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
|
|
15662
15671
|
},
|
|
15663
15672
|
outputSchema: {
|
|
15664
15673
|
candidates: z.array(z.any()).optional().describe('discovered brands ({name, domain, kind, reason})'),
|
|
15665
|
-
diagnostics: z.any().optional().describe('discovery diagnostics (
|
|
15674
|
+
diagnostics: z.any().optional().describe('discovery diagnostics (model usage, web grounding)'),
|
|
15666
15675
|
},
|
|
15667
15676
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
15668
15677
|
}, wrap(async ({ domain, mode = 'competitors' }) => {
|
|
@@ -15674,7 +15683,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15674
15683
|
server.registerTool('pull_competitor_ads', {
|
|
15675
15684
|
_meta: openaiMeta(AD_SPY_URI, 'Pulling their live ads…', 'Competitor ads pulled'),
|
|
15676
15685
|
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
|
|
15686
|
+
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
15687
|
inputSchema: {
|
|
15679
15688
|
companyName: z.string().optional().describe('the advertiser name'),
|
|
15680
15689
|
domain: z.string().optional().describe('the advertiser domain'),
|
|
@@ -15750,7 +15759,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15750
15759
|
const cards = adSpyCards(rows);
|
|
15751
15760
|
const modelRows = widget ? stripAdMedia(rows) : rows;
|
|
15752
15761
|
// 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
|
|
15762
|
+
// this exact path: our fan-out is ~5s, and the user waits ~30. The rest is the model
|
|
15754
15763
|
// reading sixteen ad rows and then WRITING a bullet for every one of them, next to a card that is already
|
|
15755
15764
|
// showing all sixteen with their creative. That enumeration is the latency, it is duplicated effort, and we
|
|
15756
15765
|
// invite it by handing over the rows at all.
|
|
@@ -15782,7 +15791,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15782
15791
|
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
15792
|
+ '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
15793
|
+ '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
|
|
15794
|
+
+ '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
15795
|
+ '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
15796
|
+ '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
15797
|
inputSchema: {
|
|
@@ -15883,7 +15892,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15883
15892
|
server.registerTool('research_ads', {
|
|
15884
15893
|
_meta: openaiMeta(AD_SPY_URI, 'Researching ads…', 'Ad research ready'),
|
|
15885
15894
|
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
|
|
15895
|
+
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
15896
|
inputSchema: {
|
|
15888
15897
|
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
15889
15898
|
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 +15979,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15970
15979
|
server.registerTool('search_meta_ads', {
|
|
15971
15980
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Meta ads…', 'Found Meta ads'),
|
|
15972
15981
|
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
|
|
15982
|
+
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
15983
|
inputSchema: {
|
|
15975
15984
|
query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
|
|
15976
15985
|
companyName: z.string().optional().describe('one advertiser’s ads by brand name'),
|
|
@@ -16040,7 +16049,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16040
16049
|
server.registerTool('search_linkedin_ads', {
|
|
16041
16050
|
_meta: openaiMeta(AD_SPY_URI, 'Searching LinkedIn ads…', 'Found LinkedIn ads'),
|
|
16042
16051
|
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
|
|
16052
|
+
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
16053
|
inputSchema: {
|
|
16045
16054
|
company: z.string().optional().describe('advertiser company name'),
|
|
16046
16055
|
keyword: z.string().optional().describe('keyword across all advertisers'),
|
|
@@ -16075,7 +16084,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16075
16084
|
server.registerTool('search_tiktok', {
|
|
16076
16085
|
_meta: openaiMeta(AD_SPY_URI, 'Searching TikTok videos…', 'Found TikTok videos'),
|
|
16077
16086
|
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
|
|
16087
|
+
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
16088
|
inputSchema: {
|
|
16080
16089
|
query: z.string().describe('keyword or hashtag (no # needed)'),
|
|
16081
16090
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
@@ -16101,7 +16110,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16101
16110
|
server.registerTool('search_instagram', {
|
|
16102
16111
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Instagram reels…', 'Found Instagram reels'),
|
|
16103
16112
|
title: 'Search Instagram',
|
|
16104
|
-
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search —
|
|
16113
|
+
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
16114
|
inputSchema: {
|
|
16106
16115
|
query: z.string().describe('keyword to search reels for'),
|
|
16107
16116
|
limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
|
|
@@ -16129,7 +16138,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16129
16138
|
server.registerTool('search_youtube', {
|
|
16130
16139
|
_meta: openaiMeta(AD_SPY_URI, 'Searching YouTube videos…', 'Found YouTube videos'),
|
|
16131
16140
|
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
|
|
16141
|
+
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
16142
|
inputSchema: {
|
|
16134
16143
|
query: z.string().describe('keyword to search videos for'),
|
|
16135
16144
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
@@ -16150,7 +16159,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16150
16159
|
|
|
16151
16160
|
server.registerTool('search_reddit', {
|
|
16152
16161
|
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
|
|
16162
|
+
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
16163
|
inputSchema: {
|
|
16155
16164
|
query: z.string().describe('what to search Reddit for'),
|
|
16156
16165
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
@@ -16173,7 +16182,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16173
16182
|
server.registerTool('search_threads', {
|
|
16174
16183
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Threads posts…', 'Found Threads posts'),
|
|
16175
16184
|
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
|
|
16185
|
+
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
16186
|
inputSchema: {
|
|
16178
16187
|
query: z.string().describe('keyword to search Threads for'),
|
|
16179
16188
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
@@ -16197,11 +16206,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16197
16206
|
return adsOut('posts', all.length, all.slice(0, nAds(limit)), '', 'threads');
|
|
16198
16207
|
}));
|
|
16199
16208
|
|
|
16200
|
-
server.registerTool('
|
|
16201
|
-
title: 'Fetch
|
|
16202
|
-
description: "Generic
|
|
16209
|
+
server.registerTool('fetch_social_data', {
|
|
16210
|
+
title: 'Fetch social data',
|
|
16211
|
+
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
16212
|
inputSchema: {
|
|
16204
|
-
path: z.string().describe("exact
|
|
16213
|
+
path: z.string().describe("exact endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
|
|
16205
16214
|
params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
|
|
16206
16215
|
},
|
|
16207
16216
|
outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
|
|
@@ -16234,7 +16243,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16234
16243
|
|
|
16235
16244
|
server.registerTool('draft_brand', {
|
|
16236
16245
|
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
|
|
16246
|
+
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
16247
|
inputSchema: {
|
|
16239
16248
|
domain: z.string().optional().describe('a website to scrape'),
|
|
16240
16249
|
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
@@ -16337,11 +16346,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16337
16346
|
return ok(`Asset: ${absolute}\nDownload: ${dl}`, { url: absolute, downloadUrl: dl });
|
|
16338
16347
|
}));
|
|
16339
16348
|
|
|
16340
|
-
// ---------- post-production & analysis (
|
|
16349
|
+
// ---------- post-production & analysis (each wraps an EXISTING worker/route) ----------
|
|
16341
16350
|
server.group('create');
|
|
16342
16351
|
server.registerTool('analyze_video', {
|
|
16343
16352
|
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
|
|
16353
|
+
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
16354
|
inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
|
|
16346
16355
|
outputSchema: {
|
|
16347
16356
|
durationSeconds: z.number().optional().describe('the video length in seconds'),
|
|
@@ -16518,7 +16527,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16518
16527
|
|
|
16519
16528
|
server.registerTool('competitor_teardown', {
|
|
16520
16529
|
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
|
|
16530
|
+
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
16531
|
inputSchema: {
|
|
16523
16532
|
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
16533
|
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 +16609,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16600
16609
|
|
|
16601
16610
|
server.registerTool('mine_angles', {
|
|
16602
16611
|
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
|
|
16612
|
+
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
16613
|
inputSchema: {
|
|
16605
16614
|
brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
|
|
16606
16615
|
},
|
|
@@ -16760,7 +16769,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16760
16769
|
inputSchema: {
|
|
16761
16770
|
channel: z.string().optional().describe('restrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest)'),
|
|
16762
16771
|
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
|
|
16772
|
+
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
16773
|
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
16774
|
},
|
|
16766
16775
|
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.163",
|
|
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.
|