hermoso 0.1.5 → 0.1.7
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/mcp/hermoso-mcp.mjs +4 -2
- package/mcp/http.mjs +2 -2
- package/mcp/tools.mjs +488 -63
- package/package.json +19 -7
package/mcp/tools.mjs
CHANGED
|
@@ -8,12 +8,36 @@ import { apiGet, apiPost, apiPut, apiSSE, submitJob, getJob, jobResult, pollJob,
|
|
|
8
8
|
|
|
9
9
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
10
10
|
const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
|
|
11
|
-
const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredContent: data ??
|
|
11
|
+
const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredContent: data ?? {} });
|
|
12
12
|
// Video-return variant: attaches the clip's first frame as an inline image block (Claude can't play mp4 in chat,
|
|
13
13
|
// but a poster makes the result VISIBLE, mirroring generate_image). Falls back to plain ok() when frames fail.
|
|
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
|
-
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 ??
|
|
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 ?? {} }; };
|
|
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 (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).',
|
|
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 (one-click top-up / first-purchase 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: with a saved card + admin rights it one-click charges after an explicit confirm:true (state the exact price first); the FIRST purchase is a Stripe link your human pays, which saves the card. 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 += `\nRun buy_credits to
|
|
68
|
+
if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes then one-click charges on confirm:true; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
|
|
45
69
|
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
46
70
|
}
|
|
47
71
|
};
|
|
@@ -62,55 +86,197 @@ async function renderJob(type, input, label) {
|
|
|
62
86
|
}
|
|
63
87
|
}
|
|
64
88
|
|
|
89
|
+
// Shared outputSchema fields for the job-based render tools (the renderJob result that becomes structuredContent).
|
|
90
|
+
// Every field is optional so validation can never fail on a sparse or still-rendering result.
|
|
91
|
+
const JOB_OUT = {
|
|
92
|
+
jobId: z.string().optional().describe('the render job id — poll get_job with this id to resume or inspect'),
|
|
93
|
+
url: z.string().nullable().optional().describe('the served URL of the finished media (absent/null while still rendering)'),
|
|
94
|
+
model: z.string().nullable().optional().describe('the product-facing label of the model that rendered it'),
|
|
95
|
+
raw: z.any().optional().describe('the raw job result payload (e.g. images[] for carousel template ads)'),
|
|
96
|
+
stillRendering: z.boolean().optional().describe('true when the render is still in progress — keep polling get_job with jobId'),
|
|
97
|
+
};
|
|
98
|
+
|
|
65
99
|
export function registerTools(server) {
|
|
66
100
|
// ---------- read-only / discovery ----------
|
|
67
101
|
server.registerTool('hermoso_capabilities', {
|
|
102
|
+
title: 'Hermoso capabilities',
|
|
68
103
|
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
|
-
inputSchema: {},
|
|
104
|
+
inputSchema: {}, outputSchema: {
|
|
105
|
+
image: z.any().optional().describe('the default image provider label, or null when image generation is unavailable'),
|
|
106
|
+
video: z.any().optional().describe('the default video provider label, or null when video generation is unavailable'),
|
|
107
|
+
canEdit: z.boolean().optional().describe('whether image editing is enabled on this account'),
|
|
108
|
+
canAvatar: z.boolean().optional().describe('whether talking-avatar generation is enabled'),
|
|
109
|
+
canPublish: z.boolean().optional().describe('whether ad publishing is enabled'),
|
|
110
|
+
editCredits: z.number().optional().describe('credit cost of one image edit'),
|
|
111
|
+
options: z.any().optional().describe('the live model catalog — image/video/voice/llm model lists with per-model credit costs'),
|
|
112
|
+
recipes: z.array(z.any()).optional().describe('the creative recipe catalog (id + label per recipe)'),
|
|
113
|
+
},
|
|
114
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
70
115
|
}, wrap(async () => {
|
|
71
116
|
const d = await apiGet('/api/generate/status');
|
|
72
117
|
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
118
|
// durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
|
|
74
119
|
// prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
|
|
75
120
|
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('; ');
|
|
76
|
-
|
|
121
|
+
// voice engines (generate_voice) + writing models (generate_text) — so the RAW PLAYGROUND is usable from one probe
|
|
122
|
+
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';
|
|
123
|
+
const llm = d.options?.llm ? (d.options.llm.models || []).map(m => `${m.id} (${m.label})`).join('; ') : 'unavailable';
|
|
124
|
+
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
125
|
return ok(text, d);
|
|
78
126
|
}));
|
|
79
127
|
|
|
80
128
|
server.registerTool('hermoso_credits', {
|
|
129
|
+
title: 'Credit balance',
|
|
81
130
|
description: 'Return the account credit balance, credits used this session, and recent priced calls. Check before kicking off paid generation.',
|
|
82
|
-
inputSchema: {},
|
|
131
|
+
inputSchema: {}, outputSchema: {
|
|
132
|
+
accountBalance: z.number().nullable().optional().describe('the account’s Hermoso credit balance (authoritative when authed)'),
|
|
133
|
+
balance: z.number().optional().describe('raw vendor meter balance (operator/local-dev surface)'),
|
|
134
|
+
sessionStart: z.number().nullable().optional().describe('vendor balance at session start (operator surface)'),
|
|
135
|
+
sessionUsed: z.number().optional().describe('credits used this session'),
|
|
136
|
+
recentCalls: z.array(z.any()).optional().describe('recent priced calls with their credit deltas'),
|
|
137
|
+
},
|
|
138
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
83
139
|
}, wrap(async () => {
|
|
84
140
|
const d = await apiGet('/api/credits');
|
|
85
141
|
const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
|
|
86
142
|
return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
|
|
87
143
|
}));
|
|
88
144
|
|
|
89
|
-
// AGENT BILLING
|
|
90
|
-
//
|
|
91
|
-
//
|
|
145
|
+
// AGENT BILLING: out of credits → top up. With a saved card + billing-admin rights this is the SAME one-click
|
|
146
|
+
// off-session charge the web app's Add-credits button uses (explicit confirm:true required — an agent states the
|
|
147
|
+
// exact charge before any money moves). First-ever purchase (no card on file) goes through a Stripe checkout link
|
|
148
|
+
// the human pays once — that card then saves for one-click forever. Packs only — subscriptions are in-app.
|
|
92
149
|
server.registerTool('buy_credits', {
|
|
93
|
-
|
|
150
|
+
title: 'Buy credits',
|
|
151
|
+
description: "Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings → Billing.",
|
|
94
152
|
inputSchema: {
|
|
95
153
|
pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
|
|
154
|
+
confirm: z.boolean().optional().describe('set true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path)'),
|
|
96
155
|
},
|
|
97
|
-
|
|
98
|
-
|
|
156
|
+
outputSchema: {
|
|
157
|
+
packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing'),
|
|
158
|
+
quote: z.any().optional().describe('the one-click charge quote ({packId, credits, priceUsd, card}) awaiting confirm:true'),
|
|
159
|
+
ok: z.boolean().optional().describe('true when a one-click top-up charge succeeded'),
|
|
160
|
+
credits: z.number().optional().describe('credits added by a completed top-up (or bought by the checkout link)'),
|
|
161
|
+
url: z.string().optional().describe('Stripe checkout URL for a first purchase (no saved card yet)'),
|
|
162
|
+
amountUsd: z.number().optional().describe('USD amount of the checkout link'),
|
|
163
|
+
packId: z.string().optional().describe('the pack id the checkout link buys'),
|
|
164
|
+
},
|
|
165
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, // confirm:true charges the saved card (one-click top-up); link path charges nothing
|
|
166
|
+
}, wrap(async ({ pack, confirm }) => {
|
|
99
167
|
const cfg = await apiGet('/api/billing/config');
|
|
100
168
|
const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
|
|
101
169
|
if (!pack) {
|
|
102
170
|
const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
|
|
103
|
-
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
|
|
171
|
+
return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>". With a saved card it's a one-click charge (you'll be asked to confirm); otherwise you get a checkout link for your human.`, { packs });
|
|
104
172
|
}
|
|
105
173
|
const match = packs.find(p => p.id === pack);
|
|
106
174
|
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 });
|
|
175
|
+
let st = null;
|
|
176
|
+
try { st = await apiGet('/api/billing/status'); } catch {}
|
|
177
|
+
if (st?.paymentMethodOnFile && st?.isAdmin) {
|
|
178
|
+
const card = st.card ? `${st.card.brand} ····${st.card.last4}` : 'the saved card';
|
|
179
|
+
if (!confirm) return ok(`Ready to charge ${card} $${match.priceUsd} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}" and confirm:true.`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null } });
|
|
180
|
+
const d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: (globalThis.crypto?.randomUUID?.() || String(Date.now())) });
|
|
181
|
+
return ok(`Done — charged ${card} $${match.priceUsd}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Settings → Billing → invoice history.)`, d);
|
|
182
|
+
}
|
|
107
183
|
const d = await apiPost('/api/billing/checkout-link', { packId: pack });
|
|
108
|
-
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 —
|
|
184
|
+
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 — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay.`, d);
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
// BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
|
|
188
|
+
// billing_status (full picture + your role) → upgrade_plan / set_auto_reload (admin-only, pay-on-Stripe / in-app).
|
|
189
|
+
server.registerTool('billing_status', {
|
|
190
|
+
title: 'Billing status',
|
|
191
|
+
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.",
|
|
192
|
+
inputSchema: {}, outputSchema: {
|
|
193
|
+
plan: z.any().optional().describe('the current plan ({id, label, monthlyUsd})'),
|
|
194
|
+
balanceCredits: z.number().optional().describe('the current credit balance'),
|
|
195
|
+
autoReload: z.any().optional().describe('auto-reload config ({enabled, thresholdCredits, reloadCredits, available})'),
|
|
196
|
+
paymentMethodOnFile: z.boolean().optional().describe('whether a card is saved for one-click charges'),
|
|
197
|
+
card: z.any().optional().describe('the saved card ({brand, last4}) when present'),
|
|
198
|
+
role: z.string().optional().describe('this key’s billing role (admin/member)'),
|
|
199
|
+
isAdmin: z.boolean().optional().describe('whether this key can change billing'),
|
|
200
|
+
},
|
|
201
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
202
|
+
}, wrap(async () => {
|
|
203
|
+
const d = await apiGet('/api/billing/status');
|
|
204
|
+
const ar = d.autoReload || {};
|
|
205
|
+
const arLine = ar.available === false ? 'set in the app (not via API)' : (ar.enabled ? `on (below ${ar.thresholdCredits} cr → +${ar.reloadCredits} cr)` : 'off');
|
|
206
|
+
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'}`;
|
|
207
|
+
return ok(text, d);
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
// AGENT BILLING HANDOFF (plans): mint a ready-to-pay Stripe SUBSCRIPTION link for a NEW subscriber; existing-sub
|
|
211
|
+
// changes + downgrades are made in-app (the tool returns exactly what to do). Admin-only; a human always pays.
|
|
212
|
+
server.registerTool('upgrade_plan', {
|
|
213
|
+
title: 'Upgrade plan',
|
|
214
|
+
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.",
|
|
215
|
+
inputSchema: {
|
|
216
|
+
plan: z.string().optional().describe('the plan id to move to (e.g. pro) — omit to list the available plans first'),
|
|
217
|
+
period: z.enum(['mo', 'yr']).optional().describe('billing cadence — monthly (default) or yearly (2 months free)'),
|
|
218
|
+
},
|
|
219
|
+
outputSchema: {
|
|
220
|
+
plans: z.array(z.any()).optional().describe('available paid plans ({id, name, priceUsd, credits}) when listing'),
|
|
221
|
+
mode: z.string().optional().describe("'checkout' (a Stripe URL was minted) or 'in_app' (a person makes the change in the app)"),
|
|
222
|
+
url: z.string().optional().describe('the ready-to-pay Stripe Checkout URL (checkout mode)'),
|
|
223
|
+
plan: z.string().optional().describe('the target plan id'),
|
|
224
|
+
planLabel: z.string().optional().describe('the target plan display name'),
|
|
225
|
+
monthlyUsd: z.number().optional().describe('the plan’s monthly price in USD'),
|
|
226
|
+
chargeUsd: z.number().optional().describe('the actual charge amount (yearly billing charges the annual total)'),
|
|
227
|
+
period: z.string().optional().describe("billing cadence of the link — 'mo' or 'yr'"),
|
|
228
|
+
action: z.string().optional().describe("the in-app action required ('upgrade' or 'downgrade')"),
|
|
229
|
+
guidance: z.string().optional().describe('exact instructions when the change must be made in the app'),
|
|
230
|
+
},
|
|
231
|
+
annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe / in-app
|
|
232
|
+
}, wrap(async ({ plan, period }) => {
|
|
233
|
+
const cfg = await apiGet('/api/billing/config');
|
|
234
|
+
const plans = (cfg.plans || []).filter(p => p.priceUsd > 0).map(p => ({ id: p.id, name: p.name, priceUsd: p.priceUsd, credits: p.credits }));
|
|
235
|
+
if (!plan) {
|
|
236
|
+
const lines = plans.map(p => `• ${p.id} — ${p.name}: $${p.priceUsd}/mo · ${p.credits.toLocaleString()} credits/mo`).join('\n') || '(no plans configured)';
|
|
237
|
+
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 });
|
|
238
|
+
}
|
|
239
|
+
const d = await apiPost('/api/billing/plan-link', { planId: plan, period });
|
|
240
|
+
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);
|
|
241
|
+
return ok(d.guidance, d); // in_app — an existing-subscriber upgrade or a downgrade (done by a person in the app)
|
|
242
|
+
}));
|
|
243
|
+
|
|
244
|
+
// Standing auto-reload config — a REAL server-side write now (persists on the account + fires even with no app open).
|
|
245
|
+
// Admin-only; requires a card on file (added ONCE in the app, then agents manage top-ups/auto-reload/plan links fully).
|
|
246
|
+
server.registerTool('set_auto_reload', {
|
|
247
|
+
title: 'Set auto-reload',
|
|
248
|
+
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.",
|
|
249
|
+
inputSchema: {
|
|
250
|
+
enabled: z.boolean().describe('true to turn auto-reload on, false to turn it off'),
|
|
251
|
+
thresholdCredits: z.number().int().optional().describe('reload when the balance drops below this many credits'),
|
|
252
|
+
reloadCredits: z.number().int().optional().describe('how many credits to add each reload — must match a credit pack size (see buy_credits)'),
|
|
253
|
+
},
|
|
254
|
+
outputSchema: {
|
|
255
|
+
applied: z.boolean().optional().describe('whether the auto-reload config was applied'),
|
|
256
|
+
needsCard: z.boolean().optional().describe('true when there is no saved card yet (add one in the app first)'),
|
|
257
|
+
enabled: z.boolean().optional().describe('the resulting auto-reload state'),
|
|
258
|
+
thresholdCredits: z.number().nullable().optional().describe('reload triggers below this balance'),
|
|
259
|
+
reloadCredits: z.number().nullable().optional().describe('credits added per reload'),
|
|
260
|
+
reloadPack: z.any().optional().describe('the pack charged on each reload'),
|
|
261
|
+
capUsd: z.any().optional().describe('monthly auto-reload spend cap in USD, if set'),
|
|
262
|
+
status: z.string().optional().describe('auto-reload status detail'),
|
|
263
|
+
guidance: z.string().optional().describe('instructions when the change must be made in the app'),
|
|
264
|
+
},
|
|
265
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
266
|
+
}, wrap(async ({ enabled, thresholdCredits, reloadCredits }) => {
|
|
267
|
+
const d = await apiPost('/api/billing/autoreload-config', { enabled, thresholdCredits, reloadCredits });
|
|
268
|
+
if (d.needsCard) return ok(d.guidance || 'Add a card on file first (in the app), then auto-reload can use it.', d);
|
|
269
|
+
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);
|
|
270
|
+
return ok(d.guidance || 'Manage auto-reload in the app: Settings → Billing → Auto-reload.', d);
|
|
109
271
|
}));
|
|
110
272
|
|
|
111
273
|
server.registerTool('list_brands', {
|
|
274
|
+
title: 'List brands',
|
|
112
275
|
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.",
|
|
113
|
-
inputSchema: {},
|
|
276
|
+
inputSchema: {}, outputSchema: {
|
|
277
|
+
brands: z.array(z.any()).optional().describe('every brand on the account ({id, name, active})'),
|
|
278
|
+
},
|
|
279
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
114
280
|
}, wrap(async () => {
|
|
115
281
|
const d = await apiGet('/api/brands');
|
|
116
282
|
const lines = (d.brands || []).map(b => `• ${b.name} (id: ${b.id})${b.active ? ' ← active' : ''}`).join('\n');
|
|
@@ -118,9 +284,14 @@ export function registerTools(server) {
|
|
|
118
284
|
}));
|
|
119
285
|
|
|
120
286
|
server.registerTool('use_brand', {
|
|
287
|
+
title: 'Switch brand',
|
|
121
288
|
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.",
|
|
122
289
|
inputSchema: { brand: z.string().describe('brand id (e.g. default / p_xxx) or its exact name from list_brands') },
|
|
123
|
-
|
|
290
|
+
outputSchema: {
|
|
291
|
+
ok: z.boolean().optional().describe('true when the brand switch persisted'),
|
|
292
|
+
brand: z.any().optional().describe('the now-active brand ({id, name})'),
|
|
293
|
+
},
|
|
294
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
124
295
|
}, wrap(async ({ brand }) => {
|
|
125
296
|
const d = await apiGet('/api/brands');
|
|
126
297
|
const want = String(brand || '').trim().toLowerCase();
|
|
@@ -132,6 +303,7 @@ export function registerTools(server) {
|
|
|
132
303
|
|
|
133
304
|
// ---------- planning (LLM, 0 SC credits) ----------
|
|
134
305
|
server.registerTool('plan_ad', {
|
|
306
|
+
title: 'Plan an ad concept',
|
|
135
307
|
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.',
|
|
136
308
|
inputSchema: {
|
|
137
309
|
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'),
|
|
@@ -139,29 +311,51 @@ export function registerTools(server) {
|
|
|
139
311
|
format: z.enum(['auto', 'image', 'video']).optional().describe("'image', 'video', or 'auto' when unspecified"),
|
|
140
312
|
recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
|
|
141
313
|
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)'),
|
|
142
|
-
language: z.string().optional(),
|
|
314
|
+
language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
|
|
315
|
+
},
|
|
316
|
+
outputSchema: {
|
|
317
|
+
format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
|
|
318
|
+
concept: z.string().optional().describe('the one-line creative concept'),
|
|
319
|
+
recipe: z.string().optional().describe('the resolved recipe id'),
|
|
320
|
+
recipe_label: z.string().optional().describe('the resolved recipe display name'),
|
|
321
|
+
copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
|
|
322
|
+
image_concept: z.any().optional().describe('the render-ready image concept (prompt etc.) when format is image'),
|
|
323
|
+
video_storyboard: z.any().optional().describe('the timed storyboard (scenes, cta, music) when format is video'),
|
|
324
|
+
render_plan: z.any().optional().describe('the routing plan (structure/duration) render_ad honors'),
|
|
325
|
+
imodel: z.string().optional().describe('the image model id to render with'),
|
|
326
|
+
vmodel: z.string().optional().describe('the video model id to render with'),
|
|
327
|
+
brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
|
|
143
328
|
},
|
|
144
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
329
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
145
330
|
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
|
|
146
331
|
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
|
|
147
332
|
const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
|
|
148
333
|
const c = d.creative || d;
|
|
334
|
+
// EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
|
|
335
|
+
// grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
|
|
336
|
+
// /api/render/assemble prefers creative.brand, so "pass plan_ad's full output" now carries the right grounding.
|
|
337
|
+
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) };
|
|
149
338
|
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'}.`;
|
|
150
339
|
return ok(text, c);
|
|
151
340
|
}));
|
|
152
341
|
|
|
153
342
|
// ---------- image (synchronous) ----------
|
|
154
343
|
server.registerTool('generate_image', {
|
|
155
|
-
|
|
344
|
+
title: 'Generate ad image',
|
|
345
|
+
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.',
|
|
156
346
|
inputSchema: {
|
|
157
347
|
prompt: z.string().describe('the full image prompt — subject, composition, lighting, and any on-image ad text'),
|
|
158
348
|
refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in'),
|
|
159
349
|
useBrand: z.boolean().optional().describe('default true: with no refImages, the server hydrates the SAVED brand’s product/logo references so the output lands on-brand; pass false for a pure prompt-only render'),
|
|
160
350
|
aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
|
|
161
351
|
model: z.string().optional().describe('image model id from hermoso_capabilities'),
|
|
162
|
-
imageSize: z.string().optional(),
|
|
352
|
+
imageSize: z.string().optional().describe('pixel-size preset for models that support it (e.g. 1K/2K) — omit for the default'),
|
|
163
353
|
},
|
|
164
|
-
|
|
354
|
+
outputSchema: {
|
|
355
|
+
image: z.string().optional().describe('the served absolute URL of the finished image'),
|
|
356
|
+
model: z.string().optional().describe('the product-facing label of the model that rendered it'),
|
|
357
|
+
},
|
|
358
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
165
359
|
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
|
|
166
360
|
const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
|
|
167
361
|
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
|
|
@@ -169,14 +363,54 @@ export function registerTools(server) {
|
|
|
169
363
|
return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
170
364
|
}));
|
|
171
365
|
|
|
366
|
+
// ---------- raw playground: voice (TTS) + writing models ----------
|
|
367
|
+
server.registerTool('generate_voice', {
|
|
368
|
+
title: 'Generate voiceover',
|
|
369
|
+
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).",
|
|
370
|
+
inputSchema: {
|
|
371
|
+
text: z.string().describe('the script to speak (≤900 characters)'),
|
|
372
|
+
engine: z.string().optional().describe("voice-engine id: 'seed-audio' (default), 'eleven-v3', 'minimax-speech', or 'kokoro' — listed in hermoso_capabilities"),
|
|
373
|
+
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"),
|
|
374
|
+
},
|
|
375
|
+
outputSchema: {
|
|
376
|
+
audio: z.string().optional().describe('the served absolute URL of the MP3 voice clip'),
|
|
377
|
+
voice: z.string().optional().describe('the voice preset used'),
|
|
378
|
+
model: z.string().optional().describe('the voice engine label'),
|
|
379
|
+
creditsUsed: z.number().optional().describe('credits billed for this clip'),
|
|
380
|
+
},
|
|
381
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
382
|
+
}, wrap(async ({ text, engine, voice }) => {
|
|
383
|
+
const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}) });
|
|
384
|
+
return ok(`Voice clip ready — ${d.voice}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
|
|
385
|
+
}));
|
|
386
|
+
|
|
387
|
+
server.registerTool('generate_text', {
|
|
388
|
+
title: 'Generate text',
|
|
389
|
+
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).",
|
|
390
|
+
inputSchema: {
|
|
391
|
+
prompt: z.string().describe('the writing task / question'),
|
|
392
|
+
model: z.string().optional().describe('a writing-model id from hermoso_capabilities (a Claude / Gemini / GPT / Llama / DeepSeek id) — omit for the default'),
|
|
393
|
+
},
|
|
394
|
+
outputSchema: {
|
|
395
|
+
text: z.string().optional().describe('the generated text'),
|
|
396
|
+
model: z.string().optional().describe('the writing model label'),
|
|
397
|
+
creditsUsed: z.number().optional().describe('credits billed for this generation'),
|
|
398
|
+
},
|
|
399
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
400
|
+
}, wrap(async ({ prompt, model }) => {
|
|
401
|
+
const d = await apiPost('/api/models/llm', { prompt, ...(model ? { model } : {}) });
|
|
402
|
+
return ok(`${d.text}${d.model ? `\n\n— ${d.model}` : ''}`, d);
|
|
403
|
+
}));
|
|
404
|
+
|
|
172
405
|
// ---------- video / avatar / stitch (job-based, polled to completion) ----------
|
|
173
406
|
server.registerTool('render_ad', {
|
|
407
|
+
title: 'Render ad video',
|
|
174
408
|
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.',
|
|
175
409
|
inputSchema: {
|
|
176
410
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
177
411
|
model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
|
|
178
|
-
durationSeconds: z.number().optional(),
|
|
179
|
-
aspectRatio: z.string().optional(),
|
|
412
|
+
durationSeconds: z.number().optional().describe('total ad length in seconds — omit to honor the plan’s own duration'),
|
|
413
|
+
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
180
414
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
|
|
181
415
|
captions: z.boolean().optional().describe('composited caption pills on/off (default: the recipe decides)'),
|
|
182
416
|
endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
|
|
@@ -185,7 +419,13 @@ export function registerTools(server) {
|
|
|
185
419
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
186
420
|
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'),
|
|
187
421
|
},
|
|
188
|
-
|
|
422
|
+
outputSchema: {
|
|
423
|
+
...JOB_OUT,
|
|
424
|
+
dryRun: z.boolean().optional().describe('true when this was a dry run (no job submitted, nothing charged)'),
|
|
425
|
+
jobType: z.string().optional().describe("the routing decision — 'video' (single pass) or 'stitch' (acts)"),
|
|
426
|
+
input: z.any().optional().describe('the assembled render input (dry run only — resolved model, duration, scenes)'),
|
|
427
|
+
},
|
|
428
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
189
429
|
}, wrap(async (a) => {
|
|
190
430
|
const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
|
|
191
431
|
// LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
|
|
@@ -198,23 +438,26 @@ export function registerTools(server) {
|
|
|
198
438
|
|
|
199
439
|
|
|
200
440
|
server.registerTool('make_template_ad', {
|
|
441
|
+
title: 'Make template ad',
|
|
201
442
|
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.",
|
|
202
443
|
inputSchema: {
|
|
203
444
|
config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
|
|
204
445
|
},
|
|
205
|
-
|
|
446
|
+
outputSchema: { ...JOB_OUT },
|
|
447
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
206
448
|
}, wrap(async (a) => {
|
|
207
449
|
const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
|
|
208
450
|
if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
|
|
209
451
|
const urls = r.raw.images.map((u) => abs(u));
|
|
210
452
|
const first = await imageBlock(urls[0]).catch(() => null);
|
|
211
|
-
return { content: [{ type: 'text', text: `Carousel ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ??
|
|
453
|
+
return { content: [{ type: 'text', text: `Carousel ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? {} };
|
|
212
454
|
}
|
|
213
|
-
if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]` }, ...(img ? [img] : [])], structuredContent: r ??
|
|
455
|
+
if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]` }, ...(img ? [img] : [])], structuredContent: r ?? {} }; }
|
|
214
456
|
return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
215
457
|
}));
|
|
216
458
|
|
|
217
459
|
server.registerTool('finish_video', {
|
|
460
|
+
title: 'Finish video',
|
|
218
461
|
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.",
|
|
219
462
|
inputSchema: {
|
|
220
463
|
videoUrl: z.string().describe('the served URL of the video to finish (from a previous render/job)'),
|
|
@@ -225,13 +468,15 @@ export function registerTools(server) {
|
|
|
225
468
|
pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
|
|
226
469
|
grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
|
|
227
470
|
},
|
|
228
|
-
|
|
471
|
+
outputSchema: { ...JOB_OUT },
|
|
472
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
229
473
|
}, wrap(async (a) => {
|
|
230
474
|
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');
|
|
231
475
|
return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
|
|
232
476
|
}));
|
|
233
477
|
|
|
234
478
|
server.registerTool('fix_beat', {
|
|
479
|
+
title: 'Fix a video beat',
|
|
235
480
|
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.",
|
|
236
481
|
inputSchema: {
|
|
237
482
|
videoUrl: z.string().describe('the served URL of the master video to fix'),
|
|
@@ -241,26 +486,30 @@ export function registerTools(server) {
|
|
|
241
486
|
refImage: z.string().optional().describe('optional product/style anchor image URL'),
|
|
242
487
|
speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
|
|
243
488
|
},
|
|
244
|
-
|
|
489
|
+
outputSchema: { ...JOB_OUT },
|
|
490
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
245
491
|
}, wrap(async (a) => {
|
|
246
492
|
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');
|
|
247
493
|
return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
|
|
248
494
|
}));
|
|
249
495
|
|
|
250
496
|
server.registerTool('generate_video', {
|
|
251
|
-
|
|
497
|
+
title: 'Generate video',
|
|
498
|
+
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).',
|
|
252
499
|
inputSchema: {
|
|
253
|
-
prompt: z.string().describe('the video prompt / shot description'),
|
|
500
|
+
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
254
501
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
502
|
+
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."),
|
|
255
503
|
durationSeconds: z.number().optional().describe('clip length in seconds'),
|
|
256
504
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
257
505
|
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'),
|
|
258
506
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
|
|
259
507
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
260
508
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
261
|
-
musicMood: z.string().optional(),
|
|
509
|
+
musicMood: z.string().optional().describe('licensed music-bed mood (e.g. upbeat / cinematic) — omit for no music bed'),
|
|
262
510
|
},
|
|
263
|
-
|
|
511
|
+
outputSchema: { ...JOB_OUT },
|
|
512
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
264
513
|
}, wrap(async (a) => {
|
|
265
514
|
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
266
515
|
// an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
|
|
@@ -270,6 +519,7 @@ export function registerTools(server) {
|
|
|
270
519
|
}));
|
|
271
520
|
|
|
272
521
|
server.registerTool('generate_avatar', {
|
|
522
|
+
title: 'Generate talking avatar',
|
|
273
523
|
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.',
|
|
274
524
|
inputSchema: {
|
|
275
525
|
image: z.string().describe('local path or URL of the presenter portrait'),
|
|
@@ -277,7 +527,8 @@ export function registerTools(server) {
|
|
|
277
527
|
voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
|
|
278
528
|
resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
|
|
279
529
|
},
|
|
280
|
-
|
|
530
|
+
outputSchema: { ...JOB_OUT },
|
|
531
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
281
532
|
}, wrap(async (a) => {
|
|
282
533
|
const image = await toRef(a.image);
|
|
283
534
|
const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
|
|
@@ -285,17 +536,19 @@ export function registerTools(server) {
|
|
|
285
536
|
}));
|
|
286
537
|
|
|
287
538
|
server.registerTool('stitch_video', {
|
|
539
|
+
title: 'Stitch multi-scene video',
|
|
288
540
|
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.',
|
|
289
541
|
inputSchema: {
|
|
290
542
|
scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
|
|
291
|
-
aspectRatio: z.string().optional(),
|
|
292
|
-
voiceover: z.string().optional(),
|
|
293
|
-
voice: z.string().optional(),
|
|
294
|
-
resolution: z.string().optional(),
|
|
295
|
-
model: z.string().optional(),
|
|
296
|
-
durationSeconds: z.number().optional(),
|
|
297
|
-
},
|
|
298
|
-
|
|
543
|
+
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
544
|
+
voiceover: z.string().optional().describe('full voiceover script spoken across the scenes'),
|
|
545
|
+
voice: z.string().optional().describe('voiceover voice name, e.g. Rachel / George'),
|
|
546
|
+
resolution: z.string().optional().describe('720p (default), 480p draft, or 1080p final'),
|
|
547
|
+
model: z.string().optional().describe('video model id from hermoso_capabilities — omit to let the router pick'),
|
|
548
|
+
durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
|
|
549
|
+
},
|
|
550
|
+
outputSchema: { ...JOB_OUT },
|
|
551
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
299
552
|
}, wrap(async (a) => {
|
|
300
553
|
// HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
|
|
301
554
|
// clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
|
|
@@ -317,8 +570,18 @@ export function registerTools(server) {
|
|
|
317
570
|
}));
|
|
318
571
|
|
|
319
572
|
server.registerTool('get_job', {
|
|
573
|
+
title: 'Get render job',
|
|
320
574
|
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.',
|
|
321
575
|
inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
|
|
576
|
+
outputSchema: {
|
|
577
|
+
id: z.string().optional().describe('the job id'),
|
|
578
|
+
status: z.string().optional().describe('queued | running | done | error'),
|
|
579
|
+
progress: z.number().optional().describe('0–1 progress when reported'),
|
|
580
|
+
error: z.string().nullable().optional().describe('the failure message when status is error'),
|
|
581
|
+
url: z.string().nullable().optional().describe('the served media URL once done'),
|
|
582
|
+
type: z.string().optional().describe('the job type (video / stitch / avatar / …)'),
|
|
583
|
+
result: z.any().optional().describe('the raw job result payload'),
|
|
584
|
+
},
|
|
322
585
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
323
586
|
}, wrap(async ({ id }) => {
|
|
324
587
|
const j = await getJob(id);
|
|
@@ -332,8 +595,13 @@ export function registerTools(server) {
|
|
|
332
595
|
|
|
333
596
|
// ---------- skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles) ----------
|
|
334
597
|
server.registerTool('list_skills', {
|
|
598
|
+
title: 'List skills',
|
|
335
599
|
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.',
|
|
336
|
-
inputSchema: {},
|
|
600
|
+
inputSchema: {}, outputSchema: {
|
|
601
|
+
bundles: z.array(z.any()).optional().describe('bundled skills ({name, description}) loadable via get_skill'),
|
|
602
|
+
inApp: z.array(z.any()).optional().describe('in-app strategy skills + creative recipes ({id, kind/group})'),
|
|
603
|
+
},
|
|
604
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
337
605
|
}, wrap(async () => {
|
|
338
606
|
const { readdir, readFile } = await import('node:fs/promises');
|
|
339
607
|
const dir = new URL('../skills/', import.meta.url);
|
|
@@ -355,8 +623,12 @@ export function registerTools(server) {
|
|
|
355
623
|
}));
|
|
356
624
|
|
|
357
625
|
server.registerTool('get_skill', {
|
|
626
|
+
title: 'Get skill',
|
|
358
627
|
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.',
|
|
359
628
|
inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
|
|
629
|
+
outputSchema: {
|
|
630
|
+
name: z.string().optional().describe('the loaded skill bundle name'),
|
|
631
|
+
},
|
|
360
632
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
361
633
|
}, wrap(async ({ name }) => {
|
|
362
634
|
const safe = String(name).replace(/[^a-z0-9-]/gi, '');
|
|
@@ -367,8 +639,13 @@ export function registerTools(server) {
|
|
|
367
639
|
}));
|
|
368
640
|
|
|
369
641
|
server.registerTool('list_jobs', {
|
|
642
|
+
title: 'List render jobs',
|
|
370
643
|
description: 'List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.',
|
|
371
|
-
inputSchema: {},
|
|
644
|
+
inputSchema: {}, outputSchema: {
|
|
645
|
+
running: z.number().optional().describe('how many jobs are currently running'),
|
|
646
|
+
jobs: z.array(z.any()).optional().describe('recent jobs ({id, type, status, …}), newest first'),
|
|
647
|
+
},
|
|
648
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
372
649
|
}, wrap(async () => {
|
|
373
650
|
const d = await apiGet('/api/jobs');
|
|
374
651
|
const lines = (d.jobs || []).slice(0, 12).map(j => `${j.id} ${j.type} ${j.status}`).join('\n');
|
|
@@ -377,10 +654,15 @@ export function registerTools(server) {
|
|
|
377
654
|
|
|
378
655
|
// ---------- research / discovery ----------
|
|
379
656
|
server.registerTool('find_competitors', {
|
|
657
|
+
title: 'Find competitors',
|
|
380
658
|
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.",
|
|
381
659
|
inputSchema: {
|
|
382
660
|
domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
|
|
383
|
-
mode: z.enum(['competitors', 'inspiration', 'company']).optional(),
|
|
661
|
+
mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
|
|
662
|
+
},
|
|
663
|
+
outputSchema: {
|
|
664
|
+
candidates: z.array(z.any()).optional().describe('discovered brands ({name, domain, kind, reason})'),
|
|
665
|
+
diagnostics: z.any().optional().describe('discovery diagnostics (LLM tokens, web grounding)'),
|
|
384
666
|
},
|
|
385
667
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
386
668
|
}, wrap(async ({ domain, mode = 'competitors' }) => {
|
|
@@ -390,15 +672,21 @@ export function registerTools(server) {
|
|
|
390
672
|
}));
|
|
391
673
|
|
|
392
674
|
server.registerTool('pull_competitor_ads', {
|
|
675
|
+
title: 'Pull competitor ads',
|
|
393
676
|
description: 'Pull a brand\'s real running ads across Meta / Google / LinkedIn ad libraries (deduped, sorted, right page resolved). Spends ScrapeCreators credits.',
|
|
394
677
|
inputSchema: {
|
|
395
678
|
companyName: z.string().optional().describe('the advertiser name'),
|
|
396
679
|
domain: z.string().optional().describe('the advertiser domain'),
|
|
397
680
|
platforms: z.array(z.string()).optional().describe("default ['facebook']; add 'google','linkedin'"),
|
|
398
681
|
country: z.string().optional().describe("2-letter, default 'US'"),
|
|
399
|
-
limit: z.number().optional(),
|
|
682
|
+
limit: z.number().optional().describe('max ads per platform (default 30)'),
|
|
400
683
|
sort: z.string().optional().describe("'longest_running' (default) etc."),
|
|
401
684
|
},
|
|
685
|
+
outputSchema: {
|
|
686
|
+
facebook: z.any().optional().describe('Meta results ({ads[], matched} or {error}; null when not requested)'),
|
|
687
|
+
google: z.any().optional().describe('Google results ({ads[], cursor} or {error}; null when not requested)'),
|
|
688
|
+
linkedin: z.any().optional().describe('LinkedIn results ({ads[], cursor} or {error}; null when not requested)'),
|
|
689
|
+
},
|
|
402
690
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
403
691
|
}, wrap(async (a) => {
|
|
404
692
|
const d = await apiPost('/api/inspire/fanout', { platforms: ['facebook'], country: 'US', limit: 30, sort: 'longest_running', ...a });
|
|
@@ -406,10 +694,16 @@ export function registerTools(server) {
|
|
|
406
694
|
}));
|
|
407
695
|
|
|
408
696
|
server.registerTool('research_ads', {
|
|
697
|
+
title: 'Research ads',
|
|
409
698
|
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.',
|
|
410
699
|
inputSchema: {
|
|
411
700
|
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
412
|
-
brand: z.union([z.string(), z.object({}).passthrough()]).optional(),
|
|
701
|
+
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'),
|
|
702
|
+
},
|
|
703
|
+
outputSchema: {
|
|
704
|
+
reply: z.string().optional().describe('the research summary'),
|
|
705
|
+
results: z.array(z.any()).optional().describe('the found ads/videos (normalized card objects with served URLs)'),
|
|
706
|
+
actions: z.any().optional().describe('follow-up actions the research loop suggested'),
|
|
413
707
|
},
|
|
414
708
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
415
709
|
}, wrap(async ({ query, brand }) => {
|
|
@@ -427,6 +721,7 @@ export function registerTools(server) {
|
|
|
427
721
|
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
|
|
428
722
|
|
|
429
723
|
server.registerTool('search_meta_ads', {
|
|
724
|
+
title: 'Search Meta ads',
|
|
430
725
|
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).",
|
|
431
726
|
inputSchema: {
|
|
432
727
|
query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
|
|
@@ -434,9 +729,13 @@ export function registerTools(server) {
|
|
|
434
729
|
pageId: z.string().optional().describe('one advertiser’s ads by Facebook page id (most precise)'),
|
|
435
730
|
country: z.string().optional().describe("2-letter code or 'ALL' (default ALL)"),
|
|
436
731
|
status: z.enum(['ACTIVE', 'INACTIVE', 'ALL']).optional().describe("ACTIVE = currently running; default ALL (includes proven past winners)"),
|
|
437
|
-
mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional(),
|
|
732
|
+
mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional().describe('filter by creative type (default ALL)'),
|
|
438
733
|
limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
|
|
439
734
|
},
|
|
735
|
+
outputSchema: {
|
|
736
|
+
found: z.number().optional().describe('total ads found upstream'),
|
|
737
|
+
ads: z.array(z.any()).optional().describe('the compact ad objects ({page_name, body, cta, link, dates, media})'),
|
|
738
|
+
},
|
|
440
739
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
441
740
|
}, wrap(async (a) => {
|
|
442
741
|
if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
|
|
@@ -457,6 +756,7 @@ export function registerTools(server) {
|
|
|
457
756
|
}));
|
|
458
757
|
|
|
459
758
|
server.registerTool('search_google_ads', {
|
|
759
|
+
title: 'Search Google ads',
|
|
460
760
|
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.",
|
|
461
761
|
inputSchema: {
|
|
462
762
|
domain: z.string().optional().describe("the advertiser's domain, e.g. nike.com"),
|
|
@@ -464,6 +764,10 @@ export function registerTools(server) {
|
|
|
464
764
|
region: z.string().optional().describe('2-letter region, default US'),
|
|
465
765
|
limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
|
|
466
766
|
},
|
|
767
|
+
outputSchema: {
|
|
768
|
+
found: z.number().optional().describe('total ads found upstream'),
|
|
769
|
+
ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, format, adUrl, image, firstShown, lastShown})'),
|
|
770
|
+
},
|
|
467
771
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
468
772
|
}, wrap(async (a) => {
|
|
469
773
|
if (!a.domain && !a.advertiserId) throw new Error('Pass domain or advertiserId.');
|
|
@@ -474,14 +778,19 @@ export function registerTools(server) {
|
|
|
474
778
|
}));
|
|
475
779
|
|
|
476
780
|
server.registerTool('search_linkedin_ads', {
|
|
781
|
+
title: 'Search LinkedIn ads',
|
|
477
782
|
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).",
|
|
478
783
|
inputSchema: {
|
|
479
784
|
company: z.string().optional().describe('advertiser company name'),
|
|
480
785
|
keyword: z.string().optional().describe('keyword across all advertisers'),
|
|
481
|
-
companyId: z.string().optional(),
|
|
786
|
+
companyId: z.string().optional().describe('LinkedIn company id (numeric) when the name is ambiguous'),
|
|
482
787
|
countries: z.string().optional().describe("CSV of 2-letter codes like 'US,CA'; omit or 'ALL' = worldwide"),
|
|
483
788
|
limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
|
|
484
789
|
},
|
|
790
|
+
outputSchema: {
|
|
791
|
+
found: z.number().optional().describe('total ads found upstream'),
|
|
792
|
+
ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, headline, description, cta, link, media, dates, impressions})'),
|
|
793
|
+
},
|
|
485
794
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
486
795
|
}, wrap(async (a) => {
|
|
487
796
|
if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
|
|
@@ -495,11 +804,16 @@ export function registerTools(server) {
|
|
|
495
804
|
}));
|
|
496
805
|
|
|
497
806
|
server.registerTool('search_tiktok', {
|
|
807
|
+
title: 'Search TikTok',
|
|
498
808
|
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).",
|
|
499
809
|
inputSchema: {
|
|
500
810
|
query: z.string().describe('keyword or hashtag (no # needed)'),
|
|
501
811
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
502
812
|
},
|
|
813
|
+
outputSchema: {
|
|
814
|
+
found: z.number().optional().describe('total videos found'),
|
|
815
|
+
videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
|
|
816
|
+
},
|
|
503
817
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
504
818
|
}, wrap(async ({ query, limit }) => {
|
|
505
819
|
const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
|
|
@@ -515,11 +829,16 @@ export function registerTools(server) {
|
|
|
515
829
|
}));
|
|
516
830
|
|
|
517
831
|
server.registerTool('search_instagram', {
|
|
832
|
+
title: 'Search Instagram',
|
|
518
833
|
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).",
|
|
519
834
|
inputSchema: {
|
|
520
835
|
query: z.string().describe('keyword to search reels for'),
|
|
521
836
|
limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
|
|
522
837
|
},
|
|
838
|
+
outputSchema: {
|
|
839
|
+
found: z.number().optional().describe('total reels found'),
|
|
840
|
+
reels: z.array(z.any()).optional().describe('the compact reel objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
|
|
841
|
+
},
|
|
523
842
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
524
843
|
}, wrap(async ({ query, limit }) => {
|
|
525
844
|
const d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query });
|
|
@@ -537,11 +856,16 @@ export function registerTools(server) {
|
|
|
537
856
|
}));
|
|
538
857
|
|
|
539
858
|
server.registerTool('search_youtube', {
|
|
859
|
+
title: 'Search YouTube',
|
|
540
860
|
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).",
|
|
541
861
|
inputSchema: {
|
|
542
862
|
query: z.string().describe('keyword to search videos for'),
|
|
543
863
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
544
864
|
},
|
|
865
|
+
outputSchema: {
|
|
866
|
+
found: z.number().optional().describe('total videos found'),
|
|
867
|
+
videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, link, cover}), ranked by views'),
|
|
868
|
+
},
|
|
545
869
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
546
870
|
}, wrap(async ({ query, limit }) => {
|
|
547
871
|
const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
|
|
@@ -553,11 +877,16 @@ export function registerTools(server) {
|
|
|
553
877
|
}));
|
|
554
878
|
|
|
555
879
|
server.registerTool('search_reddit', {
|
|
880
|
+
title: 'Search Reddit',
|
|
556
881
|
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).",
|
|
557
882
|
inputSchema: {
|
|
558
883
|
query: z.string().describe('what to search Reddit for'),
|
|
559
884
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
560
885
|
},
|
|
886
|
+
outputSchema: {
|
|
887
|
+
found: z.number().optional().describe('total posts found'),
|
|
888
|
+
posts: z.array(z.any()).optional().describe('the compact post objects ({desc, subreddit, upvotes, comments, link})'),
|
|
889
|
+
},
|
|
561
890
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
562
891
|
}, wrap(async ({ query, limit }) => {
|
|
563
892
|
const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'top' });
|
|
@@ -570,11 +899,16 @@ export function registerTools(server) {
|
|
|
570
899
|
}));
|
|
571
900
|
|
|
572
901
|
server.registerTool('search_threads', {
|
|
902
|
+
title: 'Search Threads',
|
|
573
903
|
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).",
|
|
574
904
|
inputSchema: {
|
|
575
905
|
query: z.string().describe('keyword to search Threads for'),
|
|
576
906
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
577
907
|
},
|
|
908
|
+
outputSchema: {
|
|
909
|
+
found: z.number().optional().describe('total posts found'),
|
|
910
|
+
posts: z.array(z.any()).optional().describe('the compact post objects ({desc, author, handle, likes, link, cover})'),
|
|
911
|
+
},
|
|
578
912
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
579
913
|
}, wrap(async ({ query, limit }) => {
|
|
580
914
|
const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
|
|
@@ -591,11 +925,13 @@ export function registerTools(server) {
|
|
|
591
925
|
}));
|
|
592
926
|
|
|
593
927
|
server.registerTool('scrapecreators_fetch', {
|
|
928
|
+
title: 'Fetch ScrapeCreators endpoint',
|
|
594
929
|
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.",
|
|
595
930
|
inputSchema: {
|
|
596
931
|
path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
|
|
597
932
|
params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
|
|
598
933
|
},
|
|
934
|
+
outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
|
|
599
935
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
600
936
|
}, wrap(async ({ path, params }) => {
|
|
601
937
|
const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
|
|
@@ -605,8 +941,14 @@ export function registerTools(server) {
|
|
|
605
941
|
|
|
606
942
|
// ---------- brand onboarding ----------
|
|
607
943
|
server.registerTool('get_brand', {
|
|
944
|
+
title: 'Get saved brand',
|
|
608
945
|
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.',
|
|
609
946
|
inputSchema: {},
|
|
947
|
+
outputSchema: {
|
|
948
|
+
hasBrand: z.boolean().optional().describe('whether a brand is saved for this workspace'),
|
|
949
|
+
brand: z.any().optional().describe('the saved brand profile (name, domain, category, products, palette, …) or null'),
|
|
950
|
+
memoryCount: z.number().optional().describe('how many learned memory notes the workspace holds'),
|
|
951
|
+
},
|
|
610
952
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
611
953
|
}, wrap(async () => {
|
|
612
954
|
const d = await apiGet('/api/brand/current');
|
|
@@ -617,15 +959,27 @@ export function registerTools(server) {
|
|
|
617
959
|
}));
|
|
618
960
|
|
|
619
961
|
server.registerTool('draft_brand', {
|
|
962
|
+
title: 'Draft brand profile',
|
|
620
963
|
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.',
|
|
621
964
|
inputSchema: {
|
|
622
965
|
domain: z.string().optional().describe('a website to scrape'),
|
|
623
966
|
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
624
|
-
socialHandle: z.string().optional(),
|
|
967
|
+
socialHandle: z.string().optional().describe('a social handle to draft from (influencers/creators) — pair with platform'),
|
|
625
968
|
platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
|
|
626
969
|
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'),
|
|
627
970
|
},
|
|
628
|
-
|
|
971
|
+
outputSchema: {
|
|
972
|
+
name: z.string().optional().describe('the drafted brand name — VERIFY it matches the brand the user meant'),
|
|
973
|
+
domain: z.string().optional().describe('the brand website domain (empty for non-website drafts)'),
|
|
974
|
+
category: z.string().optional().describe('the detected category'),
|
|
975
|
+
summary: z.string().optional().describe('a short positioning summary'),
|
|
976
|
+
sells: z.any().optional().describe('what the brand sells'),
|
|
977
|
+
logo: z.string().optional().describe('the detected logo URL'),
|
|
978
|
+
palette: z.array(z.any()).optional().describe('the brand colors'),
|
|
979
|
+
products: z.any().optional().describe('the detected products'),
|
|
980
|
+
productImages: z.array(z.any()).optional().describe('product photo URLs'),
|
|
981
|
+
},
|
|
982
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
629
983
|
}, wrap(async ({ save, ...a }) => {
|
|
630
984
|
const d = await apiPost('/api/brand/draft', a);
|
|
631
985
|
const p = d.profile || d;
|
|
@@ -645,8 +999,13 @@ export function registerTools(server) {
|
|
|
645
999
|
|
|
646
1000
|
// ---------- assets ----------
|
|
647
1001
|
server.registerTool('fetch_asset', {
|
|
1002
|
+
title: 'Fetch asset',
|
|
648
1003
|
description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
|
|
649
|
-
inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional() },
|
|
1004
|
+
inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional().describe('optional filename for the download') },
|
|
1005
|
+
outputSchema: {
|
|
1006
|
+
url: z.string().optional().describe('the clickable absolute asset URL'),
|
|
1007
|
+
downloadUrl: z.string().optional().describe('a direct download URL for the asset'),
|
|
1008
|
+
},
|
|
650
1009
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
651
1010
|
}, wrap(async ({ url, name }) => {
|
|
652
1011
|
const absolute = abs(url);
|
|
@@ -656,8 +1015,14 @@ export function registerTools(server) {
|
|
|
656
1015
|
|
|
657
1016
|
// ---------- post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route) ----------
|
|
658
1017
|
server.registerTool('analyze_video', {
|
|
1018
|
+
title: 'Analyze video',
|
|
659
1019
|
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.",
|
|
660
1020
|
inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
|
|
1021
|
+
outputSchema: {
|
|
1022
|
+
durationSeconds: z.number().optional().describe('the video length in seconds'),
|
|
1023
|
+
frameTimes: z.array(z.number()).optional().describe('timestamps (seconds) of the sampled frames'),
|
|
1024
|
+
transcript: z.string().nullable().optional().describe('verbatim voiceover + on-screen text with a beat list (null when silent/unreachable)'),
|
|
1025
|
+
},
|
|
661
1026
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
662
1027
|
}, wrap(async ({ url }) => {
|
|
663
1028
|
const [fr, tr] = await Promise.all([
|
|
@@ -670,12 +1035,20 @@ export function registerTools(server) {
|
|
|
670
1035
|
}));
|
|
671
1036
|
|
|
672
1037
|
server.registerTool('score_ad', {
|
|
1038
|
+
title: 'Score ad',
|
|
673
1039
|
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.",
|
|
674
1040
|
inputSchema: {
|
|
675
1041
|
url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
|
|
676
|
-
kind: z.enum(['image', 'video']).optional(),
|
|
1042
|
+
kind: z.enum(['image', 'video']).optional().describe("'image' (default) or 'video'"),
|
|
677
1043
|
intent: z.string().optional().describe('what the ad is trying to achieve, for goal-fit scoring'),
|
|
678
1044
|
},
|
|
1045
|
+
outputSchema: {
|
|
1046
|
+
overall: z.number().optional().describe('the overall score out of 100'),
|
|
1047
|
+
tier: z.string().optional().describe('the qualitative tier'),
|
|
1048
|
+
dimensions: z.array(z.any()).optional().describe('per-dimension breakdown ({name, score})'),
|
|
1049
|
+
top_fix: z.string().optional().describe('the single biggest improvement lever'),
|
|
1050
|
+
strengths: z.any().optional().describe('what the ad already does well'),
|
|
1051
|
+
},
|
|
679
1052
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
680
1053
|
}, wrap(async ({ url, kind = 'image', intent = '' }) => {
|
|
681
1054
|
const d = await apiPost('/api/score/ad', { url, kind, intent, format: kind });
|
|
@@ -685,49 +1058,58 @@ export function registerTools(server) {
|
|
|
685
1058
|
}));
|
|
686
1059
|
|
|
687
1060
|
server.registerTool('reframe_video', {
|
|
1061
|
+
title: 'Reframe video',
|
|
688
1062
|
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.",
|
|
689
1063
|
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') },
|
|
690
|
-
|
|
1064
|
+
outputSchema: { ...JOB_OUT },
|
|
1065
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
691
1066
|
}, wrap(async ({ video, aspectRatio }) => {
|
|
692
1067
|
const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
|
|
693
1068
|
return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
|
|
694
1069
|
}));
|
|
695
1070
|
|
|
696
1071
|
server.registerTool('upscale_video', {
|
|
1072
|
+
title: 'Upscale video',
|
|
697
1073
|
description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
|
|
698
1074
|
inputSchema: { video: z.string().describe('the source video URL') },
|
|
699
|
-
|
|
1075
|
+
outputSchema: { ...JOB_OUT },
|
|
1076
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
700
1077
|
}, wrap(async ({ video }) => {
|
|
701
1078
|
const r = await renderJob('upscale', { video, factor: 2 }, 'Upscale 2x');
|
|
702
1079
|
return okVideo(`Upscaled video: ${r.url}`, r);
|
|
703
1080
|
}));
|
|
704
1081
|
|
|
705
1082
|
server.registerTool('dub_video', {
|
|
1083
|
+
title: 'Dub video',
|
|
706
1084
|
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.",
|
|
707
1085
|
inputSchema: {
|
|
708
1086
|
video: z.string().describe('the source video URL'),
|
|
709
1087
|
language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
|
|
710
1088
|
script: z.string().optional().describe('the original spoken script if known — improves translation fidelity'),
|
|
711
1089
|
},
|
|
712
|
-
|
|
1090
|
+
outputSchema: { ...JOB_OUT },
|
|
1091
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
713
1092
|
}, wrap(async ({ video, language, script }) => {
|
|
714
1093
|
const r = await renderJob('dub', { video, language, script: script || '' }, `Dub → ${language}`);
|
|
715
1094
|
return okVideo(`Localized video (${language}): ${r.url}`, r);
|
|
716
1095
|
}));
|
|
717
1096
|
|
|
718
1097
|
server.registerTool('change_voice', {
|
|
1098
|
+
title: 'Change narrator voice',
|
|
719
1099
|
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.",
|
|
720
1100
|
inputSchema: {
|
|
721
1101
|
video: z.string().describe('the source video URL'),
|
|
722
1102
|
voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)"),
|
|
723
1103
|
},
|
|
724
|
-
|
|
1104
|
+
outputSchema: { ...JOB_OUT },
|
|
1105
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
725
1106
|
}, wrap(async ({ video, voice }) => {
|
|
726
1107
|
const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
|
|
727
1108
|
return okVideo(`Voice-swapped video: ${r.url}`, r);
|
|
728
1109
|
}));
|
|
729
1110
|
|
|
730
1111
|
server.registerTool('recast_motion', {
|
|
1112
|
+
title: 'Recast motion',
|
|
731
1113
|
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.",
|
|
732
1114
|
inputSchema: {
|
|
733
1115
|
image: z.string().describe("the actor/character image URL (who should appear)"),
|
|
@@ -735,21 +1117,27 @@ export function registerTools(server) {
|
|
|
735
1117
|
prompt: z.string().optional().describe('optional scene/style guidance'),
|
|
736
1118
|
orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
|
|
737
1119
|
},
|
|
738
|
-
|
|
1120
|
+
outputSchema: { ...JOB_OUT },
|
|
1121
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
739
1122
|
}, wrap(async ({ image, video, prompt = '', orientation = 'video' }) => {
|
|
740
1123
|
const r = await renderJob('motion', { image, video, prompt, orientation }, 'Motion recast');
|
|
741
1124
|
return okVideo(`Recast video: ${r.url}`, r);
|
|
742
1125
|
}));
|
|
743
1126
|
|
|
744
1127
|
server.registerTool('plan_variations', {
|
|
1128
|
+
title: 'Plan ad variations',
|
|
745
1129
|
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.",
|
|
746
1130
|
inputSchema: {
|
|
747
1131
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
|
|
748
1132
|
product: z.string().describe('what to advertise'),
|
|
749
1133
|
count: z.number().int().min(2).max(8).optional().describe('how many distinct variants (default 6)'),
|
|
750
|
-
language: z.string().optional(),
|
|
1134
|
+
language: z.string().optional().describe('output language for the variant copy (e.g. Spanish) — default English'),
|
|
1135
|
+
},
|
|
1136
|
+
outputSchema: {
|
|
1137
|
+
variants: z.array(z.any()).optional().describe('the distinct ad angles ({name, hook, headline, visual brief})'),
|
|
1138
|
+
angles: z.array(z.any()).optional().describe('alternate key the planner may return the variants under'),
|
|
751
1139
|
},
|
|
752
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint:
|
|
1140
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
753
1141
|
}, wrap(async ({ brand, product, count = 6, language }) => {
|
|
754
1142
|
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
|
|
755
1143
|
const d = await apiPost('/api/batch/plan', { brand: brandObj, product, count, language: language || '' });
|
|
@@ -775,13 +1163,18 @@ export function registerTools(server) {
|
|
|
775
1163
|
};
|
|
776
1164
|
|
|
777
1165
|
server.registerTool('competitor_teardown', {
|
|
1166
|
+
title: 'Competitor teardown',
|
|
778
1167
|
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).",
|
|
779
1168
|
inputSchema: {
|
|
780
1169
|
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'),
|
|
781
1170
|
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.'),
|
|
782
1171
|
language: z.string().optional().describe('output language (default English)'),
|
|
783
1172
|
},
|
|
784
|
-
|
|
1173
|
+
outputSchema: {
|
|
1174
|
+
teardown: z.any().optional().describe('the playbook — hook_taxonomy, campaigns, white_space, counter_plays, not_saying'),
|
|
1175
|
+
adCount: z.number().optional().describe('how many ads were analyzed'),
|
|
1176
|
+
},
|
|
1177
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
785
1178
|
}, wrap(async ({ competitor, ads, language }) => {
|
|
786
1179
|
const name = String(competitor?.name || '').trim();
|
|
787
1180
|
if (!name) throw new Error('competitor.name is required.');
|
|
@@ -803,6 +1196,7 @@ export function registerTools(server) {
|
|
|
803
1196
|
}));
|
|
804
1197
|
|
|
805
1198
|
server.registerTool('check_ad_policy', {
|
|
1199
|
+
title: 'Check ad policy',
|
|
806
1200
|
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.",
|
|
807
1201
|
inputSchema: {
|
|
808
1202
|
copy: z.string().describe('the ad copy / script / on-screen text to check'),
|
|
@@ -810,6 +1204,12 @@ export function registerTools(server) {
|
|
|
810
1204
|
category: z.string().optional().describe('the product category — helps pick the relevant policy pages'),
|
|
811
1205
|
imageDescription: z.string().optional().describe('a description of the creative / image when relevant'),
|
|
812
1206
|
},
|
|
1207
|
+
outputSchema: {
|
|
1208
|
+
verdict: z.string().optional().describe('pass / fix / block'),
|
|
1209
|
+
summary: z.string().optional().describe('one-line verdict summary'),
|
|
1210
|
+
findings: z.array(z.any()).optional().describe('flagged issues ({severity, issue, policy_quote, fix_suggestion, where_in_ad})'),
|
|
1211
|
+
anchors: z.array(z.any()).optional().describe('the Meta policy pages consulted ({url, …})'),
|
|
1212
|
+
},
|
|
813
1213
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
814
1214
|
}, wrap(async ({ copy, claims, category, imageDescription }) => {
|
|
815
1215
|
const d = await apiPost('/api/policy/check', { copy, claims: claims || '', category: category || '', imageDescription: imageDescription || '' });
|
|
@@ -820,12 +1220,19 @@ export function registerTools(server) {
|
|
|
820
1220
|
}));
|
|
821
1221
|
|
|
822
1222
|
server.registerTool('remix_static', {
|
|
1223
|
+
title: 'Remix a static ad',
|
|
823
1224
|
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.",
|
|
824
1225
|
inputSchema: {
|
|
825
1226
|
imageUrl: z.string().describe('the URL of the static ad image to remix'),
|
|
826
1227
|
brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
|
|
827
1228
|
},
|
|
828
|
-
|
|
1229
|
+
outputSchema: {
|
|
1230
|
+
image: z.string().optional().describe('the served absolute URL of the remixed ad image'),
|
|
1231
|
+
model: z.string().optional().describe('the model label that rendered it'),
|
|
1232
|
+
slots: z.any().optional().describe('the filled slot map (layout elements swapped to your brand)'),
|
|
1233
|
+
residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
|
|
1234
|
+
},
|
|
1235
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
829
1236
|
}, wrap(async ({ imageUrl, brandId }) => {
|
|
830
1237
|
const brand = await activeBrand(brandId);
|
|
831
1238
|
if (!brand) throw new Error('No saved brand to remix for — onboard one with draft_brand, or pass a brandId from list_brands.');
|
|
@@ -838,10 +1245,16 @@ export function registerTools(server) {
|
|
|
838
1245
|
}));
|
|
839
1246
|
|
|
840
1247
|
server.registerTool('mine_angles', {
|
|
1248
|
+
title: 'Mine customer angles',
|
|
841
1249
|
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.",
|
|
842
1250
|
inputSchema: {
|
|
843
1251
|
brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
|
|
844
1252
|
},
|
|
1253
|
+
outputSchema: {
|
|
1254
|
+
angles: z.array(z.any()).optional().describe('the ranked angle bank ({category, angle, score, hook_draft, proof_quotes})'),
|
|
1255
|
+
sourceCount: z.number().optional().describe('how many customer sources were mined'),
|
|
1256
|
+
note: z.string().optional().describe('why no angles were returned, when the bank is empty'),
|
|
1257
|
+
},
|
|
845
1258
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
846
1259
|
}, wrap(async ({ brandId }) => {
|
|
847
1260
|
const brand = await activeBrand(brandId);
|
|
@@ -855,10 +1268,15 @@ export function registerTools(server) {
|
|
|
855
1268
|
|
|
856
1269
|
// ---------- product-photo tools (Studio-chat parity) ----------
|
|
857
1270
|
server.registerTool('list_product_photos', {
|
|
1271
|
+
title: 'List product photos',
|
|
858
1272
|
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).",
|
|
859
1273
|
inputSchema: {
|
|
860
1274
|
brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
|
|
861
1275
|
},
|
|
1276
|
+
outputSchema: {
|
|
1277
|
+
summary: z.string().optional().describe('a readable rundown of the saved product photos'),
|
|
1278
|
+
photos: z.array(z.any()).optional().describe('the saved photos ({url, label, …})'),
|
|
1279
|
+
},
|
|
862
1280
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
863
1281
|
}, wrap(async ({ brandId }) => {
|
|
864
1282
|
const brand = await activeBrand(brandId);
|
|
@@ -867,13 +1285,20 @@ export function registerTools(server) {
|
|
|
867
1285
|
}));
|
|
868
1286
|
|
|
869
1287
|
server.registerTool('set_product_image', {
|
|
1288
|
+
title: 'Set product photo',
|
|
870
1289
|
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).",
|
|
871
1290
|
inputSchema: {
|
|
872
1291
|
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)'),
|
|
873
1292
|
source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
|
|
874
1293
|
brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
|
|
875
1294
|
},
|
|
876
|
-
|
|
1295
|
+
outputSchema: {
|
|
1296
|
+
attached: z.boolean().optional().describe('true when the image passed the product check and was locked'),
|
|
1297
|
+
summary: z.string().optional().describe('the check verdict — on rejection, why nothing was locked'),
|
|
1298
|
+
url: z.string().nullable().optional().describe('the durable served URL of the locked product photo'),
|
|
1299
|
+
source_note: z.string().nullable().optional().describe('where the photo came from'),
|
|
1300
|
+
},
|
|
1301
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
877
1302
|
}, wrap(async ({ imageUrl, source_note, brandId }) => {
|
|
878
1303
|
const brand = await activeBrand(brandId);
|
|
879
1304
|
const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
|