hermoso 0.1.6 → 0.1.8

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/bin/hermoso.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // Auth today: none locally (the server resolves the dev account). `hermoso auth login --token <t>` stores a Bearer
13
13
  // for when real auth lands — the seam, not a requirement.
14
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
14
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
17
 
@@ -19,7 +19,15 @@ const CONFIG_DIR = path.join(os.homedir(), '.hermoso');
19
19
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
20
20
 
21
21
  async function loadConfig() { try { return JSON.parse(await readFile(CONFIG_FILE, 'utf8')); } catch { return {}; } }
22
- async function saveConfig(c) { await mkdir(CONFIG_DIR, { recursive: true }); await writeFile(CONFIG_FILE, JSON.stringify(c, null, 2)); }
22
+ // config.json holds a long-lived hmk_ account bearer (full billing/spend authority) write it OWNER-ONLY (dir 0700,
23
+ // file 0600) so a co-tenant on a shared machine can't read the key and spend the victim's credits (aws/gh/docker do the
24
+ // same). mode on mkdir/writeFile is pre-umask, so also chmod after in case an existing dir/file kept looser perms.
25
+ async function saveConfig(c) {
26
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
27
+ try { await chmod(CONFIG_DIR, 0o700); } catch {}
28
+ await writeFile(CONFIG_FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
29
+ try { await chmod(CONFIG_FILE, 0o600); } catch {}
30
+ }
23
31
 
24
32
  // ---- minimal arg parser: positionals + --flags (--flag value | --flag=value | boolean --flag) ----
25
33
  function parse(argv) {
package/mcp/tools.mjs CHANGED
@@ -8,12 +8,21 @@ 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 ?? undefined });
11
+ // Null-valued keys are STRIPPED from structuredContent (2026-07-20): the SDK validates results against outputSchema
12
+ // server-side, and zod .optional() rejects null — a single null field (e.g. editCredits:null on a key-less deploy)
13
+ // bricked the whole tool result with a protocol-level validation error. Every field in our schemas is optional, so
14
+ // absent is always valid; array ELEMENTS are kept as-is (dropping them would shift indices).
15
+ // Quote tokens MINTED THIS PROCESS (buy_credits): possession of a well-formed string must not authorize a charge —
16
+ // only a token this server actually issued in a quote turn does. Process-local by design (a restart invalidates
17
+ // outstanding quotes → the agent simply re-quotes; no charge can slip through).
18
+ const _mintedQuotes = new Set();
19
+ const stripNulls = (v) => { if (Array.isArray(v)) return v.map(stripNulls); if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) { if (x !== null) o[k] = stripNulls(x); } return o; } return v; };
20
+ const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredContent: data == null ? {} : stripNulls(data) });
12
21
  // Video-return variant: attaches the clip's first frame as an inline image block (Claude can't play mp4 in chat,
13
22
  // but a poster makes the result VISIBLE, mirroring generate_image). Falls back to plain ok() when frames fail.
14
23
  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
24
  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 ?? undefined }; };
25
+ 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
26
 
18
27
  // ── CAPABILITY MAP — the FULL agent surface, four categories. Appended to hermoso_capabilities so an agent that
19
28
  // probes once learns everything Hermoso does (not just the models): ad spy, create, raw playground, account. Keep
@@ -23,7 +32,7 @@ const CAPABILITY_MAP = [
23
32
  '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
33
  '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
34
  'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
26
- 'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (top-up checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders).',
35
+ '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
36
  ].join('\n');
28
37
 
29
38
  // Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
@@ -35,8 +44,8 @@ export const MCP_INSTRUCTIONS = [
35
44
  '• 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
45
  '• 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
46
  '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
38
- '• ACCOUNT: hermoso_credits, billing_status, buy_credits (top-up link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job.',
39
- 'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits mints a Stripe link your human pays; agents never spend money directly. Always report the final media URL to the user.',
47
+ '• ACCOUNT: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job.',
48
+ '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 + the returned quote_token (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
49
  ].join('\n');
41
50
  // Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
42
51
  // Skipped silently for huge files / fetch errors — the URL in the text always works.
@@ -65,7 +74,7 @@ const wrap = (fn) => async (args, extra) => {
65
74
  catch (e) {
66
75
  let msg = `Error: ${e?.message || e}`;
67
76
  // credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
68
- if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to get a ready-to-pay checkout link (credit packs; your human pays on Stripe's secure page nothing was charged here). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
77
+ if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; 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.`;
69
78
  return { content: [{ type: 'text', text: msg }], isError: true };
70
79
  }
71
80
  };
@@ -86,12 +95,186 @@ async function renderJob(type, input, label) {
86
95
  }
87
96
  }
88
97
 
98
+ // Shared outputSchema fields for the job-based render tools (the renderJob result that becomes structuredContent).
99
+ // Every field is optional so validation can never fail on a sparse or still-rendering result.
100
+ const JOB_OUT = {
101
+ jobId: z.string().optional().describe('the render job id — poll get_job with this id to resume or inspect'),
102
+ url: z.string().nullable().optional().describe('the served URL of the finished media (absent/null while still rendering)'),
103
+ model: z.string().nullable().optional().describe('the product-facing label of the model that rendered it'),
104
+ raw: z.any().optional().describe('the raw job result payload (e.g. images[] for carousel template ads)'),
105
+ stillRendering: z.boolean().optional().describe('true when the render is still in progress — keep polling get_job with jobId'),
106
+ };
107
+
108
+ // ── ChatGPT Apps SDK components (ADDITIVE — Claude/Cursor/other clients ignore extra _meta + ui:// resources) ──
109
+ // Contract pinned from developers.openai.com/apps-sdk on 2026-07-19 (see docs/apps-sdk-notes.md):
110
+ // • a tool declares its widget via tool _meta['openai/outputTemplate'] = 'ui://widget/<name>.html'
111
+ // • that URI is a normal MCP resource with mimeType 'text/html+skybridge' (self-contained HTML+inline JS,
112
+ // runs in ChatGPT's sandboxed skybridge iframe)
113
+ // • the widget reads the tool's structuredContent from window.openai.toolOutput and re-renders on the
114
+ // 'openai:set_globals' window event; setWidgetState persists small UI state across re-renders
115
+ // • every host the iframe loads media from must be allowlisted in resource _meta['openai/widgetCSP']
116
+ const UI_MIME = 'text/html+skybridge';
117
+ const AD_RESULT_URI = 'ui://widget/ad-result.html';
118
+ const CAPABILITIES_URI = 'ui://widget/capabilities.html';
119
+ // Where the widgets' <img>/<video> srcs live: served app media + the R2 asset origins (GEN_PUBLIC_BASE/R2_PUBLIC_BASE).
120
+ const WIDGET_CSP = { connect_domains: [], resource_domains: ['https://app.hermoso.ai', 'https://assets.hermoso.ai', 'https://*.r2.dev'] };
121
+ const openaiMeta = (template, invoking, invoked) => ({ 'openai/outputTemplate': template, 'openai/toolInvocation/invoking': invoking, 'openai/toolInvocation/invoked': invoked }); // status strings ≤64 chars
122
+
123
+ // String.raw so regex backslashes inside the inline widget JS survive the template literal (no ${} used).
124
+ const AD_RESULT_HTML = String.raw`<div id="root"></div>
125
+ <style>
126
+ :root { color-scheme: light dark; }
127
+ * { box-sizing: border-box; margin: 0; padding: 0; }
128
+ #root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; color: #16181c; }
129
+ @media (prefers-color-scheme: dark) { #root { color: #ececf1; } }
130
+ .card { max-width: 520px; border: 1px solid rgba(128,128,128,.28); border-radius: 14px; overflow: hidden; background: rgba(128,128,128,.05); }
131
+ .media img, .media video { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; background: rgba(0,0,0,.85); }
132
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 4px; padding: 4px; background: rgba(0,0,0,.85); }
133
+ .grid img { width: 100%; height: auto; display: block; border-radius: 8px; }
134
+ .meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 9px 12px; font-size: 12.5px; }
135
+ .pill { border: 1px solid rgba(128,128,128,.35); border-radius: 999px; padding: 2px 9px; opacity: .85; }
136
+ .spacer { flex: 1; }
137
+ .wordmark { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; opacity: .4; }
138
+ .empty { padding: 18px 16px; font-size: 13px; opacity: .75; }
139
+ </style>
140
+ <script>
141
+ (function () {
142
+ var root = document.getElementById('root');
143
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }
144
+ function looksVideo(u) { return /\.(mp4|webm|mov|m4v)([?#]|$)/i.test(String(u || '')); }
145
+ function render() {
146
+ var out = (window.openai && window.openai.toolOutput) || {};
147
+ var raw = out.raw || {};
148
+ var pills = '';
149
+ if (out.model) pills += '<span class="pill">' + esc(out.model) + '</span>';
150
+ var credits = out.creditsUsed != null ? out.creditsUsed : (out.credits != null ? out.credits : raw.creditsUsed);
151
+ if (credits != null) pills += '<span class="pill">' + esc(credits) + ' credits</span>';
152
+ var footer = '<div class="meta">' + pills + '<span class="spacer"></span><span class="wordmark">Hermoso</span></div>';
153
+ var slides = Array.isArray(raw.images) && raw.images.length ? raw.images : null;
154
+ var vid = out.video || raw.video || null;
155
+ var img = out.image || raw.image || null;
156
+ var any = out.url || raw.url || null;
157
+ if (!vid && !img && any) { if (looksVideo(any)) { vid = any; } else { img = any; } }
158
+ var body;
159
+ if (out.stillRendering) body = '<div class="empty">Still rendering' + (out.jobId ? ' — job ' + esc(out.jobId) : '') + '. Video renders take 1–3 minutes; the finished ad appears here.</div>';
160
+ else if (slides) body = '<div class="grid">' + slides.map(function (u) { return '<img src="' + esc(u) + '" alt="carousel slide" loading="lazy">'; }).join('') + '</div>';
161
+ else if (vid) body = '<div class="media"><video controls muted autoplay loop playsinline preload="metadata" src="' + esc(vid) + '"></video></div>';
162
+ else if (img) body = '<div class="media"><img src="' + esc(img) + '" alt="generated ad"></div>';
163
+ else body = '<div class="empty">No media in this result yet.</div>';
164
+ root.innerHTML = '<div class="card">' + body + footer + '</div>';
165
+ }
166
+ render();
167
+ window.addEventListener('openai:set_globals', render);
168
+ })();
169
+ </script>`;
170
+
171
+ const CAPABILITIES_HTML = String.raw`<div id="root"></div>
172
+ <style>
173
+ :root { color-scheme: light dark; }
174
+ * { box-sizing: border-box; margin: 0; padding: 0; }
175
+ #root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; color: #16181c; font-size: 13px; max-width: 560px; }
176
+ @media (prefers-color-scheme: dark) { #root { color: #ececf1; } }
177
+ .bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 4px 0 10px; }
178
+ .chip { font: inherit; color: inherit; background: transparent; border: 1px solid rgba(128,128,128,.4); border-radius: 999px; padding: 3px 11px; cursor: pointer; opacity: .75; }
179
+ .chip.on { opacity: 1; border-color: currentColor; font-weight: 600; }
180
+ .q { font: inherit; color: inherit; background: rgba(128,128,128,.1); border: 1px solid rgba(128,128,128,.3); border-radius: 8px; padding: 4px 9px; flex: 1; min-width: 120px; }
181
+ .list { border: 1px solid rgba(128,128,128,.25); border-radius: 12px; overflow: hidden; }
182
+ .row { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 8px 12px; }
183
+ .row + .row { border-top: 1px solid rgba(128,128,128,.18); }
184
+ .mid { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; font-weight: 600; }
185
+ .lbl { opacity: .65; font-size: 12px; }
186
+ .right { margin-left: auto; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
187
+ .kind { font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; opacity: .55; }
188
+ .badge { font-size: 10.5px; border: 1px solid rgba(128,128,128,.35); border-radius: 999px; padding: 1px 7px; opacity: .8; }
189
+ .cost { font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
190
+ .none { padding: 16px; opacity: .7; }
191
+ .foot { display: flex; justify-content: space-between; padding: 8px 2px 2px; font-size: 11.5px; opacity: .55; }
192
+ .wordmark { font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
193
+ </style>
194
+ <script>
195
+ (function () {
196
+ var root = document.getElementById('root');
197
+ var saved = (window.openai && window.openai.widgetState) || {};
198
+ var filter = saved.filter || 'all';
199
+ var q = saved.q || '';
200
+ var KINDS = ['all', 'image', 'video', 'voice', 'writing'];
201
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }
202
+ function items() {
203
+ var out = (window.openai && window.openai.toolOutput) || {};
204
+ var opt = out.options || {};
205
+ var list = [];
206
+ ((opt.image && opt.image.models) || []).forEach(function (m) {
207
+ list.push({ kind: 'image', id: m.id, label: m.label || '', cost: m.credits != null ? m.credits + ' cr' : '', badges: [m.best ? 'best' : '', m.hiRes ? '2K' : '', m.refs && m.refs.max ? 'refs ≤' + m.refs.max : ''] });
208
+ });
209
+ ((opt.video && opt.video.models) || []).forEach(function (m) {
210
+ var cost = (m.durations || []).map(function (d) { var c = m.credits && m.credits[d]; return d + 's=' + (c == null ? '?' : c) + 'cr'; }).join(' · ');
211
+ list.push({ kind: 'video', id: m.id, label: m.label || '', cost: cost, badges: [m.best ? 'best' : '', m.audio ? 'audio' : 'silent', m.refs && m.refs.required ? 'image-to-video' : ''] });
212
+ });
213
+ ((opt.voice && opt.voice.engines) || []).forEach(function (e) {
214
+ list.push({ kind: 'voice', id: e.id, label: e.label || '', cost: e.creditsPer1k != null ? e.creditsPer1k + ' cr/1k chars' : '', badges: [(e.voices || []).length ? e.voices.length + ' voices' : ''] });
215
+ });
216
+ ((opt.llm && opt.llm.models) || []).forEach(function (m) {
217
+ list.push({ kind: 'writing', id: m.id, label: m.label || '', cost: m.credits != null ? m.credits + ' cr' : '', badges: [] });
218
+ });
219
+ return list;
220
+ }
221
+ function row(it) {
222
+ var badges = it.badges.filter(Boolean).map(function (b) { return '<span class="badge">' + esc(b) + '</span>'; }).join('');
223
+ return '<div class="row"><span class="mid">' + esc(it.id) + '</span><span class="lbl">' + esc(it.label) + '</span><span class="right"><span class="kind">' + esc(it.kind) + '</span>' + badges + '<span class="cost">' + esc(it.cost) + '</span></span></div>';
224
+ }
225
+ function persist() { try { if (window.openai && window.openai.setWidgetState) window.openai.setWidgetState({ filter: filter, q: q }); } catch (e) {} }
226
+ function list() {
227
+ Array.prototype.forEach.call(root.querySelectorAll('.chip'), function (b) { b.classList.toggle('on', b.getAttribute('data-k') === filter); });
228
+ var all = items();
229
+ var needle = q.toLowerCase();
230
+ var vis = all.filter(function (it) { return (filter === 'all' || it.kind === filter) && (!needle || (it.id + ' ' + it.label).toLowerCase().indexOf(needle) >= 0); });
231
+ document.getElementById('list').innerHTML = vis.map(row).join('') || '<div class="none">No matching models.</div>';
232
+ document.getElementById('count').textContent = vis.length + ' of ' + all.length + ' models · costs in Hermoso credits';
233
+ }
234
+ function shell() {
235
+ var chips = KINDS.map(function (k) { return '<button type="button" class="chip" data-k="' + k + '">' + k.charAt(0).toUpperCase() + k.slice(1) + '</button>'; }).join('');
236
+ root.innerHTML = '<div class="bar">' + chips + '<input id="q" class="q" type="search" placeholder="Filter models…"></div><div id="list" class="list"></div><div class="foot"><span id="count"></span><span class="wordmark">Hermoso</span></div>';
237
+ var inp = document.getElementById('q');
238
+ inp.value = q;
239
+ inp.addEventListener('input', function () { q = inp.value; persist(); list(); });
240
+ Array.prototype.forEach.call(root.querySelectorAll('.chip'), function (b) {
241
+ b.addEventListener('click', function () { filter = b.getAttribute('data-k'); persist(); list(); });
242
+ });
243
+ list();
244
+ }
245
+ shell();
246
+ window.addEventListener('openai:set_globals', list);
247
+ })();
248
+ </script>`;
249
+
250
+ function registerAppResources(server) {
251
+ const reg = (name, uri, description, html) => {
252
+ const meta = { 'openai/widgetDescription': description, 'openai/widgetPrefersBorder': true, 'openai/widgetCSP': WIDGET_CSP };
253
+ server.registerResource(name, uri, { description, mimeType: UI_MIME, _meta: meta },
254
+ async () => ({ contents: [{ uri, mimeType: UI_MIME, text: html, _meta: meta }] }));
255
+ };
256
+ reg('hermoso-ad-result', AD_RESULT_URI, 'Shows the finished Hermoso ad — the image, auto-playing video, or carousel — with the model that rendered it and credits spent.', AD_RESULT_HTML);
257
+ reg('hermoso-capabilities', CAPABILITIES_URI, 'Browsable Hermoso model catalog: image/video/voice/writing models with exact per-render credit costs and a filter row.', CAPABILITIES_HTML);
258
+ }
259
+
89
260
  export function registerTools(server) {
261
+ registerAppResources(server); // ChatGPT Apps SDK widget templates — inert decoration for every other client
90
262
  // ---------- read-only / discovery ----------
91
263
  server.registerTool('hermoso_capabilities', {
92
264
  title: 'Hermoso capabilities',
93
265
  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.',
94
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
266
+ inputSchema: {}, outputSchema: {
267
+ image: z.any().optional().describe('the default image provider label, or null when image generation is unavailable'),
268
+ video: z.any().optional().describe('the default video provider label, or null when video generation is unavailable'),
269
+ canEdit: z.boolean().optional().describe('whether image editing is enabled on this account'),
270
+ canAvatar: z.boolean().optional().describe('whether talking-avatar generation is enabled'),
271
+ canPublish: z.boolean().optional().describe('whether ad publishing is enabled'),
272
+ editCredits: z.number().nullable().optional().describe('credit cost of one image edit (null when image editing is not configured)'),
273
+ options: z.any().optional().describe('the live model catalog — image/video/voice/llm model lists with per-model credit costs'),
274
+ recipes: z.array(z.any()).optional().describe('the creative recipe catalog (id + label per recipe)'),
275
+ },
276
+ annotations: { readOnlyHint: true, openWorldHint: false },
277
+ _meta: openaiMeta(CAPABILITIES_URI, 'Loading the model catalog…', 'Model catalog ready'),
95
278
  }, wrap(async () => {
96
279
  const d = await apiGet('/api/generate/status');
97
280
  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('; ');
@@ -108,34 +291,81 @@ export function registerTools(server) {
108
291
  server.registerTool('hermoso_credits', {
109
292
  title: 'Credit balance',
110
293
  description: 'Return the account credit balance, credits used this session, and recent priced calls. Check before kicking off paid generation.',
111
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
294
+ inputSchema: {}, outputSchema: {
295
+ accountBalance: z.number().nullable().optional().describe('the account’s Hermoso credit balance (authoritative when authed)'),
296
+ balance: z.number().optional().describe('raw vendor meter balance (operator/local-dev surface)'),
297
+ sessionStart: z.number().nullable().optional().describe('vendor balance at session start (operator surface)'),
298
+ sessionUsed: z.number().optional().describe('credits used this session'),
299
+ recentCalls: z.array(z.any()).optional().describe('recent priced calls with their credit deltas'),
300
+ },
301
+ annotations: { readOnlyHint: true, openWorldHint: false },
112
302
  }, wrap(async () => {
113
303
  const d = await apiGet('/api/credits');
114
304
  const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
115
305
  return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
116
306
  }));
117
307
 
118
- // AGENT BILLING HANDOFF: out of credits → mint a ready-to-pay Stripe checkout link for a credit PACK and hand the
119
- // URL to the human. The human pays on Stripe's hosted page (agents never spend money directly); credits post to
120
- // this account automatically once payment completes. Packs only subscriptions are managed by a person in-app.
308
+ // AGENT BILLING: out of credits → top up. With a saved card + billing-admin rights this is the SAME one-click
309
+ // off-session charge the web app's Add-credits button uses (explicit confirm:true required an agent states the
310
+ // exact charge before any money moves). First-ever purchase (no card on file) goes through a Stripe checkout link
311
+ // the human pays once — that card then saves for one-click forever. Packs only — subscriptions are in-app.
121
312
  server.registerTool('buy_credits', {
122
313
  title: 'Buy credits',
123
- description: "Out of credits? Get a ready-to-pay checkout link for a credit PACK. Call with no argument to list the available packs (id · credits · price); call again with `pack` set to a pack id to get a Stripe checkout URL. Hand that URL to your humanTHEY pay on Stripe's secure hosted page (agents never spend money directly), and the credits land on this account the moment payment completes. Packs only; subscriptions are managed by a person in Settings → Billing. Nothing is charged until your human pays.",
314
+ 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 AND the quote's quote_token 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.",
124
315
  inputSchema: {
125
316
  pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
317
+ 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)'),
318
+ quote_token: z.string().optional().describe('the quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent'),
319
+ },
320
+ outputSchema: {
321
+ packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing'),
322
+ quote: z.any().optional().describe('the one-click charge quote ({packId, credits, priceUsd, card}) awaiting confirm:true'),
323
+ ok: z.boolean().optional().describe('true when a one-click top-up charge succeeded'),
324
+ credits: z.number().optional().describe('credits added by a completed top-up (or bought by the checkout link)'),
325
+ url: z.string().optional().describe('Stripe checkout URL for a first purchase (no saved card yet)'),
326
+ amountUsd: z.number().optional().describe('USD amount of the checkout link'),
327
+ packId: z.string().optional().describe('the pack id the checkout link buys'),
126
328
  },
127
- annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe's page
128
- }, wrap(async ({ pack }) => {
329
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, // confirm:true charges the saved card (one-click top-up); link path charges nothing
330
+ }, wrap(async ({ pack, confirm, quote_token }) => {
129
331
  const cfg = await apiGet('/api/billing/config');
130
332
  const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
131
333
  if (!pack) {
132
334
  const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
133
- return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>" to get a checkout link for your human to pay.`, { packs });
335
+ 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 });
134
336
  }
135
337
  const match = packs.find(p => p.id === pack);
136
338
  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 });
339
+ let st = null;
340
+ try { st = await apiGet('/api/billing/status'); } catch {}
341
+ if (st?.paymentMethodOnFile && st?.isAdmin) {
342
+ const card = st.card ? `${st.card.brand} ····${st.card.last4}` : 'the saved card';
343
+ // QUOTE-TOKEN CONTRACT (2026-07-20): the quote mints a token binding {pack, price, 10-min expiry}; the charge
344
+ // REQUIRES it, enforced here in tool code where the agent can't route around it. (1) confirm:true on a FIRST
345
+ // call can never move money — a prompt-injected agent is forced through a user-visible quote turn; (2) the token
346
+ // doubles as the Stripe idempotency key (server builds tp:<account>:<key>), so a lost-response retry of the SAME
347
+ // confirm dedups at Stripe instead of double-charging (the app's fix #15 class — the old fresh-UUID-per-call
348
+ // re-introduced it); (3) the LIVE price is re-checked at charge time — a catalog change between quote and
349
+ // confirm re-quotes instead of silently charging a price the human never saw. '|' separator: pack prices can
350
+ // carry decimals, so '.' would split wrong.
351
+ const mintQuote = () => {
352
+ const t = `qt1|${match.id}|${match.priceUsd}|${Math.floor(Date.now() / 1000) + 600}|${(globalThis.crypto?.randomUUID?.() || String(Date.now())).slice(0, 8)}`;
353
+ _mintedQuotes.add(t); if (_mintedQuotes.size > 50) _mintedQuotes.delete(_mintedQuotes.values().next().value); // bounded
354
+ 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}", confirm:true and quote_token="${t}" (valid 10 minutes).`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null, quoteToken: t, expiresInSeconds: 600 } });
355
+ };
356
+ if (!confirm || !quote_token) return mintQuote();
357
+ const qp = String(quote_token).split('|');
358
+ if (!_mintedQuotes.has(String(quote_token)) || qp[0] !== 'qt1' || qp.length < 5 || qp[1] !== match.id || Number(qp[3]) < Math.floor(Date.now() / 1000) || Number(qp[2]) !== match.priceUsd) return mintQuote(); // UNMINTED (forged/other-process — c3d6081 review: format-only validation was trivially forgeable, the token MUST come from a real quote in THIS process; a restart just re-quotes) / expired / wrong-pack / price-moved → fresh quote, never a surprise charge
359
+ let d;
360
+ try { d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: String(quote_token), expectedPriceUsd: match.priceUsd }); } // expectedPriceUsd: server-side price binding (409s if the catalog moved under the quote)
361
+ catch (e) {
362
+ if (e?.status === 403) return ok(`This key doesn't have billing-admin rights on the account, so it can't charge the saved card. Ask a workspace admin to top up (app Settings → Billing → Add credits, or their own buy_credits call).`, { packs });
363
+ throw e;
364
+ }
365
+ return ok(`Done — charged ${card} $${match.priceUsd}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Settings → Billing → invoice history.)`, d);
366
+ }
137
367
  const d = await apiPost('/api/billing/checkout-link', { packId: pack });
138
- return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — the credits post to this account automatically once payment completes. Nothing is charged until they pay.`, d);
368
+ 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);
139
369
  }));
140
370
 
141
371
  // BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
@@ -143,7 +373,16 @@ export function registerTools(server) {
143
373
  server.registerTool('billing_status', {
144
374
  title: 'Billing status',
145
375
  description: "Show this account's billing at a glance: current plan (id + label + monthly price), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing.",
146
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
376
+ inputSchema: {}, outputSchema: {
377
+ plan: z.any().optional().describe('the current plan ({id, label, monthlyUsd})'),
378
+ balanceCredits: z.number().nullable().optional().describe('the current credit balance'),
379
+ autoReload: z.any().optional().describe('auto-reload config ({enabled, thresholdCredits, reloadCredits, available})'),
380
+ paymentMethodOnFile: z.boolean().optional().describe('whether a card is saved for one-click charges'),
381
+ card: z.any().optional().describe('the saved card ({brand, last4}) when present'),
382
+ role: z.string().optional().describe('this key’s billing role (admin/member)'),
383
+ isAdmin: z.boolean().optional().describe('whether this key can change billing'),
384
+ },
385
+ annotations: { readOnlyHint: true, openWorldHint: false },
147
386
  }, wrap(async () => {
148
387
  const d = await apiGet('/api/billing/status');
149
388
  const ar = d.autoReload || {};
@@ -161,6 +400,18 @@ export function registerTools(server) {
161
400
  plan: z.string().optional().describe('the plan id to move to (e.g. pro) — omit to list the available plans first'),
162
401
  period: z.enum(['mo', 'yr']).optional().describe('billing cadence — monthly (default) or yearly (2 months free)'),
163
402
  },
403
+ outputSchema: {
404
+ plans: z.array(z.any()).optional().describe('available paid plans ({id, name, priceUsd, credits}) when listing'),
405
+ mode: z.string().optional().describe("'checkout' (a Stripe URL was minted) or 'in_app' (a person makes the change in the app)"),
406
+ url: z.string().optional().describe('the ready-to-pay Stripe Checkout URL (checkout mode)'),
407
+ plan: z.string().optional().describe('the target plan id'),
408
+ planLabel: z.string().optional().describe('the target plan display name'),
409
+ monthlyUsd: z.number().optional().describe('the plan’s monthly price in USD'),
410
+ chargeUsd: z.number().optional().describe('the actual charge amount (yearly billing charges the annual total)'),
411
+ period: z.string().optional().describe("billing cadence of the link — 'mo' or 'yr'"),
412
+ action: z.string().optional().describe("the in-app action required ('upgrade' or 'downgrade')"),
413
+ guidance: z.string().optional().describe('exact instructions when the change must be made in the app'),
414
+ },
164
415
  annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe / in-app
165
416
  }, wrap(async ({ plan, period }) => {
166
417
  const cfg = await apiGet('/api/billing/config');
@@ -184,6 +435,17 @@ export function registerTools(server) {
184
435
  thresholdCredits: z.number().int().optional().describe('reload when the balance drops below this many credits'),
185
436
  reloadCredits: z.number().int().optional().describe('how many credits to add each reload — must match a credit pack size (see buy_credits)'),
186
437
  },
438
+ outputSchema: {
439
+ applied: z.boolean().optional().describe('whether the auto-reload config was applied'),
440
+ needsCard: z.boolean().optional().describe('true when there is no saved card yet (add one in the app first)'),
441
+ enabled: z.boolean().optional().describe('the resulting auto-reload state'),
442
+ thresholdCredits: z.number().nullable().optional().describe('reload triggers below this balance'),
443
+ reloadCredits: z.number().nullable().optional().describe('credits added per reload'),
444
+ reloadPack: z.any().optional().describe('the pack charged on each reload'),
445
+ capUsd: z.any().optional().describe('monthly auto-reload spend cap in USD, if set'),
446
+ status: z.string().optional().describe('auto-reload status detail'),
447
+ guidance: z.string().optional().describe('instructions when the change must be made in the app'),
448
+ },
187
449
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
188
450
  }, wrap(async ({ enabled, thresholdCredits, reloadCredits }) => {
189
451
  const d = await apiPost('/api/billing/autoreload-config', { enabled, thresholdCredits, reloadCredits });
@@ -195,7 +457,10 @@ export function registerTools(server) {
195
457
  server.registerTool('list_brands', {
196
458
  title: 'List brands',
197
459
  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.",
198
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
460
+ inputSchema: {}, outputSchema: {
461
+ brands: z.array(z.any()).optional().describe('every brand on the account ({id, name, active})'),
462
+ },
463
+ annotations: { readOnlyHint: true, openWorldHint: false },
199
464
  }, wrap(async () => {
200
465
  const d = await apiGet('/api/brands');
201
466
  const lines = (d.brands || []).map(b => `• ${b.name} (id: ${b.id})${b.active ? ' ← active' : ''}`).join('\n');
@@ -206,6 +471,10 @@ export function registerTools(server) {
206
471
  title: 'Switch brand',
207
472
  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.",
208
473
  inputSchema: { brand: z.string().describe('brand id (e.g. default / p_xxx) or its exact name from list_brands') },
474
+ outputSchema: {
475
+ ok: z.boolean().optional().describe('true when the brand switch persisted'),
476
+ brand: z.any().optional().describe('the now-active brand ({id, name})'),
477
+ },
209
478
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
210
479
  }, wrap(async ({ brand }) => {
211
480
  const d = await apiGet('/api/brands');
@@ -226,7 +495,20 @@ export function registerTools(server) {
226
495
  format: z.enum(['auto', 'image', 'video']).optional().describe("'image', 'video', or 'auto' when unspecified"),
227
496
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
228
497
  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)'),
229
- language: z.string().optional(),
498
+ language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
499
+ },
500
+ outputSchema: {
501
+ format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
502
+ concept: z.string().optional().describe('the one-line creative concept'),
503
+ recipe: z.string().optional().describe('the resolved recipe id'),
504
+ recipe_label: z.string().optional().describe('the resolved recipe display name'),
505
+ copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
506
+ image_concept: z.any().optional().describe('the render-ready image concept (prompt etc.) when format is image'),
507
+ video_storyboard: z.any().optional().describe('the timed storyboard (scenes, cta, music) when format is video'),
508
+ render_plan: z.any().optional().describe('the routing plan (structure/duration) render_ad honors'),
509
+ imodel: z.string().optional().describe('the image model id to render with'),
510
+ vmodel: z.string().optional().describe('the video model id to render with'),
511
+ brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
230
512
  },
231
513
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
232
514
  }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
@@ -251,9 +533,14 @@ export function registerTools(server) {
251
533
  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'),
252
534
  aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
253
535
  model: z.string().optional().describe('image model id from hermoso_capabilities'),
254
- imageSize: z.string().optional(),
536
+ imageSize: z.string().optional().describe('pixel-size preset for models that support it (e.g. 1K/2K) — omit for the default'),
537
+ },
538
+ outputSchema: {
539
+ image: z.string().optional().describe('the served absolute URL of the finished image'),
540
+ model: z.string().optional().describe('the product-facing label of the model that rendered it'),
255
541
  },
256
542
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
543
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
257
544
  }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
258
545
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
259
546
  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
@@ -270,6 +557,12 @@ export function registerTools(server) {
270
557
  engine: z.string().optional().describe("voice-engine id: 'seed-audio' (default), 'eleven-v3', 'minimax-speech', or 'kokoro' — listed in hermoso_capabilities"),
271
558
  voice: z.string().optional().describe("a voice preset from the chosen engine (e.g. 'Aria'/'George' on eleven-v3, 'stokie_en' on seed-audio) — omit for the engine default"),
272
559
  },
560
+ outputSchema: {
561
+ audio: z.string().optional().describe('the served absolute URL of the MP3 voice clip'),
562
+ voice: z.string().optional().describe('the voice preset used'),
563
+ model: z.string().optional().describe('the voice engine label'),
564
+ creditsUsed: z.number().optional().describe('credits billed for this clip'),
565
+ },
273
566
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
274
567
  }, wrap(async ({ text, engine, voice }) => {
275
568
  const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}) });
@@ -283,6 +576,11 @@ export function registerTools(server) {
283
576
  prompt: z.string().describe('the writing task / question'),
284
577
  model: z.string().optional().describe('a writing-model id from hermoso_capabilities (a Claude / Gemini / GPT / Llama / DeepSeek id) — omit for the default'),
285
578
  },
579
+ outputSchema: {
580
+ text: z.string().optional().describe('the generated text'),
581
+ model: z.string().optional().describe('the writing model label'),
582
+ creditsUsed: z.number().optional().describe('credits billed for this generation'),
583
+ },
286
584
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
287
585
  }, wrap(async ({ prompt, model }) => {
288
586
  const d = await apiPost('/api/models/llm', { prompt, ...(model ? { model } : {}) });
@@ -296,8 +594,8 @@ export function registerTools(server) {
296
594
  inputSchema: {
297
595
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
298
596
  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)'),
299
- durationSeconds: z.number().optional(),
300
- aspectRatio: z.string().optional(),
597
+ durationSeconds: z.number().optional().describe('total ad length in seconds — omit to honor the plan’s own duration'),
598
+ aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
301
599
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
302
600
  captions: z.boolean().optional().describe('composited caption pills on/off (default: the recipe decides)'),
303
601
  endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
@@ -306,7 +604,14 @@ export function registerTools(server) {
306
604
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
307
605
  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'),
308
606
  },
607
+ outputSchema: {
608
+ ...JOB_OUT,
609
+ dryRun: z.boolean().optional().describe('true when this was a dry run (no job submitted, nothing charged)'),
610
+ jobType: z.string().optional().describe("the routing decision — 'video' (single pass) or 'stitch' (acts)"),
611
+ input: z.any().optional().describe('the assembled render input (dry run only — resolved model, duration, scenes)'),
612
+ },
309
613
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
614
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your video ad…', 'Video ad ready'),
310
615
  }, wrap(async (a) => {
311
616
  const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
312
617
  // LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
@@ -324,15 +629,17 @@ export function registerTools(server) {
324
629
  inputSchema: {
325
630
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
326
631
  },
632
+ outputSchema: { ...JOB_OUT },
327
633
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
634
+ _meta: openaiMeta(AD_RESULT_URI, 'Building your template ad…', 'Template ad ready'),
328
635
  }, wrap(async (a) => {
329
636
  const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
330
637
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
331
638
  const urls = r.raw.images.map((u) => abs(u));
332
639
  const first = await imageBlock(urls[0]).catch(() => null);
333
- 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 ?? undefined };
640
+ 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 ?? {} };
334
641
  }
335
- 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 ?? undefined }; }
642
+ 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 ?? {} }; }
336
643
  return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
337
644
  }));
338
645
 
@@ -348,6 +655,7 @@ export function registerTools(server) {
348
655
  pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
349
656
  grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
350
657
  },
658
+ outputSchema: { ...JOB_OUT },
351
659
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
352
660
  }, wrap(async (a) => {
353
661
  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');
@@ -365,6 +673,7 @@ export function registerTools(server) {
365
673
  refImage: z.string().optional().describe('optional product/style anchor image URL'),
366
674
  speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
367
675
  },
676
+ outputSchema: { ...JOB_OUT },
368
677
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
369
678
  }, wrap(async (a) => {
370
679
  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');
@@ -384,9 +693,11 @@ export function registerTools(server) {
384
693
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
385
694
  ttsScript: z.string().optional().describe('voiceover script to speak'),
386
695
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
387
- musicMood: z.string().optional(),
696
+ musicMood: z.string().optional().describe('licensed music-bed mood (e.g. upbeat / cinematic) — omit for no music bed'),
388
697
  },
698
+ outputSchema: { ...JOB_OUT },
389
699
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
700
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your video…', 'Video ready'),
390
701
  }, wrap(async (a) => {
391
702
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
392
703
  // an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
@@ -404,7 +715,9 @@ export function registerTools(server) {
404
715
  voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
405
716
  resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
406
717
  },
718
+ outputSchema: { ...JOB_OUT },
407
719
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
720
+ _meta: openaiMeta(AD_RESULT_URI, 'Rendering your avatar clip…', 'Avatar clip ready'),
408
721
  }, wrap(async (a) => {
409
722
  const image = await toRef(a.image);
410
723
  const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
@@ -416,13 +729,14 @@ export function registerTools(server) {
416
729
  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.',
417
730
  inputSchema: {
418
731
  scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
419
- aspectRatio: z.string().optional(),
420
- voiceover: z.string().optional(),
421
- voice: z.string().optional(),
422
- resolution: z.string().optional(),
423
- model: z.string().optional(),
424
- durationSeconds: z.number().optional(),
732
+ aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
733
+ voiceover: z.string().optional().describe('full voiceover script spoken across the scenes'),
734
+ voice: z.string().optional().describe('voiceover voice name, e.g. Rachel / George'),
735
+ resolution: z.string().optional().describe('720p (default), 480p draft, or 1080p final'),
736
+ model: z.string().optional().describe('video model id from hermoso_capabilities — omit to let the router pick'),
737
+ durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
425
738
  },
739
+ outputSchema: { ...JOB_OUT },
426
740
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
427
741
  }, wrap(async (a) => {
428
742
  // HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
@@ -448,6 +762,15 @@ export function registerTools(server) {
448
762
  title: 'Get render job',
449
763
  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.',
450
764
  inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
765
+ outputSchema: {
766
+ id: z.string().optional().describe('the job id'),
767
+ status: z.string().optional().describe('queued | running | done | error'),
768
+ progress: z.number().optional().describe('0–1 progress when reported'),
769
+ error: z.string().nullable().optional().describe('the failure message when status is error'),
770
+ url: z.string().nullable().optional().describe('the served media URL once done'),
771
+ type: z.string().optional().describe('the job type (video / stitch / avatar / …)'),
772
+ result: z.any().optional().describe('the raw job result payload'),
773
+ },
451
774
  annotations: { readOnlyHint: true, openWorldHint: false },
452
775
  }, wrap(async ({ id }) => {
453
776
  const j = await getJob(id);
@@ -463,7 +786,11 @@ export function registerTools(server) {
463
786
  server.registerTool('list_skills', {
464
787
  title: 'List skills',
465
788
  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.',
466
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
789
+ inputSchema: {}, outputSchema: {
790
+ bundles: z.array(z.any()).optional().describe('bundled skills ({name, description}) loadable via get_skill'),
791
+ inApp: z.array(z.any()).optional().describe('in-app strategy skills + creative recipes ({id, kind/group})'),
792
+ },
793
+ annotations: { readOnlyHint: true, openWorldHint: false },
467
794
  }, wrap(async () => {
468
795
  const { readdir, readFile } = await import('node:fs/promises');
469
796
  const dir = new URL('../skills/', import.meta.url);
@@ -488,6 +815,9 @@ export function registerTools(server) {
488
815
  title: 'Get skill',
489
816
  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.',
490
817
  inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
818
+ outputSchema: {
819
+ name: z.string().optional().describe('the loaded skill bundle name'),
820
+ },
491
821
  annotations: { readOnlyHint: true, openWorldHint: false },
492
822
  }, wrap(async ({ name }) => {
493
823
  const safe = String(name).replace(/[^a-z0-9-]/gi, '');
@@ -500,7 +830,11 @@ export function registerTools(server) {
500
830
  server.registerTool('list_jobs', {
501
831
  title: 'List render jobs',
502
832
  description: 'List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.',
503
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
833
+ inputSchema: {}, outputSchema: {
834
+ running: z.number().optional().describe('how many jobs are currently running'),
835
+ jobs: z.array(z.any()).optional().describe('recent jobs ({id, type, status, …}), newest first'),
836
+ },
837
+ annotations: { readOnlyHint: true, openWorldHint: false },
504
838
  }, wrap(async () => {
505
839
  const d = await apiGet('/api/jobs');
506
840
  const lines = (d.jobs || []).slice(0, 12).map(j => `${j.id} ${j.type} ${j.status}`).join('\n');
@@ -513,7 +847,11 @@ export function registerTools(server) {
513
847
  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.",
514
848
  inputSchema: {
515
849
  domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
516
- mode: z.enum(['competitors', 'inspiration', 'company']).optional(),
850
+ mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
851
+ },
852
+ outputSchema: {
853
+ candidates: z.array(z.any()).optional().describe('discovered brands ({name, domain, kind, reason})'),
854
+ diagnostics: z.any().optional().describe('discovery diagnostics (LLM tokens, web grounding)'),
517
855
  },
518
856
  annotations: { readOnlyHint: true, openWorldHint: true },
519
857
  }, wrap(async ({ domain, mode = 'competitors' }) => {
@@ -530,9 +868,14 @@ export function registerTools(server) {
530
868
  domain: z.string().optional().describe('the advertiser domain'),
531
869
  platforms: z.array(z.string()).optional().describe("default ['facebook']; add 'google','linkedin'"),
532
870
  country: z.string().optional().describe("2-letter, default 'US'"),
533
- limit: z.number().optional(),
871
+ limit: z.number().optional().describe('max ads per platform (default 30)'),
534
872
  sort: z.string().optional().describe("'longest_running' (default) etc."),
535
873
  },
874
+ outputSchema: {
875
+ facebook: z.any().optional().describe('Meta results ({ads[], matched} or {error}; null when not requested)'),
876
+ google: z.any().optional().describe('Google results ({ads[], cursor} or {error}; null when not requested)'),
877
+ linkedin: z.any().optional().describe('LinkedIn results ({ads[], cursor} or {error}; null when not requested)'),
878
+ },
536
879
  annotations: { readOnlyHint: true, openWorldHint: true },
537
880
  }, wrap(async (a) => {
538
881
  const d = await apiPost('/api/inspire/fanout', { platforms: ['facebook'], country: 'US', limit: 30, sort: 'longest_running', ...a });
@@ -544,7 +887,12 @@ export function registerTools(server) {
544
887
  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.',
545
888
  inputSchema: {
546
889
  query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
547
- brand: z.union([z.string(), z.object({}).passthrough()]).optional(),
890
+ 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'),
891
+ },
892
+ outputSchema: {
893
+ reply: z.string().optional().describe('the research summary'),
894
+ results: z.array(z.any()).optional().describe('the found ads/videos (normalized card objects with served URLs)'),
895
+ actions: z.any().optional().describe('follow-up actions the research loop suggested'),
548
896
  },
549
897
  annotations: { readOnlyHint: true, openWorldHint: true },
550
898
  }, wrap(async ({ query, brand }) => {
@@ -570,9 +918,13 @@ export function registerTools(server) {
570
918
  pageId: z.string().optional().describe('one advertiser’s ads by Facebook page id (most precise)'),
571
919
  country: z.string().optional().describe("2-letter code or 'ALL' (default ALL)"),
572
920
  status: z.enum(['ACTIVE', 'INACTIVE', 'ALL']).optional().describe("ACTIVE = currently running; default ALL (includes proven past winners)"),
573
- mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional(),
921
+ mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional().describe('filter by creative type (default ALL)'),
574
922
  limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
575
923
  },
924
+ outputSchema: {
925
+ found: z.number().optional().describe('total ads found upstream'),
926
+ ads: z.array(z.any()).optional().describe('the compact ad objects ({page_name, body, cta, link, dates, media})'),
927
+ },
576
928
  annotations: { readOnlyHint: true, openWorldHint: true },
577
929
  }, wrap(async (a) => {
578
930
  if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
@@ -601,6 +953,10 @@ export function registerTools(server) {
601
953
  region: z.string().optional().describe('2-letter region, default US'),
602
954
  limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
603
955
  },
956
+ outputSchema: {
957
+ found: z.number().optional().describe('total ads found upstream'),
958
+ ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, format, adUrl, image, firstShown, lastShown})'),
959
+ },
604
960
  annotations: { readOnlyHint: true, openWorldHint: true },
605
961
  }, wrap(async (a) => {
606
962
  if (!a.domain && !a.advertiserId) throw new Error('Pass domain or advertiserId.');
@@ -616,10 +972,14 @@ export function registerTools(server) {
616
972
  inputSchema: {
617
973
  company: z.string().optional().describe('advertiser company name'),
618
974
  keyword: z.string().optional().describe('keyword across all advertisers'),
619
- companyId: z.string().optional(),
975
+ companyId: z.string().optional().describe('LinkedIn company id (numeric) when the name is ambiguous'),
620
976
  countries: z.string().optional().describe("CSV of 2-letter codes like 'US,CA'; omit or 'ALL' = worldwide"),
621
977
  limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
622
978
  },
979
+ outputSchema: {
980
+ found: z.number().optional().describe('total ads found upstream'),
981
+ ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, headline, description, cta, link, media, dates, impressions})'),
982
+ },
623
983
  annotations: { readOnlyHint: true, openWorldHint: true },
624
984
  }, wrap(async (a) => {
625
985
  if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
@@ -639,6 +999,10 @@ export function registerTools(server) {
639
999
  query: z.string().describe('keyword or hashtag (no # needed)'),
640
1000
  limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
641
1001
  },
1002
+ outputSchema: {
1003
+ found: z.number().optional().describe('total videos found'),
1004
+ videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
1005
+ },
642
1006
  annotations: { readOnlyHint: true, openWorldHint: true },
643
1007
  }, wrap(async ({ query, limit }) => {
644
1008
  const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
@@ -660,6 +1024,10 @@ export function registerTools(server) {
660
1024
  query: z.string().describe('keyword to search reels for'),
661
1025
  limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
662
1026
  },
1027
+ outputSchema: {
1028
+ found: z.number().optional().describe('total reels found'),
1029
+ reels: z.array(z.any()).optional().describe('the compact reel objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
1030
+ },
663
1031
  annotations: { readOnlyHint: true, openWorldHint: true },
664
1032
  }, wrap(async ({ query, limit }) => {
665
1033
  const d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query });
@@ -683,6 +1051,10 @@ export function registerTools(server) {
683
1051
  query: z.string().describe('keyword to search videos for'),
684
1052
  limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
685
1053
  },
1054
+ outputSchema: {
1055
+ found: z.number().optional().describe('total videos found'),
1056
+ videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, link, cover}), ranked by views'),
1057
+ },
686
1058
  annotations: { readOnlyHint: true, openWorldHint: true },
687
1059
  }, wrap(async ({ query, limit }) => {
688
1060
  const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
@@ -700,6 +1072,10 @@ export function registerTools(server) {
700
1072
  query: z.string().describe('what to search Reddit for'),
701
1073
  limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
702
1074
  },
1075
+ outputSchema: {
1076
+ found: z.number().optional().describe('total posts found'),
1077
+ posts: z.array(z.any()).optional().describe('the compact post objects ({desc, subreddit, upvotes, comments, link})'),
1078
+ },
703
1079
  annotations: { readOnlyHint: true, openWorldHint: true },
704
1080
  }, wrap(async ({ query, limit }) => {
705
1081
  const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'top' });
@@ -718,6 +1094,10 @@ export function registerTools(server) {
718
1094
  query: z.string().describe('keyword to search Threads for'),
719
1095
  limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
720
1096
  },
1097
+ outputSchema: {
1098
+ found: z.number().optional().describe('total posts found'),
1099
+ posts: z.array(z.any()).optional().describe('the compact post objects ({desc, author, handle, likes, link, cover})'),
1100
+ },
721
1101
  annotations: { readOnlyHint: true, openWorldHint: true },
722
1102
  }, wrap(async ({ query, limit }) => {
723
1103
  const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
@@ -740,6 +1120,7 @@ export function registerTools(server) {
740
1120
  path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
741
1121
  params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
742
1122
  },
1123
+ outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
743
1124
  annotations: { readOnlyHint: true, openWorldHint: true },
744
1125
  }, wrap(async ({ path, params }) => {
745
1126
  const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
@@ -752,6 +1133,11 @@ export function registerTools(server) {
752
1133
  title: 'Get saved brand',
753
1134
  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.',
754
1135
  inputSchema: {},
1136
+ outputSchema: {
1137
+ hasBrand: z.boolean().optional().describe('whether a brand is saved for this workspace'),
1138
+ brand: z.any().optional().describe('the saved brand profile (name, domain, category, products, palette, …) or null'),
1139
+ memoryCount: z.number().optional().describe('how many learned memory notes the workspace holds'),
1140
+ },
755
1141
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
756
1142
  }, wrap(async () => {
757
1143
  const d = await apiGet('/api/brand/current');
@@ -767,10 +1153,21 @@ export function registerTools(server) {
767
1153
  inputSchema: {
768
1154
  domain: z.string().optional().describe('a website to scrape'),
769
1155
  description: z.string().optional().describe('a free-text brand description (no website)'),
770
- socialHandle: z.string().optional(),
1156
+ socialHandle: z.string().optional().describe('a social handle to draft from (influencers/creators) — pair with platform'),
771
1157
  platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
772
1158
  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'),
773
1159
  },
1160
+ outputSchema: {
1161
+ name: z.string().optional().describe('the drafted brand name — VERIFY it matches the brand the user meant'),
1162
+ domain: z.string().optional().describe('the brand website domain (empty for non-website drafts)'),
1163
+ category: z.string().optional().describe('the detected category'),
1164
+ summary: z.string().optional().describe('a short positioning summary'),
1165
+ sells: z.any().optional().describe('what the brand sells'),
1166
+ logo: z.string().optional().describe('the detected logo URL'),
1167
+ palette: z.array(z.any()).optional().describe('the brand colors'),
1168
+ products: z.any().optional().describe('the detected products'),
1169
+ productImages: z.array(z.any()).optional().describe('product photo URLs'),
1170
+ },
774
1171
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
775
1172
  }, wrap(async ({ save, ...a }) => {
776
1173
  const d = await apiPost('/api/brand/draft', a);
@@ -793,7 +1190,11 @@ export function registerTools(server) {
793
1190
  server.registerTool('fetch_asset', {
794
1191
  title: 'Fetch asset',
795
1192
  description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
796
- inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional() },
1193
+ inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional().describe('optional filename for the download') },
1194
+ outputSchema: {
1195
+ url: z.string().optional().describe('the clickable absolute asset URL'),
1196
+ downloadUrl: z.string().optional().describe('a direct download URL for the asset'),
1197
+ },
797
1198
  annotations: { readOnlyHint: true, openWorldHint: false },
798
1199
  }, wrap(async ({ url, name }) => {
799
1200
  const absolute = abs(url);
@@ -806,6 +1207,11 @@ export function registerTools(server) {
806
1207
  title: 'Analyze video',
807
1208
  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.",
808
1209
  inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
1210
+ outputSchema: {
1211
+ durationSeconds: z.number().optional().describe('the video length in seconds'),
1212
+ frameTimes: z.array(z.number()).optional().describe('timestamps (seconds) of the sampled frames'),
1213
+ transcript: z.string().nullable().optional().describe('verbatim voiceover + on-screen text with a beat list (null when silent/unreachable)'),
1214
+ },
809
1215
  annotations: { readOnlyHint: true, openWorldHint: true },
810
1216
  }, wrap(async ({ url }) => {
811
1217
  const [fr, tr] = await Promise.all([
@@ -822,9 +1228,16 @@ export function registerTools(server) {
822
1228
  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.",
823
1229
  inputSchema: {
824
1230
  url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
825
- kind: z.enum(['image', 'video']).optional(),
1231
+ kind: z.enum(['image', 'video']).optional().describe("'image' (default) or 'video'"),
826
1232
  intent: z.string().optional().describe('what the ad is trying to achieve, for goal-fit scoring'),
827
1233
  },
1234
+ outputSchema: {
1235
+ overall: z.number().optional().describe('the overall score out of 100'),
1236
+ tier: z.string().optional().describe('the qualitative tier'),
1237
+ dimensions: z.array(z.any()).optional().describe('per-dimension breakdown ({name, score})'),
1238
+ top_fix: z.string().optional().describe('the single biggest improvement lever'),
1239
+ strengths: z.any().optional().describe('what the ad already does well'),
1240
+ },
828
1241
  annotations: { readOnlyHint: true, openWorldHint: true },
829
1242
  }, wrap(async ({ url, kind = 'image', intent = '' }) => {
830
1243
  const d = await apiPost('/api/score/ad', { url, kind, intent, format: kind });
@@ -837,6 +1250,7 @@ export function registerTools(server) {
837
1250
  title: 'Reframe video',
838
1251
  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.",
839
1252
  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') },
1253
+ outputSchema: { ...JOB_OUT },
840
1254
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
841
1255
  }, wrap(async ({ video, aspectRatio }) => {
842
1256
  const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
@@ -847,6 +1261,7 @@ export function registerTools(server) {
847
1261
  title: 'Upscale video',
848
1262
  description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
849
1263
  inputSchema: { video: z.string().describe('the source video URL') },
1264
+ outputSchema: { ...JOB_OUT },
850
1265
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
851
1266
  }, wrap(async ({ video }) => {
852
1267
  const r = await renderJob('upscale', { video, factor: 2 }, 'Upscale 2x');
@@ -861,6 +1276,7 @@ export function registerTools(server) {
861
1276
  language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
862
1277
  script: z.string().optional().describe('the original spoken script if known — improves translation fidelity'),
863
1278
  },
1279
+ outputSchema: { ...JOB_OUT },
864
1280
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
865
1281
  }, wrap(async ({ video, language, script }) => {
866
1282
  const r = await renderJob('dub', { video, language, script: script || '' }, `Dub → ${language}`);
@@ -874,6 +1290,7 @@ export function registerTools(server) {
874
1290
  video: z.string().describe('the source video URL'),
875
1291
  voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)"),
876
1292
  },
1293
+ outputSchema: { ...JOB_OUT },
877
1294
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
878
1295
  }, wrap(async ({ video, voice }) => {
879
1296
  const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
@@ -889,6 +1306,7 @@ export function registerTools(server) {
889
1306
  prompt: z.string().optional().describe('optional scene/style guidance'),
890
1307
  orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
891
1308
  },
1309
+ outputSchema: { ...JOB_OUT },
892
1310
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
893
1311
  }, wrap(async ({ image, video, prompt = '', orientation = 'video' }) => {
894
1312
  const r = await renderJob('motion', { image, video, prompt, orientation }, 'Motion recast');
@@ -902,7 +1320,11 @@ export function registerTools(server) {
902
1320
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
903
1321
  product: z.string().describe('what to advertise'),
904
1322
  count: z.number().int().min(2).max(8).optional().describe('how many distinct variants (default 6)'),
905
- language: z.string().optional(),
1323
+ language: z.string().optional().describe('output language for the variant copy (e.g. Spanish) — default English'),
1324
+ },
1325
+ outputSchema: {
1326
+ variants: z.array(z.any()).optional().describe('the distinct ad angles ({name, hook, headline, visual brief})'),
1327
+ angles: z.array(z.any()).optional().describe('alternate key the planner may return the variants under'),
906
1328
  },
907
1329
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
908
1330
  }, wrap(async ({ brand, product, count = 6, language }) => {
@@ -937,6 +1359,10 @@ export function registerTools(server) {
937
1359
  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.'),
938
1360
  language: z.string().optional().describe('output language (default English)'),
939
1361
  },
1362
+ outputSchema: {
1363
+ teardown: z.any().optional().describe('the playbook — hook_taxonomy, campaigns, white_space, counter_plays, not_saying'),
1364
+ adCount: z.number().optional().describe('how many ads were analyzed'),
1365
+ },
940
1366
  annotations: { readOnlyHint: true, openWorldHint: true },
941
1367
  }, wrap(async ({ competitor, ads, language }) => {
942
1368
  const name = String(competitor?.name || '').trim();
@@ -967,6 +1393,12 @@ export function registerTools(server) {
967
1393
  category: z.string().optional().describe('the product category — helps pick the relevant policy pages'),
968
1394
  imageDescription: z.string().optional().describe('a description of the creative / image when relevant'),
969
1395
  },
1396
+ outputSchema: {
1397
+ verdict: z.string().optional().describe('pass / fix / block'),
1398
+ summary: z.string().optional().describe('one-line verdict summary'),
1399
+ findings: z.array(z.any()).optional().describe('flagged issues ({severity, issue, policy_quote, fix_suggestion, where_in_ad})'),
1400
+ anchors: z.array(z.any()).optional().describe('the Meta policy pages consulted ({url, …})'),
1401
+ },
970
1402
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
971
1403
  }, wrap(async ({ copy, claims, category, imageDescription }) => {
972
1404
  const d = await apiPost('/api/policy/check', { copy, claims: claims || '', category: category || '', imageDescription: imageDescription || '' });
@@ -983,6 +1415,12 @@ export function registerTools(server) {
983
1415
  imageUrl: z.string().describe('the URL of the static ad image to remix'),
984
1416
  brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
985
1417
  },
1418
+ outputSchema: {
1419
+ image: z.string().optional().describe('the served absolute URL of the remixed ad image'),
1420
+ model: z.string().optional().describe('the model label that rendered it'),
1421
+ slots: z.any().optional().describe('the filled slot map (layout elements swapped to your brand)'),
1422
+ residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
1423
+ },
986
1424
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
987
1425
  }, wrap(async ({ imageUrl, brandId }) => {
988
1426
  const brand = await activeBrand(brandId);
@@ -1001,6 +1439,11 @@ export function registerTools(server) {
1001
1439
  inputSchema: {
1002
1440
  brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
1003
1441
  },
1442
+ outputSchema: {
1443
+ angles: z.array(z.any()).optional().describe('the ranked angle bank ({category, angle, score, hook_draft, proof_quotes})'),
1444
+ sourceCount: z.number().optional().describe('how many customer sources were mined'),
1445
+ note: z.string().optional().describe('why no angles were returned, when the bank is empty'),
1446
+ },
1004
1447
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1005
1448
  }, wrap(async ({ brandId }) => {
1006
1449
  const brand = await activeBrand(brandId);
@@ -1019,6 +1462,10 @@ export function registerTools(server) {
1019
1462
  inputSchema: {
1020
1463
  brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
1021
1464
  },
1465
+ outputSchema: {
1466
+ summary: z.string().optional().describe('a readable rundown of the saved product photos'),
1467
+ photos: z.array(z.any()).optional().describe('the saved photos ({url, label, …})'),
1468
+ },
1022
1469
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1023
1470
  }, wrap(async ({ brandId }) => {
1024
1471
  const brand = await activeBrand(brandId);
@@ -1034,6 +1481,12 @@ export function registerTools(server) {
1034
1481
  source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
1035
1482
  brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
1036
1483
  },
1484
+ outputSchema: {
1485
+ attached: z.boolean().optional().describe('true when the image passed the product check and was locked'),
1486
+ summary: z.string().optional().describe('the check verdict — on rejection, why nothing was locked'),
1487
+ url: z.string().nullable().optional().describe('the durable served URL of the locked product photo'),
1488
+ source_note: z.string().nullable().optional().describe('where the photo came from'),
1489
+ },
1037
1490
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1038
1491
  }, wrap(async ({ imageUrl, source_note, brandId }) => {
1039
1492
  const brand = await activeBrand(brandId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
6
6
  "type": "module",