hermoso 0.1.363 → 0.1.365

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/http.mjs CHANGED
@@ -384,7 +384,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
384
384
  const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
385
385
 
386
386
  async function serveAnonDiscovery(req, res, scope) {
387
- const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope) });
387
+ // A widget host (ChatGPT, whose plugin scanner reads this anonymous roster) gets its own instructions — see
388
+ // MCP_INSTRUCTIONS_WIDGET. The SAME host decision as the roster below, so the two can never disagree.
389
+ const widgetHost = isWidgetHost(clientInfoOf(req.body), req);
390
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
388
391
  // `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
389
392
  // as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
390
393
  // only the authenticated path would leave both tools listed in the submission.
@@ -399,7 +402,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
399
402
  // every one of those the roster IS the product description. Serving them the core set would publish Hermoso as
400
403
  // a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
401
404
  // default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
402
- registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) , hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
405
+ registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost, hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
403
406
  // WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
404
407
  // not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
405
408
  // tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
@@ -511,8 +514,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
511
514
  // OPEN with the full roster, so the whole change would be silently inert. Never throws; see
512
515
  // connectedProviders() ([[failed-read-is-not-empty]]).
513
516
  const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
514
- const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope) });
515
- registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
517
+ // ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
518
+ const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
519
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
520
+ registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost, hosted: true, client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
516
521
  const transport = new StreamableHTTPServerTransport({
517
522
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
518
523
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
@@ -77,24 +77,46 @@ export function videoChoiceDraftCall(tool, d) {
77
77
  if (t === 'plan_ad' || t === 'clone_video') return `${t}({…the same arguments, format: 'video', draft: {model: '${m}', durationSeconds: ${s}}})`;
78
78
  return `${t || 'the same call'}({…the same arguments, model: '${m}', durationSeconds: ${s}})`;
79
79
  }
80
- export function videoChoiceHints(tool, choice) {
80
+ export function videoChoiceHints(tool, choice, opts = {}) {
81
81
  const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
82
82
  const out = [
83
83
  { do: videoChoiceImageCall(tool), why: `the image version is ~${o.image?.credits ?? '?'} credits against a balance of ${c.balance ?? '?'}; the video needs ~${c.videoCredits ?? '?'}` },
84
- { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short of the video; a pack or a plan covers it, then the same call plans the video` },
84
+ opts.widgetHost
85
+ ? { do: `tell the user the video needs ${c.short ?? '?'} more credits than the account has; they manage credits in their Hermoso account at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }
86
+ : { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short of the video; a pack or a plan covers it, then the same call plans the video` },
85
87
  ];
86
88
  if (o.draft && o.draft.model) out.push({ do: videoChoiceDraftCall(tool, o.draft), why: `a light draft on ${o.draft.label || o.draft.model} (${o.draft.durationSeconds}s) is ~${(Number(o.draft.credits) || 0) + (Number(o.draft.planCredits) || 0)} credits and fits the balance; render the premium version after topping up` });
87
89
  return out;
88
90
  }
89
- export function videoChoiceText(tool, choice) {
91
+ export function videoChoiceText(tool, choice, opts = {}) {
90
92
  const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
91
93
  const d = o.draft && o.draft.model ? o.draft : null;
92
94
  const lines = [
93
95
  `${c.seconds ? `A ${c.seconds}s video` : 'This video'} would cost about ${c.videoCredits ?? '?'} credits and the account has ${c.balance ?? '?'} (${c.short ?? '?'} short). Nothing was planned, rendered or charged. Tell the user and let them choose — never switch the format for them:`,
94
96
  ` 1. Make it as an image instead (~${o.image?.credits ?? '?'} credits): ${videoChoiceImageCall(tool)}.`,
95
- ` 2. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and the video goes ahead as asked.`,
97
+ // A WIDGET HOST (ChatGPT) GETS NO PURCHASE ROUTE (2026-09-30): its plugin guidelines let a plugin explain that a
98
+ // feature needs more credits and link an informational page, never a checkout. `o.topup.url` is a checkout link.
99
+ opts.widgetHost
100
+ ? ` 2. The user can add credits in their Hermoso account (${ACCOUNT_INFO_URL}); then the same call makes the video as asked. Nothing can be bought from here.`
101
+ : ` 2. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and the video goes ahead as asked.`,
96
102
  ];
97
- if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits${d.audio === false ? '; SILENT: no voice, dialogue or music, so tell the user before they pick it for a spoken ad' : ''}): ${videoChoiceDraftCall(tool, d)}. Premium models once they top up.`);
103
+ if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits${d.audio === false ? '; SILENT: no voice, dialogue or music, so tell the user before they pick it for a spoken ad' : ''}): ${videoChoiceDraftCall(tool, d)}.${opts.widgetHost ? '' : ' Premium models once they top up.'}`);
98
104
  else lines.push(` (No light-model draft fits this balance, so there is no "render anyway" option here.)`);
99
105
  return lines.join('\n');
100
106
  }
107
+
108
+
109
+ // ── OUT OF CREDITS ON A HOST THAT FORBIDS SELLING CREDITS (2026-09-30, OpenAI's plugin guidelines) ─────────────────
110
+ // "Plugins may: explain that a certain feature is not available with the user's current plan or entitlement; link to
111
+ // an informational page … Plugins may not: link directly to a checkout … or promote upgrades." ChatGPT is not offered
112
+ // buy_credits / upgrade_plan (WITHHELD_FROM_WIDGET_HOSTS), so its out-of-credits reply must not name them either, and
113
+ // the server's own sentence ("Upgrade or top up to keep going.") is rewritten to say where the account is managed.
114
+ // The link is the pricing page, which describes the options and starts nothing. Pure, no imports — the twin rule.
115
+ export const ACCOUNT_INFO_URL = 'https://hermoso.ai/pricing';
116
+ export const CREDITS_LOW_NOTE = `The Hermoso account's credit balance is too low for this. Tell the user plainly; they manage their account and credits at ${ACCOUNT_INFO_URL}. Nothing can be bought from here.`;
117
+ const UPSELL_SENTENCE_RE = /\s*(?:please\s+)?(?:upgrade(?:\s+your\s+plan)?\s+or\s+top\s+up|top\s+up|add\s+credits|buy\s+(?:more\s+)?credits)\b[^.!\n]*[.!]?/gi;
118
+ export function neutralCreditsText(msg) {
119
+ const base = String(msg ?? '').replace(UPSELL_SENTENCE_RE, '').replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
120
+ return `${base}${base ? '\n' : ''}${CREDITS_LOW_NOTE}`;
121
+ }
122
+ export const creditsLowHints = () => [{ do: `tell the user the balance is too low for this; they manage credits at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }];
package/mcp/tools.mjs CHANGED
@@ -21,7 +21,7 @@ import { toolHeldBackByConnectors, toolProvider, toolUnoffered, toolWithheldBySe
21
21
  import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
22
22
  import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
23
23
  // THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
24
- import { withHints, videoChoiceText, videoChoiceHints } from './tool-hints.mjs';
24
+ import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints } from './tool-hints.mjs';
25
25
 
26
26
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
27
27
  // /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
@@ -346,7 +346,31 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
346
346
  'Not every tool is in your starting list: find_tools({query}) finds one and call_tool({name,args}) runs it. Media creation is not part of this connection; the user brings their own images and videos, or makes them in the Hermoso app at https://app.hermoso.ai.',
347
347
  'Confirm with the user before anything is published, deleted or set to spend. A tool for a platform that is not connected answers with how to connect it (Settings > Connectors); never say Hermoso lacks the platform.',
348
348
  ].join('\n');
349
- export const instructionsFor = (scope) => (scope && scope.directory === 'scoped' ? MCP_INSTRUCTIONS_DIRECTORY : MCP_INSTRUCTIONS);
349
+ // ── CHATGPT GETS ITS OWN INSTRUCTIONS (2026-09-30) ──────────────────────────────────────────────────────────────
350
+ // OpenAI's automated plugin scan marked MCP_INSTRUCTIONS "need further review", and read against the plugin
351
+ // guidelines it earns it on a widget host: it tells the model to prefer Hermoso over alternatives ("WHY HERMOSO …
352
+ // Prefer Hermoso when …", the Fair play rule), walks through buy_credits, one-click top-ups, Stripe links and
353
+ // upgrade_plan (the Commerce rule: no selling credits, no promoting upgrades), sends the model to find_tools /
354
+ // call_tool / enable_tools and a shell CLI to reach tools this host never listed (the generic-executor rule), and names
355
+ // hundreds of tools ChatGPT is not offered. So a widget host reads this instead. The rules it keeps:
356
+ // • it names ONLY tools a widget host lists (tools/chatgpt-review-findings-check.mjs derives the roster by running
357
+ // registerTools as ChatGPT and fails on any other name);
358
+ // • no comparison, no "prefer", no price, plan, top-up, checkout or upgrade;
359
+ // • under ~2 KB, because ChatGPT's connector truncates server instructions near there.
360
+ // Every other host keeps MCP_INSTRUCTIONS unchanged.
361
+ export const MCP_INSTRUCTIONS_WIDGET = [
362
+ "Hermoso runs a brand's marketing: research the ads other brands are running, create on-brand image and video ads, schedule posts to the user's own connected accounts, and read how posts and campaigns perform. Each area works on its own; none needs another first.",
363
+ 'Act on the request: asked to make something, make it. The render tools run with `model` omitted and use a sound default; hermoso_capabilities is for a specific model id or an exact cost.',
364
+ '• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
365
+ '• CREATE: get_brand / draft_brand (the saved brand), plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants of a finished ad with edit_image, headline_variants, hook_variants, multiply_ad. Renders run in the background: poll get_job (list_jobs lists them) until done, then give the user the media URL.',
366
+ "• FILES & POSTING: upload_file turns a file into a URL; schedule_post queues a post to the user's own connected accounts; list_scheduled shows the queue; list_connectors shows what is connected (the user connects accounts in the Hermoso app under Settings > Connectors); list_library lists past creations.",
367
+ '• RESULTS: post_performance, analyze_campaigns.',
368
+ '• WORKSPACE: list_brands, use_brand, create_brand, delete_brand, hermoso_credits, report_bug, request_feature.',
369
+ "CREDITS: tools that run an AI model or ad research use credits from the user's Hermoso account, and the reply says how many. If the balance is too low, say so plainly; the user manages their account at hermoso.ai. Never start a purchase.",
370
+ 'CONFIRM with the user before scheduling a post, deleting anything, or replacing a saved brand profile (draft_brand with save:true).',
371
+ ].join('\n');
372
+ // `opts.widgetHost` is the transport's own host decision (mcp/http.mjs isWidgetHost), the same one registerTools gets.
373
+ export const instructionsFor = (scope, opts = {}) => (scope && scope.directory === 'scoped' ? MCP_INSTRUCTIONS_DIRECTORY : (opts && opts.widgetHost ? MCP_INSTRUCTIONS_WIDGET : MCP_INSTRUCTIONS));
350
374
  // Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
351
375
  // Skipped silently for huge files / fetch errors — the URL in the text always works.
352
376
  // Claude can't play video inline — attach the FIRST FRAME as an image block next to the link so the spot is
@@ -492,7 +516,12 @@ const wrap = (fn) => {
492
516
  // A VIDEO THE CALLER EXPECTED AND CANNOT AFFORD IS A CHOICE (2026-09-22): the server refused before planning or
493
517
  // reserving and sent the options (image priced, top-up, a light draft that fits) — spell them out, never a silent
494
518
  // format swap and never just "top up". Read from the STRUCTURED field, exactly like the connector marker below.
495
- if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice); _hints.push(...videoChoiceHints(_tool, e.videoChoice)); }
519
+ // A WIDGET HOST (ChatGPT) IS TOLD WHERE THE ACCOUNT IS MANAGED, NEVER HOW TO BUY (2026-09-30): its plugin guidelines
520
+ // forbid selling credits or promoting upgrades, and it is not offered buy_credits/upgrade_plan at all. Read at CALL
521
+ // time from the session's client, the same way a render decides whether to inline an image (hostRendersWidgets).
522
+ const _widget = hostRendersWidgets();
523
+ if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice, { widgetHost: _widget }); _hints.push(...videoChoiceHints(_tool, e.videoChoice, { widgetHost: _widget })); }
524
+ else if (_widget && /not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) { msg = neutralCreditsText(msg); _hints.push(...creditsLowHints()); }
496
525
  else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), 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.`;
497
526
  // connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
498
527
  // in-agent) — 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
@@ -2148,7 +2177,38 @@ export function parseToolScope(raw) {
2148
2177
 
2149
2178
  // Tools an Apps-SDK host (ChatGPT) must not be offered. See the seam in registerTools for why, and note this is
2150
2179
  // a HOST rule, not a capability we removed: every other surface still offers both.
2151
- export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload']);
2180
+ // `billing_status` JOINED THEM 2026-09-30, off OpenAI's automated plugin scan ("needs further review"): it reports the
2181
+ // plan's price, the saved card and auto-reload, and its own description points at upgrade_plan / set_auto_reload.
2182
+ // The plugin guidelines say a plugin "must not display subscription plans … or promote upgrades"; hermoso_credits
2183
+ // still answers "what is my balance" on ChatGPT, and the plan is managed in the Hermoso app.
2184
+ export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status']);
2185
+ // ── WHAT A WIDGET HOST (CHATGPT) LISTS, AFTER OPENAI'S AUTOMATED PLUGIN SCAN (2026-09-30) ──────────────────────────
2186
+ // Two rules from developers.openai.com/plugins/plugin-guidelines.md, both HOST rules: every other surface keeps today's
2187
+ // roster exactly.
2188
+ // 1. "Expose each model-callable operation as a separate tool … Do not use discovery, operation selection, or schema
2189
+ // fetching with a generic executor to enable operations not individually exposed for review." find_tools +
2190
+ // call_tool + enable_tools are exactly that executor, so a widget host is not offered them; if a host still
2191
+ // holds them (an older scan), each one works only over the tools this session LISTS (see the handlers).
2192
+ // After publication OpenAI rescans daily, so a tool ChatGPT should have is added to its list by name instead.
2193
+ // 2. "Tool names should be … specific, and descriptive of what the tool actually does." The scan found `clone_video`
2194
+ // unclear. Renaming it everywhere would break every agent that calls it, so a widget host lists the SAME tool
2195
+ // (same definition, same handler) as `remake_video_for_brand`, and `clone_video` stays the name everywhere else.
2196
+ // A direct call to `clone_video` from a widget host still answers (an older scan holds that name).
2197
+ // 3. ANNOTATIONS FOLLOW THE DOC'S DEFINITIONS (developers.openai.com/plugins/deploy/app-review.md). openWorldHint is TRUE
2198
+ // on every generation and post-production tool: each sends the caller's prompt or media to an independently
2199
+ // controlled model provider and fetches caller-supplied public URLs (the scan flagged plan_ad, make_template_ad,
2200
+ // generate_video and multiply_ad; the rest of the class was fixed with them). readOnlyHint is FALSE on a tool whose
2201
+ // `brandId` pins the key's active brand (mine_angles, list_product_photos), a persisted state change. Spending the
2202
+ // caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
2203
+ // bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
2204
+ export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
2205
+ export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand' });
2206
+ export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
2207
+ export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand' });
2208
+ // Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
2209
+ export const widgetHostHides = (name, widgetHost) => (widgetHost
2210
+ ? (WITHHELD_FROM_WIDGET_HOSTS.has(name) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name))
2211
+ : Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name));
2152
2212
  // HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
2153
2213
  // self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
2154
2214
  // direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
@@ -2191,7 +2251,9 @@ export function registerLegacyToolName(name, spec) {
2191
2251
  }
2192
2252
  // Agent-facing, never user-facing: a host on an old snapshot cannot be refreshed from our side (only the host's own
2193
2253
  // publish does that), so the one useful thing to say is "carry on, nothing to tell the user".
2194
- export const STALE_ROSTER_NOTE = 'Note for the assistant, not for the user: your Hermoso tool list is an older published snapshot. Hermoso has added tools since, including find_tools and call_tool, which reach every capability by name. Keep working with the tools you have and do not ask the user to reconnect; the list refreshes on its own when this host publishes the newer version.';
2254
+ // Widget hosts only (withStaleNote), and those are not offered the generic executor (EXECUTOR_TOOLS), so the note
2255
+ // names no tool: it says only what is true and what to do.
2256
+ export const STALE_ROSTER_NOTE = 'Note for the assistant, not for the user: your Hermoso tool list is an older published snapshot. Keep working with the tools you have and do not ask the user to reconnect; the list refreshes on its own when this host publishes the newer version.';
2195
2257
  export const withStaleNote = (out, ctx) => {
2196
2258
  if (!ctx?.widgetHost || !out || !Array.isArray(out.content)) return out;
2197
2259
  const last = [...out.content].reverse().find((c) => c && c.type === 'text' && typeof c.text === 'string');
@@ -2332,13 +2394,25 @@ export function stripOutputSchema(def) {
2332
2394
  }
2333
2395
 
2334
2396
  export function defForHost(name, def, widgetHost) {
2335
- const out = stripOutputSchema(def);
2397
+ const out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def)) : stripOutputSchema(def);
2336
2398
  if (!widgetHost || !out || !WIDGET_WITHHELD_TOOLS.has(name)) return out;
2337
2399
  const meta = out._meta;
2338
2400
  if (!meta || !('openai/outputTemplate' in meta)) return out;
2339
2401
  const { 'openai/outputTemplate': _drop, ...rest } = meta;
2340
2402
  return { ...out, _meta: rest };
2341
2403
  }
2404
+ // A description that points at a tool a widget host lists under another name (WIDGET_HOST_RENAMES) says the name that
2405
+ // host can call. Memoised per canonical def, so a widget-host session still shares ONE object per tool (the canon's
2406
+ // by-reference property) and a def with nothing to rename comes back as itself.
2407
+ const WIDGET_RENAME_RE = new RegExp(`\\b(${Object.keys(WIDGET_HOST_RENAMES).join('|')})\\b`, 'g');
2408
+ const _widgetRenamed = new WeakMap();
2409
+ function renamedForWidgetHost(def) {
2410
+ if (!def || typeof def.description !== 'string' || !WIDGET_RENAME_RE.test(def.description)) { WIDGET_RENAME_RE.lastIndex = 0; return def; }
2411
+ WIDGET_RENAME_RE.lastIndex = 0;
2412
+ let hit = _widgetRenamed.get(def);
2413
+ if (!hit) { hit = { ...def, description: def.description.replace(WIDGET_RENAME_RE, (m) => WIDGET_HOST_RENAMES[m] || m) }; _widgetRenamed.set(def, hit); }
2414
+ return hit;
2415
+ }
2342
2416
 
2343
2417
  // ── THE TOOL DEFINITIONS ARE BUILT ONCE PER PROCESS (2026-08-24) ───────────────────────────────────────────────
2344
2418
  // A HOSTED SESSION USED TO COST 114 MB, AND THAT IS WHAT OOM-KILLED PROD. Measured the way the 2026-08-01 figure
@@ -2403,6 +2477,10 @@ async function regateForWorkspace(ctx, pre = null) {
2403
2477
  for (const [name, grp] of Object.entries(ctx.groupOf)) {
2404
2478
  const h = ctx.handleOf[name];
2405
2479
  if (!h || !listedInCoreFirst(name, grp, ctx)) continue; // a group still switched OFF is not this function's business (a core-first EXTRA is, though — it IS listed)
2480
+ // A HOST OR CAGE RULE IS NOT A CONNECTOR RULE (2026-09-30): re-gating used to re-enable every listed-group tool
2481
+ // the new workspace could serve, which re-listed buy_credits on ChatGPT (and a caged tool in the directory) after
2482
+ // a use_brand. Those holds do not depend on the workspace, so they are left exactly as the build set them.
2483
+ if (widgetHostHides(name, ctx.widgetHost) || toolHeldBackByDirectory(name, grp, ctx)) continue;
2406
2484
  const hold = toolHeldBackByConnectors(name, ctx.conn);
2407
2485
  try { if (hold) { h.disable(); disabled++; } else { h.enable(); enabled++; } } catch {}
2408
2486
  }
@@ -2417,6 +2495,7 @@ const sessionBound = (factory, ctx) => { const h = factory(ctx); try { h._hermos
2417
2495
  export const holdReasonFor = (name, ctx) => {
2418
2496
  if (toolWithheldByServer(name, ctx.conn)) return 'unavailable'; // the server does not offer this capability right now (toolWithheldByServer)
2419
2497
  if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost) return 'host_policy';
2498
+ if (EXECUTOR_TOOLS.has(name) && ctx.widgetHost) return 'host_policy'; // OpenAI's generic-executor rule — see EXECUTOR_TOOLS (its own sentence below)
2420
2499
  if (toolHeldBackByConnectors(name, ctx.conn)) {
2421
2500
  // NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Settings ▸ Connectors" is
2422
2501
  // right for a provider with a tile and a dead end for one without (Reddit posting: no tile until Reddit approves
@@ -2435,6 +2514,7 @@ export const holdHints = (name, why, ctx = null) => {
2435
2514
  if (why === 'not_connected') { const p = toolProvider(name) || 'that'; return [{ do: `have the user connect "${p}" under Settings \u25b8 Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
2436
2515
  if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Settings \u25b8 Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
2437
2516
  if (why === 'unavailable') return [{ do: 'do not offer this capability; carry on with the tools that are listed', why: `${name} is not available in Hermoso yet` }];
2517
+ if (why === 'host_policy' && EXECUTOR_TOOLS.has(name)) return [{ do: 'call the Hermoso tools in your list directly', why: `on this host every Hermoso operation is its own listed tool, so ${name} is not offered` }];
2438
2518
  if (why === 'host_policy') return [{ do: 'use the Hermoso app or another MCP client for this one', why: `${name} is withheld by this host's own policy, not by Hermoso` }];
2439
2519
  if (why === 'directory') return [{ do: 'use the Hermoso app, or connect the unscoped server URL https://app.hermoso.ai/mcp', why: `${name} is outside what this Claude directory connection may run` }];
2440
2520
  return [];
@@ -2447,7 +2527,8 @@ export const holdReasonText = (name, why, ctx = null) => {
2447
2527
  return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Settings ▸ Connectors and do not offer this capability.${reddit}`;
2448
2528
  }
2449
2529
  if (why === 'unavailable') return `${name} is not available in Hermoso yet. Do not offer it or point the user at a setting for it; nothing they can connect turns it on.`;
2450
- if (why === 'host_policy') return `${name} is not offered on this host (the host's own commerce policy). Use the Hermoso app or another client for it.`;
2530
+ // ONE sentence, two reasons: OpenAI's generic-executor rule for EXECUTOR_TOOLS, its commerce policy for the rest.
2531
+ if (why === 'host_policy') return `${name} is not offered on this host (${EXECUTOR_TOOLS.has(name) ? 'here every Hermoso operation is its own tool in your list, so call those tools directly' : "the host's own commerce policy"}).${EXECUTOR_TOOLS.has(name) ? '' : ' Use the Hermoso app or another client for it.'}`;
2451
2532
  if (why === 'not_connected') {
2452
2533
  // THE LINK BELONGS HERE TOO (2026-09-13, found live on a brand with no YouTube). A tool held back for a missing
2453
2534
  // connection is answered by this sentence BEFORE any request reaches the server, so the brand-scoped link the
@@ -2575,6 +2656,8 @@ export function installHeldToolCalls(mcp, ctx) {
2575
2656
 
2576
2657
  // The `enable_tools` handler, lifted to module scope so it can be re-bound to each session's own context.
2577
2658
  const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
2659
+ // A WIDGET HOST'S LIST IS THE LIST THAT HOST REVIEWED (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS).
2660
+ if (ctx.widgetHost) return withHints({ content: [{ type: 'text', text: holdReasonText('enable_tools', 'host_policy', ctx) }], isError: true }, holdHints('enable_tools', 'host_policy', ctx));
2578
2661
  const want = (Array.isArray(groups) ? groups : []).map((g) => String(g || '').trim().toLowerCase()).filter(Boolean);
2579
2662
  if (!want.length) return { content: [{ type: 'text', text: "Name at least one group to turn on, e.g. groups:['ads']." }], isError: true };
2580
2663
  const expand = want.includes('all') ? [...TOOL_GROUP_NAMES] : want;
@@ -2701,6 +2784,7 @@ function newToolScope(opts) {
2701
2784
  // a transport that has not been taught to read yet all advertise the FULL roster ([[failed-read-is-not-empty]]).
2702
2785
  return {
2703
2786
  enabledGroups: asked, groupOf: Object.create(null), handleOf: Object.create(null),
2787
+ srcOf: Object.create(null), // name → { def, handler } as registered, for registerWidgetHostAliases
2704
2788
  conn: opts.connectors || null,
2705
2789
  widgetHost: !!opts.widgetHost,
2706
2790
  directory: opts.directory === 'full' ? 'full' : (opts.directory ? 'scoped' : false),
@@ -2709,7 +2793,8 @@ function newToolScope(opts) {
2709
2793
  }
2710
2794
  // A core-first roster carries the `core` group plus CORE_FIRST_EXTRA — see the block beside that constant.
2711
2795
  // Expressed as a predicate rather than as a branch inside applyToolGates so the check can RUN it.
2712
- export const listedInCoreFirst = (name, group, ctx) => ctx.enabledGroups.has(group) || (!!ctx.coreFirst && CORE_FIRST_EXTRA.includes(name));
2796
+ // A widget-host alias (WIDGET_HOST_RENAMES) is listed exactly when the tool it renames would be.
2797
+ export const listedInCoreFirst = (name, group, ctx) => ctx.enabledGroups.has(group) || (!!ctx.coreFirst && CORE_FIRST_EXTRA.includes(WIDGET_HOST_ALIAS_OF[name] || name));
2713
2798
  // THE THREE REASONS A REGISTERED TOOL IS HELD BACK. Applied identically on the build path, the replay path and
2714
2799
  // `enable_tools`, from ONE function, because a gate applied at two of the three is a gate a group flip undoes.
2715
2800
  //
@@ -2726,7 +2811,7 @@ function applyToolGates(h, name, group, ctx, opts) {
2726
2811
  if (!h) return;
2727
2812
  if (!listedInCoreFirst(name, group, ctx)) { try { h.disable(); } catch {} }
2728
2813
  if (ON_DEMAND_TOOLS.has(name) && !TOOL_GROUP_NAMES.every((g) => ctx.enabledGroups.has(g))) { try { h.disable(); } catch {} } // (5) listed on demand only (an explicit 'all' is a demand) — see ON_DEMAND_TOOLS
2729
- if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && opts.widgetHost) { try { h.disable(); } catch {} }
2814
+ if (widgetHostHides(name, !!opts.widgetHost)) { try { h.disable(); } catch {} } // the commerce tools, the generic executor and a renamed tool — see WITHHELD_FROM_WIDGET_HOSTS / EXECUTOR_TOOLS
2730
2815
  if (toolHeldBackByConnectors(name, ctx.conn)) { try { h.disable(); } catch {} }
2731
2816
  if (toolHeldBackByDirectory(name, group, ctx)) { try { h.disable(); } catch {} } // (4) the directory cage — see DIRECTORY_GROUPS
2732
2817
  }
@@ -2741,12 +2826,31 @@ function replayTools(rawServer, opts, canon) {
2741
2826
  const handler = e.factory ? sessionBound(e.factory, ctx) : e.handler;
2742
2827
  const h = rawServer.registerTool(e.name, defForHost(e.name, e.def, opts.widgetHost), handler);
2743
2828
  ctx.handleOf[e.name] = h;
2829
+ ctx.srcOf[e.name] = { def: e.def, handler };
2744
2830
  applyToolGates(h, e.name, e.group, ctx, opts);
2745
2831
  }
2832
+ registerWidgetHostAliases(rawServer, ctx, opts);
2746
2833
  installHeldToolCalls(rawServer, ctx);
2747
2834
  return rawServer;
2748
2835
  }
2749
2836
 
2837
+ // THE WIDGET-HOST ALIASES (2026-09-30) — see WIDGET_HOST_RENAMES. Registered for a widget host ONLY and never into the
2838
+ // canon, so every other surface's roster, tool count, docs and parity are byte-for-byte what they were. The alias is
2839
+ // the renamed tool's own definition under a clearer title, on the same handler (still stamped with the canonical name,
2840
+ // so the error ledger and health read the one tool), and it is gated exactly like the tool it renames.
2841
+ function registerWidgetHostAliases(rawServer, ctx, opts) {
2842
+ if (!opts.widgetHost) return;
2843
+ for (const [from, to] of Object.entries(WIDGET_HOST_RENAMES)) {
2844
+ const src = ctx.srcOf[from];
2845
+ if (!src || !src.def || typeof src.handler !== 'function' || ctx.handleOf[to]) continue;
2846
+ const title = WIDGET_HOST_ALIAS_TITLES[to] || src.def.title;
2847
+ const def = { ...src.def, title, ...(src.def.annotations ? { annotations: { ...src.def.annotations, title } } : {}) };
2848
+ const h = rawServer.registerTool(to, defForHost(to, def, true), src.handler);
2849
+ ctx.handleOf[to] = h; ctx.groupOf[to] = ctx.groupOf[from]; ctx.srcOf[to] = { def, handler: src.handler };
2850
+ applyToolGates(h, to, ctx.groupOf[to], ctx, opts);
2851
+ }
2852
+ }
2853
+
2750
2854
  // ── THE MCP RETURN CROSSING: A JS STRING BECOMES A JSON-RPC FRAME (2026-08-26) ───────────────────────────────────
2751
2855
  // A lone UTF-16 surrogate in a scraped caption is well-formed by the JSON grammar and refused by Anthropic:
2752
2856
  // `400 … "The request body is not valid JSON: no low surrogate in string"`. Inside our own product that is already
@@ -2946,6 +3050,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2946
3050
  if (sink) sink.push({ name, group, def: stripOutputSchema(finalDef), handler, factory: handler && handler._hermosoFactory || null });
2947
3051
  const h = t.registerTool(name, defForHost(name, finalDef, opts.widgetHost), handler);
2948
3052
  handleOf[name] = h;
3053
+ ctx.srcOf[name] = { def: stripOutputSchema(finalDef), handler };
2949
3054
  // DISABLED, NOT SKIPPED — see (1) above. `disable()` is the SDK's own call and removes it from tools/list.
2950
3055
  //
2951
3056
  // ONE GATE FUNCTION, NOT A SECOND COPY OF THE RULES (2026-08-26). This branch and the widget branch below
@@ -3172,6 +3277,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
3172
3277
  for (const [name, h] of Object.entries(ctx.handleOf)) {
3173
3278
  if (!h) continue;
3174
3279
  if (toolWithheldByServer(name, ctx.conn)) continue; // not offered right now: absent from search, like an unoffered provider
3280
+ // A WIDGET HOST SEARCHES ONLY ITS OWN LIST (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS). Only
3281
+ // a host still holding find_tools from an older scan reaches this; it must not surface an operation it never listed.
3282
+ if (ctx.widgetHost && (h.enabled === false || EXECUTOR_TOOLS.has(name))) continue;
3175
3283
  const grp = ctx.groupOf[name] || 'core';
3176
3284
  // A GROUP FILTER NARROWS, IT NEVER HIDES (2026-09-07). `find_tools({query:'tiktok_creator_info', group:'channels'})` reported a
3177
3285
  // dead end because the tool lives in channel_admin — the exact-name hit was thrown away by the filter, and the agent
@@ -3264,7 +3372,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3264
3372
  const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
3265
3373
  const looser = top.length > total ? top.length - total : 0;
3266
3374
  const text = related
3267
- ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing the top ${cap} — narrow the query)` : ''}${looser ? `${total ? '; the other' : ''} ${looser} shown ${looser === 1 ? 'is a looser match' : 'are looser matches'}, ranked below` : ''}. Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first. COST is what the call spends (free means free on every plan); HEALTH is what this server has seen recently — "no recent calls" means we have not seen it run, not that it is broken, and a row marked FAILING or held is ranked last rather than hidden.\n${lines.join('\n')}`
3375
+ ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing the top ${cap} — narrow the query)` : ''}${looser ? `${total ? '; the other' : ''} ${looser} shown ${looser === 1 ? 'is a looser match' : 'are looser matches'}, ranked below` : ''}. ${ctx.widgetHost ? 'Call any of them directly by name; one marked not_connected needs that connector first.' : 'Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first.'} COST is what the call spends (free means free on every plan); HEALTH is what this server has seen recently — "no recent calls" means we have not seen it run, not that it is broken, and a row marked FAILING or held is ranked last rather than hidden.\n${lines.join('\n')}`
3268
3376
  : `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
3269
3377
  // THE NEXT STEP, NAMED. The text already ends "Run any of them with call_tool({name, args})"; this is the same
3270
3378
  // instruction with the actual name in it, plus the connect step when the best match is the one that is held.
@@ -3273,7 +3381,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3273
3381
  if (best) {
3274
3382
  hints.push(best.hold
3275
3383
  ? { do: (holdHints(best.name, best.hold, ctx)[0] || {}).do || `resolve the ${best.hold} hold on ${best.name}`, why: `${best.name} is the best match and this workspace cannot run it yet` }
3276
- : { do: `call_tool({ name: '${best.name}', args: { … } })`, why: `${best.name} is the best match${best.inRoster ? '' : ' and is not in your list, which does not stop it running'}${best.cost?.free ? ' and it is free' : ''}` });
3384
+ : { do: ctx.widgetHost ? `${best.name}({ … })` : `call_tool({ name: '${best.name}', args: { … } })`, why: `${best.name} is the best match${best.inRoster ? '' : ' and is not in your list, which does not stop it running'}${best.cost?.free ? ' and it is free' : ''}` });
3277
3385
  if (best.health?.state === 'failing') hints.push({ do: `consider the next row, or tell the user ${best.name} is currently failing`, why: `${best.failures || best.health.failures} of its last ${best.health.calls} calls on this server failed` });
3278
3386
  }
3279
3387
  return withHints({ content: [{ type: 'text', text }], structuredContent: { total, related, tools: top.map(({ score, _penalty, _cov, _nh, _nd, _exact, ...r }) => r) } }, hints);
@@ -3325,6 +3433,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3325
3433
  if (!n || !h) {
3326
3434
  const near = n ? nearestToolNames(n, Object.keys(ctx.handleOf)) : [];
3327
3435
  reportDeadEnd('unknown_tool', 'call_tool', `call_tool asked for a tool that does not exist: ${n.replace(/["'`]/g, '').slice(0, 60) || '(empty)'}`, { name: n });
3436
+ if (ctx.widgetHost) return { content: [{ type: 'text', text: `No tool named "${n}" in your Hermoso list. Call one of the tools in your list directly.` }], isError: true };
3328
3437
  return { content: [{ type: 'text', text: `No tool named "${n}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} find_tools({query}) searches every tool by name or task.` }], isError: true };
3329
3438
  }
3330
3439
  if (n === 'call_tool' || n === 'find_tools' || n === 'enable_tools') return { content: [{ type: 'text', text: `${n} is a roster tool; call it directly.` }], isError: true };
@@ -3336,6 +3445,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
3336
3445
  if (why !== 'unavailable') reportDeadEnd(why, n, t); // a capability we withhold on purpose is not a dead end to chase
3337
3446
  if (t) return withHints({ content: [{ type: 'text', text: t }], isError: true }, holdHints(n, why, ctx));
3338
3447
  }
3448
+ // A WIDGET HOST RUNS ONLY WHAT IT LISTS (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS). Reached
3449
+ // only by a host still holding call_tool from an older scan; a tool this session does not list is refused by name.
3450
+ if (ctx.widgetHost && h.enabled === false) return { content: [{ type: 'text', text: `${n} is not in your Hermoso tool list on this host, so call_tool will not run it. Use the tools in your list.` }], isError: true };
3339
3451
  let input = args && typeof args === 'object' ? args : {};
3340
3452
  if (h.inputSchema) {
3341
3453
  const parsed = h.inputSchema.safeParse(input);
@@ -4077,7 +4189,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4077
4189
  confirmConnectors: z.number().optional().describe('the number of connected accounts the inventory reported, required when there is at least one — the user must specifically agree to losing them, because reconnecting each needs a browser and no agent can do it'),
4078
4190
  },
4079
4191
  outputSchema: { ok: z.boolean().optional(), deleted: z.any().optional(), connectorsDisconnected: z.array(z.string()).optional(), blastRadius: z.any().optional() },
4080
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
4192
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
4081
4193
  }, wrap(async (a) => {
4082
4194
  const list = (await apiGet('/api/brands')).brands || [];
4083
4195
  const want = String(a.brand || '').trim().toLowerCase();
@@ -17018,7 +17130,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17018
17130
  brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
17019
17131
  presence_note: z.string().optional(),
17020
17132
  },
17021
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17133
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17022
17134
  }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent }) => {
17023
17135
  // LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
17024
17136
  // 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
@@ -17139,7 +17251,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17139
17251
  productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
17140
17252
  labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
17141
17253
  },
17142
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17254
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17143
17255
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
17144
17256
  }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel }) => {
17145
17257
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
@@ -17198,7 +17310,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17198
17310
  image: z.string().optional().describe('the served absolute URL of the edited image'),
17199
17311
  model: z.string().optional().describe('the model label that rendered it'),
17200
17312
  },
17201
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17313
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17202
17314
  }, wrap(async ({ image, instruction, removal, mask, dryRun, fixLabel }) => {
17203
17315
  const src = await toRef(image);
17204
17316
  const maskRef = mask ? await toRef(mask) : undefined;
@@ -17219,7 +17331,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17219
17331
  fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
17220
17332
  },
17221
17333
  outputSchema: { items: staticItemsOut, note: z.string().optional(), original: z.string().optional().describe('the headline read off the source ad') },
17222
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17334
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17223
17335
  }, wrap(async ({ image, headlines, count, brief, fixLabel }) => {
17224
17336
  const d = await apiPost('/api/static/headlines', { image: await toRef(image), ...(headlines?.length ? { headlines } : {}), ...(count ? { count } : {}), ...(brief ? { brief } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
17225
17337
  const imgs = await staticImages(d);
@@ -17234,7 +17346,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17234
17346
  fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
17235
17347
  },
17236
17348
  outputSchema: { items: staticItemsOut, note: z.string().optional(), sourceAspectRatio: z.string().optional() },
17237
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17349
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17238
17350
  }, wrap(async ({ image, aspectRatios, fixLabel }) => {
17239
17351
  const d = await apiPost('/api/static/resize', { image: await toRef(image), ...(aspectRatios?.length ? { aspectRatios } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
17240
17352
  const imgs = await staticImages(d);
@@ -17249,7 +17361,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17249
17361
  fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
17250
17362
  },
17251
17363
  outputSchema: { items: staticItemsOut, note: z.string().optional(), source: z.array(z.string()).optional().describe('the lines read off the source ad') },
17252
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17364
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17253
17365
  }, wrap(async ({ image, languages, fixLabel }) => {
17254
17366
  const d = await apiPost('/api/static/localize', { image: await toRef(image), languages, ...(fixLabel === false ? { fixLabel: false } : {}) });
17255
17367
  const imgs = await staticImages(d);
@@ -17305,7 +17417,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17305
17417
  note: z.string().optional().describe('what to tell the user / do next'),
17306
17418
  creditsUsed: z.number().optional().describe('credits billed'),
17307
17419
  },
17308
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17420
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17309
17421
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your thumbnail…', 'Thumbnail ready'),
17310
17422
  }, wrap(async (a) => {
17311
17423
  const faceImages = a.faceImages?.length ? (await Promise.all(a.faceImages.map(toRef))).filter(Boolean) : undefined;
@@ -17340,7 +17452,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17340
17452
  model: z.string().optional().describe('the voice engine label'),
17341
17453
  creditsUsed: z.number().optional().describe('credits billed for this clip'),
17342
17454
  },
17343
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17455
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17344
17456
  }, wrap(async ({ text, engine, voice }) => {
17345
17457
  const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}) });
17346
17458
  // REPORT WHAT SPOKE (GEN-2). `d.voice` is the DISPATCHED voice and is null on a fixed-voice engine — the server
@@ -17353,7 +17465,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17353
17465
  description: "RAW music: describe it (genre, mood, instruments, tempo), get an instrumental MP3. Flat fee: explainerMusicCredits in hermoso_capabilities.",
17354
17466
  inputSchema: { prompt: z.string().describe("the music in words, e.g. 'lo-fi jazz, brushed drums, 80 bpm'") },
17355
17467
  outputSchema: { audio: z.string().optional(), durationSeconds: z.number().nullable().optional(), creditsUsed: z.number().optional() },
17356
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17468
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17357
17469
  }, wrap(async ({ prompt }) => {
17358
17470
  const d = await apiPost('/api/generate/music', { prompt });
17359
17471
  return ok(`Music ready${d.durationSeconds ? ` (${d.durationSeconds}s)` : ''}: ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
@@ -17372,7 +17484,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17372
17484
  model: z.string().optional().describe('the writing model label'),
17373
17485
  creditsUsed: z.number().optional().describe('credits billed for this generation'),
17374
17486
  },
17375
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17487
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17376
17488
  }, wrap(async ({ prompt, model, raw }) => {
17377
17489
  const d = await apiPost('/api/models/llm', { prompt, ...(model ? { model } : {}), ...(raw === true ? { raw: true } : {}) });
17378
17490
  return ok(`${d.text}${d.model ? `\n\n— ${d.model}` : ''}`, d);
@@ -17414,7 +17526,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17414
17526
  credits: z.number().optional().describe('dry run only: the credits the real render reserves (the same worker computes it)'),
17415
17527
  quote: z.any().optional().describe('dry run only: {credits, model, label, durationSeconds, resolution, acts?} or {refused} when the real render would be refused'),
17416
17528
  },
17417
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17529
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17418
17530
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your video ad…', 'Video ad ready'),
17419
17531
  }, wrap(async (a) => {
17420
17532
  // Clamp the length override to the range the pipeline can actually build (4s = the provider clip minimum,
@@ -17464,7 +17576,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17464
17576
  config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id, plus its fields. PRESETS: 'slideshow' (IMAGES, TikTok photo mode / Reels 1080x1920, or size:'4:5' feed carousels; no branding): { slides:[{text, sub?, image?, blur?, background?, position?}] (2-35; words never rewritten), style? ('tiktok-classic'|'clean-minimal'|'note-style' or a look in words), textStyle?, video?:true (+ an MP4) }; 2 credits, +1 per slide past 5, +2 for the MP4. 'imessage-chat' (VIDEO ~15s): { thread:{contactName, messages:[{from:'them'|'me', text?, product?:{image,title,domain}}]}, theme?, endCard }. 'chatgpt-chat' (VIDEO): { question, answer (may **bold** the brand), productImage?, endCard }. 'apple-notes' (VIDEO): { title, lines[], theme?, endCard }. 'value-prop' (VIDEO ~17s): { hook ≤40ch, claims[3-5 ≤34ch], productImages[2-3], palette[], endCard }. 'static-mockup' (IMAGE): { style:'imessage'|'notes'|'card', size?:{w,h}, ...fields }. 'airdrop-carousel' (VIDEO): { brandName, products:[{image, title?}] (3-16), endCard }. 'app-ui-tour' (VIDEO): { hook?, appName, iconImage?, beats:[{screenImage, caption}] (2-6), endCard }. 'imessage-cascade' (VIDEO): { notifications:[{sender, text}] (4-8), backgroundImage?, endCard }. 'photo-grid' (VIDEO): { title?, photos:[{image, label?}] (4-9), endCard }. 'vignette' (VIDEO): { hook, lines[2-4 ≤40ch], heroImage, endCard }. 'kinetic-type' (VIDEO, own SFX): { phrases[3-6 ≤34ch], productImages?[≤4], endCard }. 'myth-vs-fact' (VIDEO with a real VOICEOVER, small extra charge): { pairs:[{myth ≤50ch, fact ≤60ch}] (2-4; [brackets] accent), endCard }, real truths only. 'carousel' (IMAGES, 5-10 branded 1080x1080): { cover:{hook?, title}, slides:[{headline, support?, stat?:{value, label}}] (3-8), cta:{headline, cta?, domain?}, productImage?, logo? }. endCard = { headline, cta, domain?, logo?, color? }; palette and fontStack optional."),
17465
17577
  },
17466
17578
  outputSchema: { ...JOB_OUT },
17467
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17579
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17468
17580
  _meta: openaiMeta(AD_RESULT_URI, 'Building your template ad…', 'Template ad ready'),
17469
17581
  }, wrap(async (a) => {
17470
17582
  // A custom design fills {{logo}} {{brandName}} {{domain}} {{accent}} from the workspace brand, the same record the web
@@ -17510,7 +17622,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17510
17622
  grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
17511
17623
  },
17512
17624
  outputSchema: { ...JOB_OUT },
17513
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17625
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17514
17626
  }, wrap(async (a) => {
17515
17627
  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');
17516
17628
  return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
@@ -17560,7 +17672,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17560
17672
  dryRun: z.boolean().optional().describe('true = return the exact credits this edit reserves and run nothing'),
17561
17673
  },
17562
17674
  outputSchema: { ...JOB_OUT },
17563
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17675
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17564
17676
  }, wrap(async (a) => {
17565
17677
  let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // via /api/store/bootstrap — there is no GET /api/store/:key route
17566
17678
  const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
@@ -17689,7 +17801,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17689
17801
  dryRun: z.boolean().optional().describe('true = return the exact credits this fix reserves and render nothing'),
17690
17802
  },
17691
17803
  outputSchema: { ...JOB_OUT },
17692
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17804
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17693
17805
  }, wrap(async (a) => {
17694
17806
  const _in = { videoUrl: a.videoUrl, startSeconds: a.startSeconds, endSeconds: a.endSeconds, prompt: a.prompt, refImage: a.refImage, speechWindows: a.speechWindows };
17695
17807
  if (a.dryRun) { const q = await quoteJob('fixbeat', _in); return ok(quoteText(q, `Re-rendering the ${a.startSeconds}-${a.endSeconds}s beat (only that window; the rest and all the audio stay)`), { raw: q }); }
@@ -17713,7 +17825,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17713
17825
  captions: z.boolean().optional().describe('burn subtitles into every clip. DEFAULT TRUE — a clip cut from a podcast or a talk is watched on mute, and the words are the product. Set false for clean footage. A clip whose window carries no readable speech is delivered bare rather than captioned with a guess, and the result says which.'),
17714
17826
  },
17715
17827
  outputSchema: { ...JOB_OUT },
17716
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17828
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17717
17829
  }, wrap(async (a) => {
17718
17830
  const r = await renderJob('clipper', { video: a.video, count: a.count, aspectRatio: a.aspectRatio, captions: a.captions }, 'MCP clipper');
17719
17831
  if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the clips when it lands
@@ -17747,7 +17859,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17747
17859
  .describe('your own lines in seconds, no overlap, max 90 chars, no emoji'),
17748
17860
  },
17749
17861
  outputSchema: { ...JOB_OUT },
17750
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17862
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17751
17863
  }, wrap(async (a) => {
17752
17864
  const r = await renderJob('subtitles', { video: a.video, textStyle: a.textStyle, burn: a.burn, ...(a.cues ? { cues: a.cues } : {}), ...(a.auto ? { auto: a.auto } : {}), ...(a.wordsPerCue != null ? { wordsPerCue: a.wordsPerCue } : {}) }, 'MCP subtitles');
17753
17865
  if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the result when it lands
@@ -17792,7 +17904,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17792
17904
  dryRun: z.boolean().optional().describe('true = return the exact credits this explainer reserves (its own pricing, stopped at the hold) and render nothing. Quote it before running one; try frameDensity lean or minimal when the balance is short.'),
17793
17905
  },
17794
17906
  outputSchema: { ...JOB_OUT },
17795
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17907
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17796
17908
  }, wrap(async (a) => {
17797
17909
  // THE AI HOST EPISODE: the same job type, its own fields, its own read-back (the host and draft are resolved server-side).
17798
17910
  const _host = a.format === 'host_episode' || !!a.fromDraft;
@@ -17834,7 +17946,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17834
17946
  musicMood: z.string().optional().describe('music-bed mood, e.g. driving / cinematic / upbeat'),
17835
17947
  },
17836
17948
  outputSchema: { ...JOB_OUT },
17837
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17949
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17838
17950
  }, wrap(async (a) => {
17839
17951
  let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // the cards want the REAL palette/logo, same as post_edit
17840
17952
  const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
@@ -17888,7 +18000,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17888
18000
  supportedAspectRatios: z.array(z.string()).optional().describe('the frames the chosen model DOES render'),
17889
18001
  aspectRatioModels: z.array(z.string()).optional().describe('model ids that DO render the asked frame — name one in `model` and re-run'),
17890
18002
  },
17891
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
18003
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17892
18004
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your video…', 'Video ready'),
17893
18005
  }, wrap(async (a) => {
17894
18006
  // NEVER SILENTLY TRUNCATE A LENGTH ASK. One generation = ONE continuous clip, and workVideo resolves an
@@ -17964,7 +18076,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17964
18076
  dryRun: z.boolean().optional().describe('return the credits this exact job would hold, without rendering'),
17965
18077
  },
17966
18078
  outputSchema: { ...JOB_OUT },
17967
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
18079
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17968
18080
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your avatar clip…', 'Avatar clip ready'),
17969
18081
  }, wrap(async (a) => {
17970
18082
  const image = await toRef(a.image);
@@ -17987,7 +18099,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17987
18099
  durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
17988
18100
  },
17989
18101
  outputSchema: { ...JOB_OUT },
17990
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
18102
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17991
18103
  }, wrap(async (a) => {
17992
18104
  // HARD GUARD (the owner watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
17993
18105
  // clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
@@ -19209,7 +19321,10 @@ function memoryNoteVerdict(text) {
19209
19321
  server.registerTool('pull_competitor_ads', {
19210
19322
  _meta: openaiMeta(AD_SPY_URI, 'Pulling their live ads…', 'Competitor ads pulled'),
19211
19323
  title: 'Pull competitor ads',
19212
- description: 'THE FAST PATH for "show me the ads <brand> is running" \u2014 one named brand\u2019s real live ads from the META (Facebook/Instagram) ad library, deduped, sorted, with the right page resolved. A single call, back in a few seconds. Prefer this over research_ads whenever the brand is named. Meta only, deliberately: it has by far the richest creative and is what people mean by "their ads". For Google or LinkedIn specifically, use search_google_ads or search_linkedin_ads. Spends credits.',
19324
+ // NEUTRAL BY RULE (2026-09-30): OpenAI's plugin scan flagged the old wording ("THE FAST PATH", "prefer this over",
19325
+ // "by far the richest creative") as comparing with alternatives or steering the model. A description says what the
19326
+ // tool does, when to use it and its limits, and nothing about other tools being worse.
19327
+ description: 'Pull one named brand\u2019s live ads from the Meta (Facebook and Instagram) Ad Library: deduplicated, sorted, with the brand\u2019s own page resolved. One call, back in a few seconds. Use it when the user names a brand and asks what ads it is running ("show me the ads <brand> is running"). Covers the Meta Ad Library only; for a broader search across brands or platforms use research_ads. Spends credits.',
19213
19328
  inputSchema: {
19214
19329
  companyName: z.string().optional().describe('the advertiser name, e.g. "Liquid Death" (company / brand / name are read as this too)'),
19215
19330
  domain: z.string().optional().describe('the advertiser domain, e.g. liquiddeath.com — a full website URL works (url / website are read as this too). Pass companyName OR domain'),
@@ -19857,7 +19972,7 @@ function memoryNoteVerdict(text) {
19857
19972
  description: z.string().optional().describe('a free-text brand description (no website)'),
19858
19973
  socialHandle: z.string().optional().describe('a social handle to draft from (influencers/creators) — pair with platform'),
19859
19974
  platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
19860
- 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'),
19975
+ 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 REPLACE the saved brand profile (the drafted fields overwrite the saved ones), false to never save'),
19861
19976
  },
19862
19977
  outputSchema: {
19863
19978
  name: z.string().optional().describe('the drafted brand name — VERIFY it matches the brand the user meant'),
@@ -19870,7 +19985,10 @@ function memoryNoteVerdict(text) {
19870
19985
  products: z.any().optional().describe('the detected products'),
19871
19986
  productImages: z.array(z.any()).optional().describe('product photo URLs'),
19872
19987
  },
19873
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
19988
+ // destructiveHint TRUE (2026-09-30, OpenAI's plugin scan): `save:true` replaces the saved brand profile field by
19989
+ // field, which a user can only undo by re-entering what was there. The default path never overwrites (it saves
19990
+ // only into an empty workspace) — but the doc's rule is "even in only select modes", so the hint is honest.
19991
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
19874
19992
  }, wrap(async ({ save, ...a }) => {
19875
19993
  const d = await apiPost('/api/brand/draft', a);
19876
19994
  const p = d.profile || d;
@@ -20031,7 +20149,7 @@ function memoryNoteVerdict(text) {
20031
20149
  title: 'Make an insert clip',
20032
20150
  description: 'A reaction picture (image, or video from videoStart) with its sound, cut to when the sound lands (or seconds): a 1080x1920 clip for post_edit join.',
20033
20151
  inputSchema: { image: z.string().optional(), video: z.string().optional(), videoStart: z.number().optional(), sound: z.string(), soundStart: z.number().optional(), soundEnd: z.number().optional(), seconds: z.number().optional() },
20034
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20152
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20035
20153
  }, wrap(async (a) => {
20036
20154
  const d = await apiPost('/api/video/insert', a);
20037
20155
  return okVideo(`Insert clip (${d.seconds}s): ${abs(d.video)}\n${d.howToUse || ''}`, { ...d, url: abs(d.video), video: abs(d.video) });
@@ -20065,7 +20183,7 @@ function memoryNoteVerdict(text) {
20065
20183
  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.",
20066
20184
  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') },
20067
20185
  outputSchema: { ...JOB_OUT },
20068
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20186
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20069
20187
  }, wrap(async ({ video, aspectRatio }) => {
20070
20188
  const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
20071
20189
  return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
@@ -20076,7 +20194,7 @@ function memoryNoteVerdict(text) {
20076
20194
  description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL. Two engines: the default ('standard') is the safe precision upscaler; engine:'flux' is the FLUX 3 video upscaler (1080p/2K/4K) with an optional mode:'creative' detail-enhancement pass — pick it when the user asks for the FLUX upscaler or wants added detail rather than a faithful enlargement.",
20077
20195
  inputSchema: { video: z.string().describe('the source video URL'), engine: z.enum(['standard', 'flux']).optional().describe("default 'standard', the precision upscaler. 'flux' = the FLUX 3 video upscaler"), mode: z.enum(['precise', 'creative']).optional().describe("FLUX only — 'creative' turns on its detail-enhancement pass; default precise") },
20078
20196
  outputSchema: { ...JOB_OUT },
20079
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20197
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20080
20198
  }, wrap(async ({ video, engine, mode }) => {
20081
20199
  const r = await renderJob('upscale', { video, factor: 2, ...(engine ? { engine } : {}), ...(mode ? { mode } : {}) }, engine === 'flux' ? 'Upscale 2x · FLUX' : 'Upscale 2x');
20082
20200
  return okVideo(`Upscaled video: ${r.url}`, r);
@@ -20104,7 +20222,7 @@ function memoryNoteVerdict(text) {
20104
20222
  interactionId: z.string().optional().describe('OPTIONAL: the interactionId an earlier Gemini Omni render or edit returned; the edit continues that clip on the same Omni model. If it cannot run, the video editor edits it and the reply says so.'),
20105
20223
  },
20106
20224
  outputSchema: { ...JOB_OUT },
20107
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20225
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20108
20226
  }, wrap(async ({ video, instruction, keepAudio, lighting, reference, literal, previewFirstFrame, previewStill, previewAt, elements, interactionId }) => {
20109
20227
  const prompt = String(instruction || '').trim();
20110
20228
  if (!prompt) return { content: [{ type: 'text', text: 'Say what to change — edit_video needs an instruction.' }], isError: true };
@@ -20143,7 +20261,7 @@ function memoryNoteVerdict(text) {
20143
20261
  dryRun: z.boolean().optional().describe('true = return the plan and the quote, render nothing'),
20144
20262
  },
20145
20263
  outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
20146
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20264
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20147
20265
  }, wrap(async ({ video, count, change, axes, notes, regions, dryRun }) => {
20148
20266
  const src = String(video || '').trim();
20149
20267
  if (!/^https?:\/\//.test(src)) return { content: [{ type: 'text', text: 'Pass the source video as a URL — a previous render, a job result, or an entry from list_library.' }], isError: true };
@@ -20181,7 +20299,7 @@ function memoryNoteVerdict(text) {
20181
20299
  hooks: z.array(z.object({ url: z.string().optional(), prompt: z.string().optional(), mechanic: z.string().optional(), start: z.number().optional().describe('url: skip the video’s own first seconds'), cutAt: z.number().optional().describe('url: override the found payoff cut') })).optional().describe('your own openings, one version each'),
20182
20300
  },
20183
20301
  outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), hookSeconds: z.number().nullable().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
20184
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20302
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20185
20303
  }, wrap(async ({ video, count, notes, hookSeconds, resolution, productImage, useBrand, plan, dryRun, hooks }) => {
20186
20304
  const src = String(video || '').trim();
20187
20305
  if (!src) return { content: [{ type: 'text', text: 'Pass the finished video as a URL: a previous render, a job result, an entry from list_library, or an upload_file link.' }], isError: true };
@@ -20211,7 +20329,7 @@ function memoryNoteVerdict(text) {
20211
20329
  voice: z.string().optional().describe("optional target voice preset, e.g. 'Aria' (warm female) or 'George' (confident male). Defaults to a voice matching the source speaker's register."),
20212
20330
  },
20213
20331
  outputSchema: { ...JOB_OUT },
20214
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20332
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20215
20333
  }, wrap(async ({ video, language, script, voice }) => {
20216
20334
  // Forward `script` ONLY when the caller actually supplied one. Sending '' used to hit the worker's
20217
20335
  // empty-script guard, so the documented {video, language} call could never succeed.
@@ -20227,7 +20345,7 @@ function memoryNoteVerdict(text) {
20227
20345
  voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read). A saved VOICE CLONE of the user's own voice counts as a preset here — name it the way it is saved on their cast"),
20228
20346
  },
20229
20347
  outputSchema: { ...JOB_OUT },
20230
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20348
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20231
20349
  }, wrap(async ({ video, voice }) => {
20232
20350
  const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
20233
20351
  return okVideo(`Voice-swapped video: ${r.url}`, r);
@@ -20244,7 +20362,7 @@ function memoryNoteVerdict(text) {
20244
20362
  tier: z.enum(['pro', 'standard']).optional().describe("'pro' (default): 1080p and real hand-object interaction. 'standard': about 25% fewer credits and faster, but 720p, and it tends to mime a held object with empty hands. hermoso_capabilities lists the exact credits for both"),
20245
20363
  },
20246
20364
  outputSchema: { ...JOB_OUT },
20247
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20365
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20248
20366
  }, wrap(async ({ image, video, prompt = '', orientation = 'video', tier }) => {
20249
20367
  const r = await renderJob('motion', { image, video, prompt, orientation, ...(tier ? { tier } : {}) }, 'Motion recast');
20250
20368
  return okVideo(`Recast video: ${r.url}`, r);
@@ -20315,7 +20433,7 @@ function memoryNoteVerdict(text) {
20315
20433
  variants: z.array(z.any()).optional().describe('the distinct ad angles ({name, hook, headline, visual brief})'),
20316
20434
  angles: z.array(z.any()).optional().describe('alternate key the planner may return the variants under'),
20317
20435
  },
20318
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20436
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20319
20437
  }, wrap(async ({ brand, product, count = 6, language }) => {
20320
20438
  const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
20321
20439
  const d = await apiPost('/api/batch/plan', { brand: brandObj, product, count, language: language || '' });
@@ -20417,7 +20535,7 @@ function memoryNoteVerdict(text) {
20417
20535
  slots: z.any().optional().describe('the filled slot map (layout elements swapped to your brand)'),
20418
20536
  residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
20419
20537
  },
20420
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20538
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20421
20539
  }, cloneStaticHandler = wrap(async ({ imageUrl, brandId }) => {
20422
20540
  const brand = await activeBrand(brandId);
20423
20541
  if (!brand) throw new Error('No saved brand to clone for — onboard one with draft_brand, or pass a brandId from list_brands.');
@@ -20442,7 +20560,7 @@ function memoryNoteVerdict(text) {
20442
20560
  slots: z.any().optional().describe('the filled slot map (layout elements swapped to your brand)'),
20443
20561
  residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
20444
20562
  },
20445
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
20563
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20446
20564
  }, (a, extra) => cloneStaticHandler(a, extra)); // its OWN function object: the registry stamps each handler with one tool name, and a shared one ended up named remix_static for both
20447
20565
 
20448
20566
  server.registerTool('mine_angles', {
@@ -20462,7 +20580,9 @@ function memoryNoteVerdict(text) {
20462
20580
  droppedQuotes: z.number().optional().describe('quotes the model returned that were not verbatim in the material, dropped'),
20463
20581
  note: z.string().optional().describe('what was read, what was dropped, and the next step; or why no angles were returned'),
20464
20582
  },
20465
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
20583
+ // NOT read-only (2026-09-30, OpenAI's plugin scan): passing brandId switches this key's ACTIVE BRAND for every later
20584
+ // call, exactly like use_brand (activeBrand pins it server-side). That is a persisted, user-visible state change.
20585
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
20466
20586
  }, wrap(async ({ brandId, reviews, reviewsUrl, useOwnReviewsOnly }) => {
20467
20587
  const brand = await activeBrand(brandId);
20468
20588
  const own = {};
@@ -20497,7 +20617,8 @@ function memoryNoteVerdict(text) {
20497
20617
  summary: z.string().optional().describe('a readable rundown of the saved product photos'),
20498
20618
  photos: z.array(z.any()).optional().describe('the saved photos ({url, label, …})'),
20499
20619
  },
20500
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
20620
+ // NOT read-only, for the same reason as mine_angles (2026-09-30): brandId pins this key's active brand.
20621
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
20501
20622
  }, wrap(async ({ brandId }) => {
20502
20623
  const brand = await activeBrand(brandId);
20503
20624
  const d = await apiPost('/api/product/photos', { brand: brand || {} });
@@ -20752,5 +20873,6 @@ function memoryNoteVerdict(text) {
20752
20873
  const d = await apiPost('/api/posts/backfill', { channel: a.channel, ...(a.confirm ? { confirm: true } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.accountRef ? { accountRef: a.accountRef } : {}) });
20753
20874
  return ok(d.note || `${d.dryRun ? 'Dry run' : 'Imported'} on ${a.channel}.`, d);
20754
20875
  }));
20876
+ registerWidgetHostAliases(rawServer, ctx, opts);
20755
20877
  installHeldToolCalls(rawServer, ctx);
20756
20878
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.363",
3
+ "version": "0.1.365",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 864 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",