hermoso 0.1.4 → 0.1.6
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 +18 -14
- package/bin/hermoso.mjs +8 -1
- package/mcp/hermoso-mcp.mjs +4 -2
- package/mcp/http.mjs +2 -2
- package/mcp/tools.mjs +212 -29
- package/package.json +20 -7
package/README.md
CHANGED
|
@@ -8,18 +8,17 @@ image & video ads (your real product composited in, copy + CTA included) — all
|
|
|
8
8
|
## Instant: the hosted Claude.ai connector
|
|
9
9
|
|
|
10
10
|
Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
|
|
11
|
-
your Hermoso account, done —
|
|
11
|
+
your Hermoso account, done — the full studio toolset with your saved brand context, billed to your plan.
|
|
12
12
|
|
|
13
13
|
## Quickstart for Claude Code / Cursor / scripts (2 minutes)
|
|
14
14
|
|
|
15
15
|
1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
|
|
16
16
|
same ones the web Studio uses.
|
|
17
17
|
2. **Create an agent key**: app.hermoso.ai → **Settings → Agents & API** → Create API key (`hmk_…`).
|
|
18
|
-
3. **Connect** (Claude Code shown; any MCP client works):
|
|
18
|
+
3. **Connect** — no clone needed, `npx` runs the published `hermoso` package (Claude Code shown; any MCP client works):
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
|
|
22
|
-
claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- node "$(pwd)/mcp/hermoso-mcp.mjs"
|
|
21
|
+
claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
|
|
23
22
|
```
|
|
24
23
|
|
|
25
24
|
Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
|
|
@@ -28,28 +27,33 @@ learned memory you set up in the web app apply automatically (`get_brand` shows
|
|
|
28
27
|
|
|
29
28
|
## 1. MCP server (stdio) — Claude Code / Cursor / Codex
|
|
30
29
|
|
|
31
|
-
`
|
|
30
|
+
`hermoso mcp` runs a stdio MCP server exposing the full studio toolset (40+ tools). The published `hermoso`
|
|
31
|
+
package means no clone — `npx -y hermoso mcp` fetches and runs it:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
|
|
35
|
-
claude mcp add hermoso -- node "$(pwd)/mcp/hermoso-mcp.mjs"
|
|
34
|
+
claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
|
|
36
35
|
```
|
|
37
36
|
|
|
38
37
|
Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
|
|
39
38
|
|
|
40
39
|
```json
|
|
41
|
-
{ "mcpServers": { "hermoso": { "command": "
|
|
40
|
+
{ "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"],
|
|
42
41
|
"env": { "HERMOSO_API_BASE": "https://app.hermoso.ai", "HERMOSO_TOKEN": "<your token>" } } } }
|
|
43
42
|
```
|
|
44
43
|
|
|
45
44
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
46
45
|
|
|
47
|
-
**Tools (
|
|
48
|
-
`
|
|
49
|
-
`
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
**Tools (40+):** research/ad-spy (`find_competitors`, `pull_competitor_ads`, `research_ads`, `search_meta_ads`,
|
|
47
|
+
`search_google_ads`, `search_linkedin_ads`, `search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`,
|
|
48
|
+
`search_threads`, `scrapecreators_fetch`), plan → generate → finish (`plan_ad`, `plan_variations`, `generate_image`,
|
|
49
|
+
`generate_video`, `generate_avatar`, `render_ad`, `make_template_ad`, `stitch_video`, `reframe_video`,
|
|
50
|
+
`upscale_video`, `dub_video`, `change_voice`, `recast_motion`, `remix_static`, `finish_video`, `fix_beat`),
|
|
51
|
+
brand + account (`get_brand`, `list_brands`, `use_brand`, `draft_brand`, `list_product_photos`, `set_product_image`,
|
|
52
|
+
`hermoso_capabilities`, `hermoso_credits`, `buy_credits`), and analysis/jobs (`analyze_video`, `score_ad`,
|
|
53
|
+
`check_ad_policy`, `competitor_teardown`, `mine_angles`, `get_job`, `list_jobs`, `get_skill`, `list_skills`,
|
|
54
|
+
`fetch_asset`). Call `hermoso_capabilities` first — it returns valid model ids and per-render credit costs;
|
|
55
|
+
`tools/list` is the authoritative current set. Render jobs queue server-side and poll to completion, returning a
|
|
56
|
+
served URL.
|
|
53
57
|
|
|
54
58
|
## 2. CLI — the token-cheap path for terminal agents
|
|
55
59
|
|
package/bin/hermoso.mjs
CHANGED
|
@@ -70,6 +70,12 @@ async function main() {
|
|
|
70
70
|
process.env.HERMOSO_API_BASE = process.env.HERMOSO_API_BASE || cfg.apiBase || 'https://app.hermoso.ai';
|
|
71
71
|
if (cfg.token && !process.env.HERMOSO_TOKEN) process.env.HERMOSO_TOKEN = cfg.token;
|
|
72
72
|
if (cfg.profile && !process.env.HERMOSO_PROFILE) process.env.HERMOSO_PROFILE = cfg.profile;
|
|
73
|
+
|
|
74
|
+
// `hermoso mcp` → run the stdio MCP server (Claude Code / Cursor / Codex spawn this, e.g. `npx -y hermoso mcp`).
|
|
75
|
+
// It OWNS stdout as the JSON-RPC channel, so hand off immediately and print nothing to stdout here. The
|
|
76
|
+
// config-resolved API base + token (set just above) ride into the server's client via env; all logs go to stderr.
|
|
77
|
+
if (group === 'mcp') { await import('../mcp/hermoso-mcp.mjs'); return; }
|
|
78
|
+
|
|
73
79
|
const api = await import('../mcp/client.mjs');
|
|
74
80
|
const out = (label, data) => { if (flags.json) console.log(JSON.stringify(data, null, 2)); else console.log(label); };
|
|
75
81
|
const absUrl = (u) => (u && u.startsWith('/') ? api.API_BASE + u : u);
|
|
@@ -149,7 +155,8 @@ async function main() {
|
|
|
149
155
|
generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
|
|
150
156
|
jobs list | jobs get <id> [--wait] competitors <domain>
|
|
151
157
|
ads pull (--company|--domain) research "<request>"
|
|
152
|
-
fetch <url> [--out]
|
|
158
|
+
fetch <url> [--out] mcp (run the stdio MCP server)
|
|
159
|
+
version
|
|
153
160
|
add --json to any command for machine output.`);
|
|
154
161
|
}
|
|
155
162
|
} catch (e) { die(e?.message || String(e)); }
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
// stdout is the JSON-RPC channel — NEVER print to it. All logging goes to stderr (console.error).
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
-
import { registerTools } from './tools.mjs';
|
|
11
|
+
import { registerTools, MCP_INSTRUCTIONS } from './tools.mjs';
|
|
12
12
|
import { API_BASE } from './client.mjs';
|
|
13
13
|
|
|
14
|
+
// instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
|
|
15
|
+
// in tools.mjs, shared with the hosted connector (http.mjs), so every surface tells agents the same breadth.
|
|
14
16
|
const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
15
|
-
instructions:
|
|
17
|
+
instructions: MCP_INSTRUCTIONS,
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
registerTools(server);
|
package/mcp/http.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
16
16
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
17
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
18
|
-
import { registerTools } from './tools.mjs';
|
|
18
|
+
import { registerTools, MCP_INSTRUCTIONS } from './tools.mjs';
|
|
19
19
|
import { mcpCtx } from './client.mjs';
|
|
20
20
|
|
|
21
21
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
@@ -51,7 +51,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
51
51
|
const sid = req.headers['mcp-session-id'];
|
|
52
52
|
let entry = sid && sessions.get(sid);
|
|
53
53
|
if (!entry) {
|
|
54
|
-
const server = new McpServer({ name: 'hermoso', version: '1.0.0' });
|
|
54
|
+
const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
55
55
|
registerTools(server); // the SAME tools as stdio — but here every /api call they make carries this user's token
|
|
56
56
|
const transport = new StreamableHTTPServerTransport({
|
|
57
57
|
sessionIdGenerator: () => 'sess_' + Math.random().toString(36).slice(2),
|
package/mcp/tools.mjs
CHANGED
|
@@ -14,6 +14,30 @@ const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredConte
|
|
|
14
14
|
const stillMsg = (r) => `Still rendering — job ${r.jobId}. This is NORMAL: video renders take 1–3 minutes and each get_job call waits up to ~45s, so it can take several calls. Keep calling get_job with this id until status is done or error — do NOT ask the user whether to keep waiting, and do NOT re-fire the render on another model (that double-charges). Only surface a problem after ~6 minutes of polling.`;
|
|
15
15
|
const okVideo = async (text, r) => {
|
|
16
16
|
if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; return { content: [{ type: 'text', text: p ? text + '\n(first frame attached — open the URL for the full video)' : text }, ...(p ? [p] : [])], structuredContent: r ?? undefined }; };
|
|
17
|
+
|
|
18
|
+
// ── CAPABILITY MAP — the FULL agent surface, four categories. Appended to hermoso_capabilities so an agent that
|
|
19
|
+
// probes once learns everything Hermoso does (not just the models): ad spy, create, raw playground, account. Keep
|
|
20
|
+
// crisp + tool-named so the model can act on it directly. (Server-level orientation lives in MCP_INSTRUCTIONS below.)
|
|
21
|
+
const CAPABILITY_MAP = [
|
|
22
|
+
'What Hermoso can do — the full agent surface (every tool below runs over this MCP):',
|
|
23
|
+
'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 · scrapecreators_fetch (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
|
|
24
|
+
'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / use_brand · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · 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).',
|
|
25
|
+
'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.',
|
|
26
|
+
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (top-up checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders).',
|
|
27
|
+
].join('\n');
|
|
28
|
+
|
|
29
|
+
// Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
|
|
30
|
+
// the capability map: it names the three jobs + the same four categories so a freshly-connected agent immediately
|
|
31
|
+
// knows the breadth. Exported so BOTH the stdio server (hermoso-mcp.mjs) and the hosted connector (http.mjs) share one
|
|
32
|
+
// source of truth. Kept parity across mcp/ and cli/mcp/ (the npm copy).
|
|
33
|
+
export const MCP_INSTRUCTIONS = [
|
|
34
|
+
'Hermoso is an AI ad studio you drive over MCP — use it for three jobs: (1) AD SPY / research the ads already winning in any market, (2) CREATE finished on-brand image & video ads, and (3) run RAW generations against the full model catalog. Call hermoso_capabilities FIRST (free) to learn valid model ids + exact credit costs. Capability map:',
|
|
35
|
+
'• 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; scrapecreators_fetch; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
|
|
36
|
+
'• CREATE (finished ads): draft_brand → plan_ad → render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar; make_template_ad (native HTML formats); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
|
|
37
|
+
'• 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.',
|
|
38
|
+
'• ACCOUNT: hermoso_credits, billing_status, buy_credits (top-up link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job.',
|
|
39
|
+
'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits mints a Stripe link your human pays; agents never spend money directly. Always report the final media URL to the user.',
|
|
40
|
+
].join('\n');
|
|
17
41
|
// Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
|
|
18
42
|
// Skipped silently for huge files / fetch errors — the URL in the text always works.
|
|
19
43
|
// Claude can't play video inline — attach the FIRST FRAME as an image block next to the link so the spot is
|
|
@@ -41,7 +65,7 @@ const wrap = (fn) => async (args, extra) => {
|
|
|
41
65
|
catch (e) {
|
|
42
66
|
let msg = `Error: ${e?.message || e}`;
|
|
43
67
|
// credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
|
|
44
|
-
if (/not enough credits/i.test(msg)) msg += `\
|
|
68
|
+
if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to get a ready-to-pay checkout link (credit packs; your human pays on Stripe's secure page — nothing was charged here). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
|
|
45
69
|
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
46
70
|
}
|
|
47
71
|
};
|
|
@@ -65,19 +89,24 @@ async function renderJob(type, input, label) {
|
|
|
65
89
|
export function registerTools(server) {
|
|
66
90
|
// ---------- read-only / discovery ----------
|
|
67
91
|
server.registerTool('hermoso_capabilities', {
|
|
92
|
+
title: 'Hermoso capabilities',
|
|
68
93
|
description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar/canPublish flags. Call this FIRST so you generate with valid model ids and known costs. Read-only, free.',
|
|
69
94
|
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
70
95
|
}, wrap(async () => {
|
|
71
96
|
const d = await apiGet('/api/generate/status');
|
|
72
|
-
const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.best ? ', best' : ''})`).join('; ');
|
|
97
|
+
const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
|
|
73
98
|
// durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
|
|
74
99
|
// prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
|
|
75
|
-
const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.best ? ', best' : ''})`).join('; ');
|
|
76
|
-
|
|
100
|
+
const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${m.best ? ', best' : ''})`).join('; ');
|
|
101
|
+
// voice engines (generate_voice) + writing models (generate_text) — so the RAW PLAYGROUND is usable from one probe
|
|
102
|
+
const voice = d.options?.voice ? (d.options.voice.engines || []).map(e => `${e.id} (${e.label}: ${(e.voices || []).slice(0, 6).join('/')}${(e.voices || []).length > 6 ? '…' : ''}, ${e.creditsPer1k}cr/1k chars)`).join('; ') : 'unavailable';
|
|
103
|
+
const llm = d.options?.llm ? (d.options.llm.models || []).map(m => `${m.id} (${m.label})`).join('; ') : 'unavailable';
|
|
104
|
+
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\nIMPORTANT: durations above are SINGLE-PASS — e.g. seedance-2 renders a full multi-beat 15s ad in ONE generation (do NOT assume a generic 8–10s cap, and do NOT stitch for ≤15s spots; stitching is only for longer). durationSeconds must be one of the model's listed values.\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar} canPublish:${d.canPublish}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`;
|
|
77
105
|
return ok(text, d);
|
|
78
106
|
}));
|
|
79
107
|
|
|
80
108
|
server.registerTool('hermoso_credits', {
|
|
109
|
+
title: 'Credit balance',
|
|
81
110
|
description: 'Return the account credit balance, credits used this session, and recent priced calls. Check before kicking off paid generation.',
|
|
82
111
|
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
83
112
|
}, wrap(async () => {
|
|
@@ -86,7 +115,85 @@ export function registerTools(server) {
|
|
|
86
115
|
return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
|
|
87
116
|
}));
|
|
88
117
|
|
|
118
|
+
// AGENT BILLING HANDOFF: out of credits → mint a ready-to-pay Stripe checkout link for a credit PACK and hand the
|
|
119
|
+
// URL to the human. The human pays on Stripe's hosted page (agents never spend money directly); credits post to
|
|
120
|
+
// this account automatically once payment completes. Packs only — subscriptions are managed by a person in-app.
|
|
121
|
+
server.registerTool('buy_credits', {
|
|
122
|
+
title: 'Buy credits',
|
|
123
|
+
description: "Out of credits? Get a ready-to-pay checkout link for a credit PACK. Call with no argument to list the available packs (id · credits · price); call again with `pack` set to a pack id to get a Stripe checkout URL. Hand that URL to your human — THEY pay on Stripe's secure hosted page (agents never spend money directly), and the credits land on this account the moment payment completes. Packs only; subscriptions are managed by a person in Settings → Billing. Nothing is charged until your human pays.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
|
|
126
|
+
},
|
|
127
|
+
annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe's page
|
|
128
|
+
}, wrap(async ({ pack }) => {
|
|
129
|
+
const cfg = await apiGet('/api/billing/config');
|
|
130
|
+
const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
|
|
131
|
+
if (!pack) {
|
|
132
|
+
const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
|
|
133
|
+
return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>" to get a checkout link for your human to pay.`, { packs });
|
|
134
|
+
}
|
|
135
|
+
const match = packs.find(p => p.id === pack);
|
|
136
|
+
if (!match) return ok(`No pack "${pack}". Available: ${packs.map(p => p.id).join(', ') || '(none)'}. Call buy_credits with no argument to see details.`, { packs });
|
|
137
|
+
const d = await apiPost('/api/billing/checkout-link', { packId: pack });
|
|
138
|
+
return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — the credits post to this account automatically once payment completes. Nothing is charged until they pay.`, d);
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
// BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
|
|
142
|
+
// billing_status (full picture + your role) → upgrade_plan / set_auto_reload (admin-only, pay-on-Stripe / in-app).
|
|
143
|
+
server.registerTool('billing_status', {
|
|
144
|
+
title: 'Billing status',
|
|
145
|
+
description: "Show this account's billing at a glance: current plan (id + label + monthly price), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing.",
|
|
146
|
+
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
147
|
+
}, wrap(async () => {
|
|
148
|
+
const d = await apiGet('/api/billing/status');
|
|
149
|
+
const ar = d.autoReload || {};
|
|
150
|
+
const arLine = ar.available === false ? 'set in the app (not via API)' : (ar.enabled ? `on (below ${ar.thresholdCredits} cr → +${ar.reloadCredits} cr)` : 'off');
|
|
151
|
+
const text = `Plan: ${d.plan?.label} ($${d.plan?.monthlyUsd}/mo)\nBalance: ${d.balanceCredits} credits\nAuto-reload: ${arLine}\nCard on file: ${d.paymentMethodOnFile ? `yes${d.card ? ` (${d.card.brand} ····${d.card.last4})` : ''}` : 'no'}\nYour billing role: ${d.role}${d.isAdmin ? ' — you can change the plan / auto-reload' : ' — read-only; ask an admin to change the plan or auto-reload'}`;
|
|
152
|
+
return ok(text, d);
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// AGENT BILLING HANDOFF (plans): mint a ready-to-pay Stripe SUBSCRIPTION link for a NEW subscriber; existing-sub
|
|
156
|
+
// changes + downgrades are made in-app (the tool returns exactly what to do). Admin-only; a human always pays.
|
|
157
|
+
server.registerTool('upgrade_plan', {
|
|
158
|
+
title: 'Upgrade plan',
|
|
159
|
+
description: "Change this account's SUBSCRIPTION plan (admin only). Call with no argument to list the plans (id · monthly price · monthly credits); call again with `plan` set to a plan id. A NEW subscriber gets a ready-to-pay Stripe Checkout URL to hand your human — THEY pay on Stripe (agents never spend money directly). If the account already has a paid plan, or you're DOWNGRADING, the change is made by a person in the app (Settings → Billing) and the tool returns exactly what to do. Members (read-only billing) get an honest 'ask an admin' message. Nothing is charged until your human pays.",
|
|
160
|
+
inputSchema: {
|
|
161
|
+
plan: z.string().optional().describe('the plan id to move to (e.g. pro) — omit to list the available plans first'),
|
|
162
|
+
period: z.enum(['mo', 'yr']).optional().describe('billing cadence — monthly (default) or yearly (2 months free)'),
|
|
163
|
+
},
|
|
164
|
+
annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe / in-app
|
|
165
|
+
}, wrap(async ({ plan, period }) => {
|
|
166
|
+
const cfg = await apiGet('/api/billing/config');
|
|
167
|
+
const plans = (cfg.plans || []).filter(p => p.priceUsd > 0).map(p => ({ id: p.id, name: p.name, priceUsd: p.priceUsd, credits: p.credits }));
|
|
168
|
+
if (!plan) {
|
|
169
|
+
const lines = plans.map(p => `• ${p.id} — ${p.name}: $${p.priceUsd}/mo · ${p.credits.toLocaleString()} credits/mo`).join('\n') || '(no plans configured)';
|
|
170
|
+
return ok(`Subscription plans:\n${lines}\n\nCall upgrade_plan again with plan="<id>" (admin only). Downgrades + changes for existing subscribers are made in the app.`, { plans });
|
|
171
|
+
}
|
|
172
|
+
const d = await apiPost('/api/billing/plan-link', { planId: plan, period });
|
|
173
|
+
if (d.mode === 'checkout') return ok(`Checkout link for the ${d.planLabel} plan ($${d.monthlyUsd}/mo${d.period === 'yr' ? `, billed $${d.chargeUsd}/yr` : ''}):\n${d.url}\n\nGive this URL to your human to subscribe on Stripe's secure page. Nothing is charged until they pay.`, d);
|
|
174
|
+
return ok(d.guidance, d); // in_app — an existing-subscriber upgrade or a downgrade (done by a person in the app)
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
// Standing auto-reload config — a REAL server-side write now (persists on the account + fires even with no app open).
|
|
178
|
+
// Admin-only; requires a card on file (added ONCE in the app, then agents manage top-ups/auto-reload/plan links fully).
|
|
179
|
+
server.registerTool('set_auto_reload', {
|
|
180
|
+
title: 'Set auto-reload',
|
|
181
|
+
description: "Turn automatic credit reloads on or off (admin only): when the balance drops below a threshold, the card on file is charged for a top-up pack — SERVER-SIDE, even with no app open. Requires a saved card, added once in the app at first checkout/top-up; if there's none the tool tells you exactly where to add it. After that one-time card setup, agents can manage auto-reload, top-ups and plan links fully. Members (read-only billing) get an 'ask an admin' message.",
|
|
182
|
+
inputSchema: {
|
|
183
|
+
enabled: z.boolean().describe('true to turn auto-reload on, false to turn it off'),
|
|
184
|
+
thresholdCredits: z.number().int().optional().describe('reload when the balance drops below this many credits'),
|
|
185
|
+
reloadCredits: z.number().int().optional().describe('how many credits to add each reload — must match a credit pack size (see buy_credits)'),
|
|
186
|
+
},
|
|
187
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
188
|
+
}, wrap(async ({ enabled, thresholdCredits, reloadCredits }) => {
|
|
189
|
+
const d = await apiPost('/api/billing/autoreload-config', { enabled, thresholdCredits, reloadCredits });
|
|
190
|
+
if (d.needsCard) return ok(d.guidance || 'Add a card on file first (in the app), then auto-reload can use it.', d);
|
|
191
|
+
if (d.applied) return ok(`Auto-reload ${d.enabled ? `ON — reloads${d.reloadCredits != null ? ' +' + d.reloadCredits.toLocaleString() + ' credits' : ''} when the balance drops below ${d.thresholdCredits} credits` : 'OFF'}.`, d);
|
|
192
|
+
return ok(d.guidance || 'Manage auto-reload in the app: Settings → Billing → Auto-reload.', d);
|
|
193
|
+
}));
|
|
194
|
+
|
|
89
195
|
server.registerTool('list_brands', {
|
|
196
|
+
title: 'List brands',
|
|
90
197
|
description: "List every brand on this account (id + name) and which one this connection currently acts on. Multi-brand accounts: call this, then use_brand to switch. Read-only, free.",
|
|
91
198
|
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
92
199
|
}, wrap(async () => {
|
|
@@ -96,9 +203,10 @@ export function registerTools(server) {
|
|
|
96
203
|
}));
|
|
97
204
|
|
|
98
205
|
server.registerTool('use_brand', {
|
|
206
|
+
title: 'Switch brand',
|
|
99
207
|
description: "Pin which brand this connection generates for (multi-brand accounts). Pass the brand id or exact name from list_brands. Persists for this API key until changed.",
|
|
100
208
|
inputSchema: { brand: z.string().describe('brand id (e.g. default / p_xxx) or its exact name from list_brands') },
|
|
101
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
209
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
102
210
|
}, wrap(async ({ brand }) => {
|
|
103
211
|
const d = await apiGet('/api/brands');
|
|
104
212
|
const want = String(brand || '').trim().toLowerCase();
|
|
@@ -110,6 +218,7 @@ export function registerTools(server) {
|
|
|
110
218
|
|
|
111
219
|
// ---------- planning (LLM, 0 SC credits) ----------
|
|
112
220
|
server.registerTool('plan_ad', {
|
|
221
|
+
title: 'Plan an ad concept',
|
|
113
222
|
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. Spends LLM tokens, 0 ScrapeCreators credits.',
|
|
114
223
|
inputSchema: {
|
|
115
224
|
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'),
|
|
@@ -119,18 +228,23 @@ export function registerTools(server) {
|
|
|
119
228
|
reference: z.string().optional().describe('a reference ad URL to remix the angle from — Facebook Ad Library, LinkedIn Ad Library or Google Ads Transparency links (the real ad’s copy/advertiser are fetched and fed into the concept)'),
|
|
120
229
|
language: z.string().optional(),
|
|
121
230
|
},
|
|
122
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
231
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
123
232
|
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
|
|
124
233
|
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
|
|
125
234
|
const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
|
|
126
235
|
const c = d.creative || d;
|
|
236
|
+
// EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
|
|
237
|
+
// grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
|
|
238
|
+
// /api/render/assemble prefers creative.brand, so "pass plan_ad's full output" now carries the right grounding.
|
|
239
|
+
if (brandObj && !c.brand) c.brand = { name: brandObj.name || '', domain: brandObj.domain || '', logo: brandObj.logo || '', sells: brandObj.sells || '', palette: (brandObj.palette || []).slice(0, 4), productImages: (brandObj.productImages || []).slice(0, 4) };
|
|
127
240
|
const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a ≤15s storyboard renders as ONE single-pass clip, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
|
|
128
241
|
return ok(text, c);
|
|
129
242
|
}));
|
|
130
243
|
|
|
131
244
|
// ---------- image (synchronous) ----------
|
|
132
245
|
server.registerTool('generate_image', {
|
|
133
|
-
|
|
246
|
+
title: 'Generate ad image',
|
|
247
|
+
description: 'Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). MULTI-BRAND CAUTION: useBrand hydration pulls the SAVED workspace brand — when working a brand that is NOT the saved one (a fresh draft_brand), pass that brand\'s own productImages/logo as refImages (and useBrand:false) or the output composites the WRONG brand\'s product. model = a catalog id from hermoso_capabilities (omit for the default). Fast (seconds). Spends credits.',
|
|
134
248
|
inputSchema: {
|
|
135
249
|
prompt: z.string().describe('the full image prompt — subject, composition, lighting, and any on-image ad text'),
|
|
136
250
|
refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in'),
|
|
@@ -139,7 +253,7 @@ export function registerTools(server) {
|
|
|
139
253
|
model: z.string().optional().describe('image model id from hermoso_capabilities'),
|
|
140
254
|
imageSize: z.string().optional(),
|
|
141
255
|
},
|
|
142
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
256
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
143
257
|
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
|
|
144
258
|
const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
|
|
145
259
|
const d = await apiPost('/api/generate/image', { prompt, refImages: refs, useBrand: useBrand !== false, aspectRatio, model, imageSize }); // explicit boolean so the server's saved-brand hydration default is unambiguous
|
|
@@ -147,8 +261,37 @@ export function registerTools(server) {
|
|
|
147
261
|
return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
148
262
|
}));
|
|
149
263
|
|
|
264
|
+
// ---------- raw playground: voice (TTS) + writing models ----------
|
|
265
|
+
server.registerTool('generate_voice', {
|
|
266
|
+
title: 'Generate voiceover',
|
|
267
|
+
description: "RAW text-to-speech from the voice-model catalog: speak a script in a chosen voice and return the served MP3 URL. For a standalone voiceover / narration clip — NOT for adding audio to a video (render_ad and generate_video voice their own spots; change_voice re-voices a finished clip). engine picks the voice model (default 'seed-audio'; also 'eleven-v3', 'minimax-speech', 'kokoro'); voice is a preset name from that engine (see hermoso_capabilities → voice engines). Paid (a couple of credits by length; ≤900 characters).",
|
|
268
|
+
inputSchema: {
|
|
269
|
+
text: z.string().describe('the script to speak (≤900 characters)'),
|
|
270
|
+
engine: z.string().optional().describe("voice-engine id: 'seed-audio' (default), 'eleven-v3', 'minimax-speech', or 'kokoro' — listed in hermoso_capabilities"),
|
|
271
|
+
voice: z.string().optional().describe("a voice preset from the chosen engine (e.g. 'Aria'/'George' on eleven-v3, 'stokie_en' on seed-audio) — omit for the engine default"),
|
|
272
|
+
},
|
|
273
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
274
|
+
}, wrap(async ({ text, engine, voice }) => {
|
|
275
|
+
const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}) });
|
|
276
|
+
return ok(`Voice clip ready — ${d.voice}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
server.registerTool('generate_text', {
|
|
280
|
+
title: 'Generate text',
|
|
281
|
+
description: "RAW text generation against the writing-model catalog (Claude, Gemini, GPT, Llama, DeepSeek…) — ad copy, hooks, scripts, rewrites, brainstorms. Prompt-only, no ad assembly (for a finished on-brand creative use plan_ad → render_ad). model = a writing-model id from hermoso_capabilities (omit for the default Claude orchestrator). Paid (a credit or two by length).",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
prompt: z.string().describe('the writing task / question'),
|
|
284
|
+
model: z.string().optional().describe('a writing-model id from hermoso_capabilities (a Claude / Gemini / GPT / Llama / DeepSeek id) — omit for the default'),
|
|
285
|
+
},
|
|
286
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
287
|
+
}, wrap(async ({ prompt, model }) => {
|
|
288
|
+
const d = await apiPost('/api/models/llm', { prompt, ...(model ? { model } : {}) });
|
|
289
|
+
return ok(`${d.text}${d.model ? `\n\n— ${d.model}` : ''}`, d);
|
|
290
|
+
}));
|
|
291
|
+
|
|
150
292
|
// ---------- video / avatar / stitch (job-based, polled to completion) ----------
|
|
151
293
|
server.registerTool('render_ad', {
|
|
294
|
+
title: 'Render ad video',
|
|
152
295
|
description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a ≤15s storyboard renders as ONE single-pass clip; a longer plan automatically renders as STITCHED ACTS (fewest balanced ≤15s clips) — never time-compressed into one clip. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
|
|
153
296
|
inputSchema: {
|
|
154
297
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
@@ -163,7 +306,7 @@ export function registerTools(server) {
|
|
|
163
306
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
164
307
|
dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged'),
|
|
165
308
|
},
|
|
166
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
309
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
167
310
|
}, wrap(async (a) => {
|
|
168
311
|
const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
|
|
169
312
|
// LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
|
|
@@ -176,11 +319,12 @@ export function registerTools(server) {
|
|
|
176
319
|
|
|
177
320
|
|
|
178
321
|
server.registerTool('make_template_ad', {
|
|
179
|
-
|
|
322
|
+
title: 'Make template ad',
|
|
323
|
+
description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
|
|
180
324
|
inputSchema: {
|
|
181
325
|
config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
|
|
182
326
|
},
|
|
183
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
327
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
184
328
|
}, wrap(async (a) => {
|
|
185
329
|
const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
|
|
186
330
|
if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
|
|
@@ -193,6 +337,7 @@ export function registerTools(server) {
|
|
|
193
337
|
}));
|
|
194
338
|
|
|
195
339
|
server.registerTool('finish_video', {
|
|
340
|
+
title: 'Finish video',
|
|
196
341
|
description: "Post-process an EXISTING rendered video (its served mp4 URL) with the proven direct-response 'reviewer' finish and/or a film-grain pass — no AI model, ~30s, a couple of credits. pills=true composites a header pill (e.g. '10/10 would buy again'), a brand-accent sub-pill, and 3-4 green-check proof pills cascading in on the beat (YOU author the copy: header ≤40 chars, sub ≤34, each point ≤44 — concrete real benefits, never fabricated stats). grain=true applies a subtle camera-grain finish that makes photoreal AI renders look phone-shot ('less AI') — works alone or with pills. Returns a NEW video; the original is untouched.",
|
|
197
342
|
inputSchema: {
|
|
198
343
|
videoUrl: z.string().describe('the served URL of the video to finish (from a previous render/job)'),
|
|
@@ -203,13 +348,14 @@ export function registerTools(server) {
|
|
|
203
348
|
pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
|
|
204
349
|
grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
|
|
205
350
|
},
|
|
206
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
351
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
207
352
|
}, wrap(async (a) => {
|
|
208
353
|
const r = await renderJob('videofinish', { videoUrl: a.videoUrl, header: a.header, sub: a.sub, points: a.points, accent: a.accent, pills: a.pills !== false, grain: !!a.grain }, 'MCP video finish');
|
|
209
354
|
return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
|
|
210
355
|
}));
|
|
211
356
|
|
|
212
357
|
server.registerTool('fix_beat', {
|
|
358
|
+
title: 'Fix a video beat',
|
|
213
359
|
description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.",
|
|
214
360
|
inputSchema: {
|
|
215
361
|
videoUrl: z.string().describe('the served URL of the master video to fix'),
|
|
@@ -219,17 +365,19 @@ export function registerTools(server) {
|
|
|
219
365
|
refImage: z.string().optional().describe('optional product/style anchor image URL'),
|
|
220
366
|
speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
|
|
221
367
|
},
|
|
222
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
368
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
223
369
|
}, wrap(async (a) => {
|
|
224
370
|
const r = await renderJob('fixbeat', { videoUrl: a.videoUrl, startSeconds: a.startSeconds, endSeconds: a.endSeconds, prompt: a.prompt, refImage: a.refImage, speechWindows: a.speechWindows }, 'MCP fix beat');
|
|
225
371
|
return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
|
|
226
372
|
}));
|
|
227
373
|
|
|
228
374
|
server.registerTool('generate_video', {
|
|
229
|
-
|
|
375
|
+
title: 'Generate video',
|
|
376
|
+
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
|
|
230
377
|
inputSchema: {
|
|
231
|
-
prompt: z.string().describe('the video prompt / shot description'),
|
|
378
|
+
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
232
379
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
380
|
+
refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the omni engine accepts a raw clip and transforms it per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit to generate a fresh clip."),
|
|
233
381
|
durationSeconds: z.number().optional().describe('clip length in seconds'),
|
|
234
382
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
235
383
|
model: z.string().optional().describe('video model id from hermoso_capabilities. Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback); omit it to let the router pick'),
|
|
@@ -238,7 +386,7 @@ export function registerTools(server) {
|
|
|
238
386
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
239
387
|
musicMood: z.string().optional(),
|
|
240
388
|
},
|
|
241
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
389
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
242
390
|
}, wrap(async (a) => {
|
|
243
391
|
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
244
392
|
// an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
|
|
@@ -248,6 +396,7 @@ export function registerTools(server) {
|
|
|
248
396
|
}));
|
|
249
397
|
|
|
250
398
|
server.registerTool('generate_avatar', {
|
|
399
|
+
title: 'Generate talking avatar',
|
|
251
400
|
description: 'Render a TALKING-AVATAR / creator lip-sync clip from a portrait image + a script. Blocks until done (1–3 min). Requires the avatar capability (canAvatar in hermoso_capabilities). Spends credits.',
|
|
252
401
|
inputSchema: {
|
|
253
402
|
image: z.string().describe('local path or URL of the presenter portrait'),
|
|
@@ -255,7 +404,7 @@ export function registerTools(server) {
|
|
|
255
404
|
voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
|
|
256
405
|
resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
|
|
257
406
|
},
|
|
258
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
407
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
259
408
|
}, wrap(async (a) => {
|
|
260
409
|
const image = await toRef(a.image);
|
|
261
410
|
const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
|
|
@@ -263,6 +412,7 @@ export function registerTools(server) {
|
|
|
263
412
|
}));
|
|
264
413
|
|
|
265
414
|
server.registerTool('stitch_video', {
|
|
415
|
+
title: 'Stitch multi-scene video',
|
|
266
416
|
description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than one model clip (>15s). A ≤15s multi-beat ad renders better and cheaper as ONE single-pass generate_video/render_ad on seedance-2 (it handles the full hook→demo→payoff arc in one take) — never stitch those. Blocks until done. Spends credits.',
|
|
267
417
|
inputSchema: {
|
|
268
418
|
scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
|
|
@@ -273,7 +423,7 @@ export function registerTools(server) {
|
|
|
273
423
|
model: z.string().optional(),
|
|
274
424
|
durationSeconds: z.number().optional(),
|
|
275
425
|
},
|
|
276
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
426
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
277
427
|
}, wrap(async (a) => {
|
|
278
428
|
// HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
|
|
279
429
|
// clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
|
|
@@ -295,6 +445,7 @@ export function registerTools(server) {
|
|
|
295
445
|
}));
|
|
296
446
|
|
|
297
447
|
server.registerTool('get_job', {
|
|
448
|
+
title: 'Get render job',
|
|
298
449
|
description: 'Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall.',
|
|
299
450
|
inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
|
|
300
451
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -310,6 +461,7 @@ export function registerTools(server) {
|
|
|
310
461
|
|
|
311
462
|
// ---------- skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles) ----------
|
|
312
463
|
server.registerTool('list_skills', {
|
|
464
|
+
title: 'List skills',
|
|
313
465
|
description: 'List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.',
|
|
314
466
|
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
315
467
|
}, wrap(async () => {
|
|
@@ -333,6 +485,7 @@ export function registerTools(server) {
|
|
|
333
485
|
}));
|
|
334
486
|
|
|
335
487
|
server.registerTool('get_skill', {
|
|
488
|
+
title: 'Get skill',
|
|
336
489
|
description: 'Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.',
|
|
337
490
|
inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
|
|
338
491
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -345,6 +498,7 @@ export function registerTools(server) {
|
|
|
345
498
|
}));
|
|
346
499
|
|
|
347
500
|
server.registerTool('list_jobs', {
|
|
501
|
+
title: 'List render jobs',
|
|
348
502
|
description: 'List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.',
|
|
349
503
|
inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
|
|
350
504
|
}, wrap(async () => {
|
|
@@ -355,6 +509,7 @@ export function registerTools(server) {
|
|
|
355
509
|
|
|
356
510
|
// ---------- research / discovery ----------
|
|
357
511
|
server.registerTool('find_competitors', {
|
|
512
|
+
title: 'Find competitors',
|
|
358
513
|
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 ScrapeCreators credits.",
|
|
359
514
|
inputSchema: {
|
|
360
515
|
domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
|
|
@@ -368,6 +523,7 @@ export function registerTools(server) {
|
|
|
368
523
|
}));
|
|
369
524
|
|
|
370
525
|
server.registerTool('pull_competitor_ads', {
|
|
526
|
+
title: 'Pull competitor ads',
|
|
371
527
|
description: 'Pull a brand\'s real running ads across Meta / Google / LinkedIn ad libraries (deduped, sorted, right page resolved). Spends ScrapeCreators credits.',
|
|
372
528
|
inputSchema: {
|
|
373
529
|
companyName: z.string().optional().describe('the advertiser name'),
|
|
@@ -384,6 +540,7 @@ export function registerTools(server) {
|
|
|
384
540
|
}));
|
|
385
541
|
|
|
386
542
|
server.registerTool('research_ads', {
|
|
543
|
+
title: 'Research ads',
|
|
387
544
|
description: 'Natural-language ad research: a Claude tool-use loop over Meta/Google/LinkedIn ad libraries + organic TikTok. Returns a summary + the found ads (with their served URLs). Spends LLM tokens + ScrapeCreators credits.',
|
|
388
545
|
inputSchema: {
|
|
389
546
|
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
@@ -405,6 +562,7 @@ export function registerTools(server) {
|
|
|
405
562
|
const adsOut = (label, total, items) => ok(JSON.stringify({ found: total, showing: items.length, [label]: items }), { found: total, [label]: items }); // compact JSON summary, never the raw firehose
|
|
406
563
|
|
|
407
564
|
server.registerTool('search_meta_ads', {
|
|
565
|
+
title: 'Search Meta ads',
|
|
408
566
|
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 ScrapeCreators credits (~1–2).",
|
|
409
567
|
inputSchema: {
|
|
410
568
|
query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
|
|
@@ -435,6 +593,7 @@ export function registerTools(server) {
|
|
|
435
593
|
}));
|
|
436
594
|
|
|
437
595
|
server.registerTool('search_google_ads', {
|
|
596
|
+
title: 'Search Google ads',
|
|
438
597
|
description: "Structured Google Ads Transparency pull for ONE advertiser (by domain or advertiserId) — use when you know the brand; use research_ads for open-ended research. Deliberately fetches the cheap BASIC listing (get_ad_details=false, ~1 credit — the detailed variant with per-ad headlines costs 25 credits/call and is not exposed here). Returns compact JSON {advertiser, format, adUrl, image, firstShown, lastShown} per ad.",
|
|
439
598
|
inputSchema: {
|
|
440
599
|
domain: z.string().optional().describe("the advertiser's domain, e.g. nike.com"),
|
|
@@ -452,6 +611,7 @@ export function registerTools(server) {
|
|
|
452
611
|
}));
|
|
453
612
|
|
|
454
613
|
server.registerTool('search_linkedin_ads', {
|
|
614
|
+
title: 'Search LinkedIn ads',
|
|
455
615
|
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 ScrapeCreators credits (~1).",
|
|
456
616
|
inputSchema: {
|
|
457
617
|
company: z.string().optional().describe('advertiser company name'),
|
|
@@ -473,6 +633,7 @@ export function registerTools(server) {
|
|
|
473
633
|
}));
|
|
474
634
|
|
|
475
635
|
server.registerTool('search_tiktok', {
|
|
636
|
+
title: 'Search TikTok',
|
|
476
637
|
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 ScrapeCreators credits (~1).",
|
|
477
638
|
inputSchema: {
|
|
478
639
|
query: z.string().describe('keyword or hashtag (no # needed)'),
|
|
@@ -493,6 +654,7 @@ export function registerTools(server) {
|
|
|
493
654
|
}));
|
|
494
655
|
|
|
495
656
|
server.registerTool('search_instagram', {
|
|
657
|
+
title: 'Search Instagram',
|
|
496
658
|
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — ScrapeCreators' only IG keyword surface; profile/hashtag pulls go through scrapecreators_fetch with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends ScrapeCreators credits (~1).",
|
|
497
659
|
inputSchema: {
|
|
498
660
|
query: z.string().describe('keyword to search reels for'),
|
|
@@ -515,6 +677,7 @@ export function registerTools(server) {
|
|
|
515
677
|
}));
|
|
516
678
|
|
|
517
679
|
server.registerTool('search_youtube', {
|
|
680
|
+
title: 'Search YouTube',
|
|
518
681
|
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 ScrapeCreators credits (~1).",
|
|
519
682
|
inputSchema: {
|
|
520
683
|
query: z.string().describe('keyword to search videos for'),
|
|
@@ -531,6 +694,7 @@ export function registerTools(server) {
|
|
|
531
694
|
}));
|
|
532
695
|
|
|
533
696
|
server.registerTool('search_reddit', {
|
|
697
|
+
title: 'Search Reddit',
|
|
534
698
|
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 ScrapeCreators credits (~1).",
|
|
535
699
|
inputSchema: {
|
|
536
700
|
query: z.string().describe('what to search Reddit for'),
|
|
@@ -548,6 +712,7 @@ export function registerTools(server) {
|
|
|
548
712
|
}));
|
|
549
713
|
|
|
550
714
|
server.registerTool('search_threads', {
|
|
715
|
+
title: 'Search Threads',
|
|
551
716
|
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 ScrapeCreators credits (~1).",
|
|
552
717
|
inputSchema: {
|
|
553
718
|
query: z.string().describe('keyword to search Threads for'),
|
|
@@ -569,6 +734,7 @@ export function registerTools(server) {
|
|
|
569
734
|
}));
|
|
570
735
|
|
|
571
736
|
server.registerTool('scrapecreators_fetch', {
|
|
737
|
+
title: 'Fetch ScrapeCreators endpoint',
|
|
572
738
|
description: "Generic ScrapeCreators escape hatch for any ALLOWLISTED long-tail 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 ScrapeCreators credits.",
|
|
573
739
|
inputSchema: {
|
|
574
740
|
path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
|
|
@@ -583,6 +749,7 @@ export function registerTools(server) {
|
|
|
583
749
|
|
|
584
750
|
// ---------- brand onboarding ----------
|
|
585
751
|
server.registerTool('get_brand', {
|
|
752
|
+
title: 'Get saved brand',
|
|
586
753
|
description: 'What Hermoso ALREADY KNOWS for this account/workspace — the same saved brand profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call this FIRST: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. 0 credits.',
|
|
587
754
|
inputSchema: {},
|
|
588
755
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
@@ -595,7 +762,8 @@ export function registerTools(server) {
|
|
|
595
762
|
}));
|
|
596
763
|
|
|
597
764
|
server.registerTool('draft_brand', {
|
|
598
|
-
|
|
765
|
+
title: 'Draft brand profile',
|
|
766
|
+
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 ScrapeCreators 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.',
|
|
599
767
|
inputSchema: {
|
|
600
768
|
domain: z.string().optional().describe('a website to scrape'),
|
|
601
769
|
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
@@ -603,7 +771,7 @@ export function registerTools(server) {
|
|
|
603
771
|
platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
|
|
604
772
|
save: z.boolean().optional().describe('save as the workspace’s brand (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to overwrite, false to never save'),
|
|
605
773
|
},
|
|
606
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
774
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
607
775
|
}, wrap(async ({ save, ...a }) => {
|
|
608
776
|
const d = await apiPost('/api/brand/draft', a);
|
|
609
777
|
const p = d.profile || d;
|
|
@@ -623,6 +791,7 @@ export function registerTools(server) {
|
|
|
623
791
|
|
|
624
792
|
// ---------- assets ----------
|
|
625
793
|
server.registerTool('fetch_asset', {
|
|
794
|
+
title: 'Fetch asset',
|
|
626
795
|
description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
|
|
627
796
|
inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional() },
|
|
628
797
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -634,6 +803,7 @@ export function registerTools(server) {
|
|
|
634
803
|
|
|
635
804
|
// ---------- post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route) ----------
|
|
636
805
|
server.registerTool('analyze_video', {
|
|
806
|
+
title: 'Analyze video',
|
|
637
807
|
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; no ScrapeCreators credits.",
|
|
638
808
|
inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
|
|
639
809
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
@@ -648,6 +818,7 @@ export function registerTools(server) {
|
|
|
648
818
|
}));
|
|
649
819
|
|
|
650
820
|
server.registerTool('score_ad', {
|
|
821
|
+
title: 'Score ad',
|
|
651
822
|
description: "Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.",
|
|
652
823
|
inputSchema: {
|
|
653
824
|
url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
|
|
@@ -663,49 +834,54 @@ export function registerTools(server) {
|
|
|
663
834
|
}));
|
|
664
835
|
|
|
665
836
|
server.registerTool('reframe_video', {
|
|
837
|
+
title: 'Reframe video',
|
|
666
838
|
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master → 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
|
|
667
839
|
inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
|
|
668
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
840
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
669
841
|
}, wrap(async ({ video, aspectRatio }) => {
|
|
670
842
|
const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
|
|
671
843
|
return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
|
|
672
844
|
}));
|
|
673
845
|
|
|
674
846
|
server.registerTool('upscale_video', {
|
|
847
|
+
title: 'Upscale video',
|
|
675
848
|
description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
|
|
676
849
|
inputSchema: { video: z.string().describe('the source video URL') },
|
|
677
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
850
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
678
851
|
}, wrap(async ({ video }) => {
|
|
679
852
|
const r = await renderJob('upscale', { video, factor: 2 }, 'Upscale 2x');
|
|
680
853
|
return okVideo(`Upscaled video: ${r.url}`, r);
|
|
681
854
|
}));
|
|
682
855
|
|
|
683
856
|
server.registerTool('dub_video', {
|
|
857
|
+
title: 'Dub video',
|
|
684
858
|
description: "Remake a finished video ad's voiceover in another language (translated script, re-voiced, re-muxed). Paid; returns the served URL of the localized video.",
|
|
685
859
|
inputSchema: {
|
|
686
860
|
video: z.string().describe('the source video URL'),
|
|
687
861
|
language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
|
|
688
862
|
script: z.string().optional().describe('the original spoken script if known — improves translation fidelity'),
|
|
689
863
|
},
|
|
690
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
864
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
691
865
|
}, wrap(async ({ video, language, script }) => {
|
|
692
866
|
const r = await renderJob('dub', { video, language, script: script || '' }, `Dub → ${language}`);
|
|
693
867
|
return okVideo(`Localized video (${language}): ${r.url}`, r);
|
|
694
868
|
}));
|
|
695
869
|
|
|
696
870
|
server.registerTool('change_voice', {
|
|
871
|
+
title: 'Change narrator voice',
|
|
697
872
|
description: "Swap the narration of a finished video into a different voice — keeps the performance, lip-sync, and background sound. Use when the user likes the video but wants a different narrator voice; use dub_video only for language translation. Paid; returns the served URL.",
|
|
698
873
|
inputSchema: {
|
|
699
874
|
video: z.string().describe('the source video URL'),
|
|
700
875
|
voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)"),
|
|
701
876
|
},
|
|
702
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
877
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
703
878
|
}, wrap(async ({ video, voice }) => {
|
|
704
879
|
const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
|
|
705
880
|
return okVideo(`Voice-swapped video: ${r.url}`, r);
|
|
706
881
|
}));
|
|
707
882
|
|
|
708
883
|
server.registerTool('recast_motion', {
|
|
884
|
+
title: 'Recast motion',
|
|
709
885
|
description: "Motion transfer: re-perform a reference video's motion with a different person/character (supply their image). The reference clip drives the movement; the image supplies the identity. Paid render.",
|
|
710
886
|
inputSchema: {
|
|
711
887
|
image: z.string().describe("the actor/character image URL (who should appear)"),
|
|
@@ -713,13 +889,14 @@ export function registerTools(server) {
|
|
|
713
889
|
prompt: z.string().optional().describe('optional scene/style guidance'),
|
|
714
890
|
orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
|
|
715
891
|
},
|
|
716
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
892
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
717
893
|
}, wrap(async ({ image, video, prompt = '', orientation = 'video' }) => {
|
|
718
894
|
const r = await renderJob('motion', { image, video, prompt, orientation }, 'Motion recast');
|
|
719
895
|
return okVideo(`Recast video: ${r.url}`, r);
|
|
720
896
|
}));
|
|
721
897
|
|
|
722
898
|
server.registerTool('plan_variations', {
|
|
899
|
+
title: 'Plan ad variations',
|
|
723
900
|
description: "Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself.",
|
|
724
901
|
inputSchema: {
|
|
725
902
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
|
|
@@ -727,7 +904,7 @@ export function registerTools(server) {
|
|
|
727
904
|
count: z.number().int().min(2).max(8).optional().describe('how many distinct variants (default 6)'),
|
|
728
905
|
language: z.string().optional(),
|
|
729
906
|
},
|
|
730
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
907
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
731
908
|
}, wrap(async ({ brand, product, count = 6, language }) => {
|
|
732
909
|
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
|
|
733
910
|
const d = await apiPost('/api/batch/plan', { brand: brandObj, product, count, language: language || '' });
|
|
@@ -753,13 +930,14 @@ export function registerTools(server) {
|
|
|
753
930
|
};
|
|
754
931
|
|
|
755
932
|
server.registerTool('competitor_teardown', {
|
|
933
|
+
title: 'Competitor teardown',
|
|
756
934
|
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 ~1-2 ScrapeCreators credits, longest-running = proven winners). Auto-tailors the white space + counter-plays to YOUR saved brand. Spends LLM tokens (0 SC credits when you pass ads).",
|
|
757
935
|
inputSchema: {
|
|
758
936
|
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'),
|
|
759
937
|
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.'),
|
|
760
938
|
language: z.string().optional().describe('output language (default English)'),
|
|
761
939
|
},
|
|
762
|
-
annotations: { readOnlyHint:
|
|
940
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
763
941
|
}, wrap(async ({ competitor, ads, language }) => {
|
|
764
942
|
const name = String(competitor?.name || '').trim();
|
|
765
943
|
if (!name) throw new Error('competitor.name is required.');
|
|
@@ -781,6 +959,7 @@ export function registerTools(server) {
|
|
|
781
959
|
}));
|
|
782
960
|
|
|
783
961
|
server.registerTool('check_ad_policy', {
|
|
962
|
+
title: 'Check ad policy',
|
|
784
963
|
description: "Pre-flight ad copy against Meta's REAL, live Advertising Standards before you run it — a flat 1-credit check. Pulls Meta's actual policy pages and returns a verdict (pass / fix / block) where every flagged issue QUOTES Meta's own policy text verbatim plus a compliant rewrite that keeps the sell. It's a check, not an edit — it never changes the creative. Especially worth running for regulated-adjacent categories (health/supplements, weight-loss or beauty results claims, finance/crypto/insurance, alcohol, dating, gambling) or ANY strong/absolute/guaranteed claim.",
|
|
785
964
|
inputSchema: {
|
|
786
965
|
copy: z.string().describe('the ad copy / script / on-screen text to check'),
|
|
@@ -798,12 +977,13 @@ export function registerTools(server) {
|
|
|
798
977
|
}));
|
|
799
978
|
|
|
800
979
|
server.registerTool('remix_static', {
|
|
980
|
+
title: 'Remix a static ad',
|
|
801
981
|
description: "One-click STATIC-AD REMIX: rebuild a competitor/reference STATIC (image) ad as an on-brand version — SAME layout, composition and energy, but YOUR product, brand colours, logo and voice, with every trace of the source brand removed. Pass `imageUrl` = the static ad image to remix. Uses your saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). IMAGES ONLY — for video ads use render_ad. Bills as one image generation.",
|
|
802
982
|
inputSchema: {
|
|
803
983
|
imageUrl: z.string().describe('the URL of the static ad image to remix'),
|
|
804
984
|
brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
|
|
805
985
|
},
|
|
806
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
986
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
807
987
|
}, wrap(async ({ imageUrl, brandId }) => {
|
|
808
988
|
const brand = await activeBrand(brandId);
|
|
809
989
|
if (!brand) throw new Error('No saved brand to remix for — onboard one with draft_brand, or pass a brandId from list_brands.');
|
|
@@ -816,6 +996,7 @@ export function registerTools(server) {
|
|
|
816
996
|
}));
|
|
817
997
|
|
|
818
998
|
server.registerTool('mine_angles', {
|
|
999
|
+
title: 'Mine customer angles',
|
|
819
1000
|
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 ScrapeCreators credits + LLM tokens.",
|
|
820
1001
|
inputSchema: {
|
|
821
1002
|
brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
|
|
@@ -833,6 +1014,7 @@ export function registerTools(server) {
|
|
|
833
1014
|
|
|
834
1015
|
// ---------- product-photo tools (Studio-chat parity) ----------
|
|
835
1016
|
server.registerTool('list_product_photos', {
|
|
1017
|
+
title: 'List product photos',
|
|
836
1018
|
description: "List the product photos ALREADY saved in your workspace — the brand's product library plus any app-store screens (also surfaces photos locked in your OTHER creations, since a set product lands in the shared library). FREE — returns each photo's url + label. Call it before set_product_image to see the existing photos you can reuse. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand).",
|
|
837
1019
|
inputSchema: {
|
|
838
1020
|
brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
|
|
@@ -845,13 +1027,14 @@ export function registerTools(server) {
|
|
|
845
1027
|
}));
|
|
846
1028
|
|
|
847
1029
|
server.registerTool('set_product_image', {
|
|
1030
|
+
title: 'Set product photo',
|
|
848
1031
|
description: "Lock an image as the ad's real PRODUCT photo so every render grounds on the true packaging. Pass `imageUrl` = a product shot's URL — an image from a prior research result (an organic Instagram/TikTok post, a scraped page image), a workspace / list_product_photos url, or any public product photo. The server downloads it and runs a product+safety check: a lifestyle/scene shot with no clear product, or an off-category / unsafe image, is REJECTED and NOTHING is locked (the summary says why). On PASS it persists the photo to a DURABLE url and returns it — pass that url as a reference to generate_image / render_ad. Bills one vision check. Reads YOUR saved brand for the category match (pass brandId to target a specific brand — switches this key's active brand like use_brand).",
|
|
849
1032
|
inputSchema: {
|
|
850
1033
|
imageUrl: z.string().describe('the image URL to lock as the product (from a research result, a workspace / list_product_photos url, or any public product photo)'),
|
|
851
1034
|
source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
|
|
852
1035
|
brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
|
|
853
1036
|
},
|
|
854
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1037
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
855
1038
|
}, wrap(async ({ imageUrl, source_note, brandId }) => {
|
|
856
1039
|
const brand = await activeBrand(brandId);
|
|
857
1040
|
const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
+
"description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
|
7
8
|
"hermoso": "bin/hermoso.mjs"
|
|
@@ -13,14 +14,26 @@
|
|
|
13
14
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
14
15
|
},
|
|
15
16
|
"keywords": [
|
|
16
|
-
"mcp",
|
|
17
|
-
"model-context-protocol",
|
|
18
|
-
"ai-ads",
|
|
19
17
|
"ad-generator",
|
|
20
|
-
"
|
|
18
|
+
"ad-library",
|
|
19
|
+
"ads",
|
|
20
|
+
"advertising",
|
|
21
|
+
"ai-ads",
|
|
22
|
+
"ai-agents",
|
|
23
|
+
"ai-video",
|
|
21
24
|
"claude",
|
|
22
25
|
"cli",
|
|
23
|
-
"
|
|
26
|
+
"competitor-analysis",
|
|
27
|
+
"image-generation",
|
|
28
|
+
"marketing",
|
|
29
|
+
"mcp",
|
|
30
|
+
"mcp-server",
|
|
31
|
+
"model-context-protocol",
|
|
32
|
+
"seedance",
|
|
33
|
+
"ugc",
|
|
34
|
+
"veo",
|
|
35
|
+
"video-ads",
|
|
36
|
+
"video-generation"
|
|
24
37
|
],
|
|
25
38
|
"homepage": "https://hermoso.ai",
|
|
26
39
|
"repository": {
|