hermoso 0.1.305 → 0.1.310

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,9 +155,10 @@ the routes.
155
155
 
156
156
  ## Instant: the hosted Claude.ai connector
157
157
 
158
- Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Settings → Connectors → *Add custom connector*, pick
159
- **Always required** when Claude asks about authentication (its detector suggests "None" because our discovery
160
- handshake is open; "None" would leave every tool call unauthenticated), approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
158
+ Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
159
+ press Continue, choose **Sign in now** under Authentication (Claude's detector pre-selects "No sign-in" because our
160
+ discovery handshake is open, and with that your first request comes back "Authentication required"), press Add and
161
+ Connect, approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
161
162
 
162
163
  ## Quickstart for Claude Code (one command)
163
164
 
@@ -181,10 +182,10 @@ claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermos
181
182
  Rather install the CLI by hand? `npm install -g hermoso` puts the same `hermoso` command on your PATH, and the
182
183
  skills use it when it is there.
183
184
 
184
- The hosted URL works in Claude Code too, but it is the worse path there and it is worth knowing why:
185
- `claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"` is accepted, and then `claude mcp list`
186
- reports `! Needs authentication` because the client will not start the OAuth flow by itself: you have to open a
187
- session, run `/mcp`, find the server and press Authenticate. Measured against Claude Code 2.1.241 on 2026-08-23.
185
+ The hosted URL works in Claude Code too, but the plugin is the lighter path there because it loads no tool list
186
+ into your sessions. If you want the connector: `claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"`,
187
+ and `claude mcp list` reports `! Needs authentication` until you run `claude mcp login hermoso` once and approve in
188
+ your browser (`--no-browser` prints the link on a headless machine). Measured against Claude Code 2.1.282 on 2026-09-25.
188
189
 
189
190
  Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
190
191
  learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
package/mcp/http.mjs CHANGED
@@ -12,7 +12,8 @@
12
12
  //
13
13
  // When the cloud step happens, the remaining work is small and explicit (see ENABLE CHECKLIST at the bottom).
14
14
  // ───────────────────────────────────────────────────────────────────────────────────────────────────────
15
- import { randomUUID } from 'node:crypto';
15
+ import { randomUUID, createHash } from 'node:crypto';
16
+ import { createRequire } from 'node:module';
16
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
18
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
18
19
  import { registerTools, MCP_INSTRUCTIONS, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
@@ -20,7 +21,15 @@ import { mcpCtx, connectedProviders } from './client.mjs';
20
21
 
21
22
  // Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
22
23
  // `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
23
- export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null } = {}) {
24
+ // The release version directories show as ours (Smithery printed the hard-coded "1.0.0"). Server side this file sits
25
+ // beside cli/, whose package.json carries the release; inside the npm package there is no ../cli/ and ../package.json
26
+ // IS the CLI package. One resolver, so the byte-identical twins agree.
27
+ const PKG_VERSION = (() => { const r = createRequire(import.meta.url); for (const p of ['../cli/package.json', '../package.json']) { try { const v = r(p).version; if (v) return v; } catch {} } return '0.0.0'; })();
28
+ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null, onConnectEvent = null } = {}) {
29
+ // Outcomes of a real connect, for the host's connect watch (lib/mcp-connect-watch.mjs): a bearer we reject, and the
30
+ // first tools/list of a signed-in session (keyed by the token's hash, so a FRESH token's first listing marks a
31
+ // completed connect). Never throws into the request.
32
+ const connectEvent = (req, evt) => { if (typeof onConnectEvent !== 'function') return; try { onConnectEvent({ ua: String(req?.headers?.['user-agent'] || '').slice(0, 160), ...evt }); } catch {} };
24
33
  if ((process.env.HERMOSO_MCP_REMOTE ?? process.env.HEIST_MCP_REMOTE) !== '1') return false; // gate 1: off by default
25
34
  if (typeof verifyBearer !== 'function') { // gate 2: refuse without real auth
26
35
  console.error('[mcp-remote] REFUSING to mount: no token verifier wired. A remote, money-spending MCP must authenticate every caller (no-anon-spend). Wire Firebase Auth → verifyBearer first.');
@@ -63,6 +72,42 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
63
72
  app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
64
73
  app.get(`/.well-known/oauth-protected-resource${MCP_PATH}`, protectedResourceMetadata);
65
74
 
75
+ // ── THE STATIC SERVER CARD A DIRECTORY READS INSTEAD OF SCANNING (2026-09-25) ─────────────────────────────────────
76
+ // Smithery's re-scan stopped at "Authentication required": its first probe (UA `SmitheryBot/1.0 (+https://…)`) gets
77
+ // the anonymous preview, but its connect step sends NO user-agent, and a UA-less tokenless handshake is exactly
78
+ // Grok's setup probe, which MUST stay challenged (a 200 there made Grok save us as a no-auth connector). Smithery's
79
+ // own answer for an OAuth server is this document (smithery.ai/docs/build/publish, read 2026-09-25: "you can bypass
80
+ // scanning by serving metadata manually at /.well-known/mcp/server-card.json" — serverInfo, authentication, tools,
81
+ // resources, prompts, SEP-1649 shapes). So the roster a crawler would have listed anonymously is published here,
82
+ // built from the SAME registerTools call the anonymous preview makes and read back through a real MCP client, so
83
+ // it cannot drift from what tools/list serves. Built once per process (the roster is static per process).
84
+ let cardPromise = null;
85
+ const buildServerCard = async () => {
86
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
87
+ const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js');
88
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
89
+ registerTools(server, { only: [...DEFAULT_TOOL_GROUPS], directory: false, widgetHost: false, hosted: true });
90
+ const [a, b] = InMemoryTransport.createLinkedPair();
91
+ const client = new Client({ name: 'server-card', version: '1' });
92
+ await Promise.all([server.connect(a), client.connect(b)]);
93
+ try {
94
+ const list = async (fn, key) => { const out = []; let cursor; do { const r = await fn(cursor ? { cursor } : {}).catch(() => null); if (!r) break; out.push(...(r[key] || [])); cursor = r.nextCursor; } while (cursor); return out; };
95
+ const tools = await list((p) => client.listTools(p), 'tools');
96
+ const resources = await list((p) => client.listResources(p), 'resources');
97
+ const prompts = await list((p) => client.listPrompts(p), 'prompts');
98
+ return { serverInfo: { name: 'hermoso', title: 'Hermoso', version: PKG_VERSION }, instructions: MCP_INSTRUCTIONS,
99
+ authentication: { required: true, schemes: ['oauth2', 'bearer'] },
100
+ transport: { type: 'streamable-http', url: `${BASE}${MCP_PATH}` },
101
+ tools, resources, prompts };
102
+ } finally { try { await client.close(); } catch {} try { await server.close(); } catch {} }
103
+ };
104
+ app.get('/.well-known/mcp/server-card.json', async (req, res) => {
105
+ try {
106
+ cardPromise ||= buildServerCard().catch((e) => { cardPromise = null; throw e; });
107
+ res.set('Cache-Control', 'public, max-age=3600').json(await cardPromise);
108
+ } catch (e) { res.status(503).json({ error: 'server card unavailable, try again', detail: String(e?.message || e).slice(0, 200) }); }
109
+ });
110
+
66
111
  // Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
67
112
  //
68
113
  // ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01, again 2026-08-24) ───────────────────
@@ -123,7 +168,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
123
168
  // then the two that need no browser at all. `WWW-Authenticate` still points at the protected-resource metadata,
124
169
  // which is what a spec-following client uses; this is for the one that is not following it.
125
170
  const challenge = (res) => res.status(401)
126
- .set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"`)
171
+ // `scope` rides the challenge (MCP authorization spec, "Protected Resource Metadata Discovery Requirements":
172
+ // servers SHOULD include it; ChatGPT's own auth doc shows the same shape). The same ONE list the PRM and the AS
173
+ // metadata publish, so a client that scopes its authorize request from the challenge asks for exactly that.
174
+ .set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource", scope="hermoso.research hermoso.generate"`)
127
175
  .json({
128
176
  error: 'Authentication required',
129
177
  error_description: 'This Hermoso MCP server needs a signed-in account. Normally your client opens a browser consent page. IF NO BROWSER OR CONSENT CARD OPENED, your client cannot complete OAuth — retrying will keep failing the same way. Read the `how_to_connect` field of THIS response and use one of those two browser-free routes instead. Tell the user which one you are taking.',
@@ -233,8 +281,40 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
233
281
  // chat said "I'll send a connect card" and none ever came. Its catalog connectors (Stripe, Notion, Vercel) work
234
282
  // because their servers challenge the first request. So that client, and any caller that asks with
235
283
  // `?auth=required`, gets the challenge instead of the anonymous preview; everyone else keeps discovery.
236
- const signinUpfront = (req) => /^grok-connectors-manager\b/i.test(String(req.headers['user-agent'] || ''))
284
+ //
285
+ // THE SAME DEFECT ON THREE MORE HOSTS, MEASURED BY THE clientInfo EACH PROBE SENDS (2026-09-25). The anonymous
286
+ // initialize is logged by name (below), and a probe from each host's add/connect flow was captured on prod:
287
+ // • claude.ai's "Add custom connector" dialog — clientInfo "Anthropic" 1.0.0, UA python-httpx — pre-selected
288
+ // "No sign-in [Detected]" on our 200, so a user who keeps the default gets a connector whose first tool call
289
+ // fails. Challenged, the same dialog detects "Sign in now" + "Claude's published identity (CIMD)".
290
+ // • Gemini CLI's connect test — clientInfo "mcp-test-client" 0.0.1, UA node — reported "Connected" and never
291
+ // offered sign-in; its session client names itself "gemini-cli-mcp-client".
292
+ // • Windsurf — UA windsurf/* — two anonymous handshakes on 2026-09-21 and never a signed-in session.
293
+ // Matched by the name the client gives itself, never by UA alone: python-httpx and node are also most of the
294
+ // registry crawlers, which keep the anonymous preview.
295
+ const SIGNIN_UPFRONT_CLIENTS = new Set(['Anthropic', 'mcp-test-client', 'gemini-cli-mcp-client']);
296
+ const signinUpfront = (req) => /^(grok-connectors-manager|windsurf)\b/i.test(String(req.headers['user-agent'] || ''))
297
+ || SIGNIN_UPFRONT_CLIENTS.has(clientInfoOf(req.body))
237
298
  || String(req.query?.auth || '').toLowerCase() === 'required';
299
+ // ── THE DEFAULT IS INVERTED: A TOKENLESS HANDSHAKE IS CHALLENGED UNLESS IT IS A KNOWN LIVENESS/DIRECTORY BOT (2026-09-25) ──
300
+ // Matching the connect probes host by host (Grok, then claude.ai's "Anthropic", Gemini's "mcp-test-client") was
301
+ // whack-a-mole, and Mistral's proved it the same afternoon: its setup dialog sends clientInfo "mcp" 0.1.0 with UA
302
+ // MistralAI-MCPClient/1.0, our 200 made it select "No Authentication", and it then DISABLED its OAuth option. A
303
+ // connector-setup flow reads a 200 to a tokenless initialize as "this server needs no sign-in", which for us is
304
+ // false, and the MCP authorization spec's answer to an unauthenticated request is the 401 + WWW-Authenticate that
305
+ // starts sign-in. So that is the default now. The anonymous preview is kept ONLY for the bots that only look:
306
+ // uptime/liveness monitors and directory/registry crawlers, recognised by the self-describing user-agent every one
307
+ // of them sends (a +https:// contact URL, or bot/crawler/probe/monitor/registry/... in the name; read off 7 days
308
+ // of 200s on /mcp, 2026-09-25). A plain library UA (node, undici, python-httpx, Go-http-client) is NOT a bot by
309
+ // itself: Gemini CLI is "node" and claude.ai's probe is python-httpx. `?auth=none` asks for the preview explicitly.
310
+ // Directories that list tools with a sign-in (Smithery, Glama's inspector, OpenAI's and Anthropic's reviews) use
311
+ // OAuth already. SIGNIN_UPFRONT_CLIENTS still wins over a bot-looking UA.
312
+ const ANON_PREVIEW_UA_RE = /\+https?:\/\/|\b(bot|crawler|spider|probe|scanner|health-?check|uptime|monitor|liveness|registry|collector|audit|checkup|validator|indexer|tripwire|research|catalog-health|signals)\b|mcpbeat|sentineloracle|mcp-watch|mcpwatch|verifymcp|mcp\.market|proofbench|factanker/i;
313
+ const anonPreviewAllowed = (req) => {
314
+ if (String(req.query?.auth || '').toLowerCase() === 'none') return true;
315
+ if (signinUpfront(req)) return false;
316
+ return ANON_PREVIEW_UA_RE.test(String(req.headers['user-agent'] || ''));
317
+ };
238
318
  const isAllPreauth = (body) => {
239
319
  const arr = Array.isArray(body) ? body : [body];
240
320
  const methods = arr.map((m) => m && m.method).filter((v) => typeof v === 'string');
@@ -267,7 +347,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
267
347
  const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
268
348
 
269
349
  async function serveAnonDiscovery(req, res, scope) {
270
- const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
350
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
271
351
  // `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
272
352
  // as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
273
353
  // only the authenticated path would leave both tools listed in the submission.
@@ -300,7 +380,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
300
380
 
301
381
  app.all(MCP_PATH, async (req, res) => {
302
382
  const auth = req.headers.authorization || '';
303
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
383
+ // The auth-scheme name is case-insensitive (RFC 9110 §11.1, RFC 6750 §2.1): `bearer <key>` is the same
384
+ // credential, and reading only `Bearer ` made a client that lower-cases it look tokenless, challenged for ever.
385
+ const token = (/^Bearer[ \t]+(\S+)[ \t]*$/i.exec(auth) || [])[1] || '';
304
386
  // A HANDSHAKE IS NOT USE. `verifyBearer` stamps the key's last_used_at, and the admin dashboard's "last
305
387
  // active" takes the max of that, the billed ledger and the user's last_seen — so an agent that merely holds a
306
388
  // connection open (initialize, tools/list, ping, a notification) kept reporting the account as ACTIVE while
@@ -310,9 +392,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
310
392
  // unchanged in both branches: this decides bookkeeping, never access.
311
393
  const didWork = methodsOf(req.body).includes('tools/call');
312
394
  const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => null) : null;
395
+ if (!user && token) connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'bearer token rejected', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
313
396
  if (!user) {
314
397
  // No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
315
- if (req.method === 'POST' && isAllPreauth(req.body) && !signinUpfront(req)) {
398
+ if (req.method === 'POST' && isAllPreauth(req.body) && anonPreviewAllowed(req)) {
316
399
  const scope = scopeFor(req, res);
317
400
  if (scope === false) return; // unknown group — already answered 400
318
401
  return serveAnonDiscovery(req, res, scope).catch(() => { try { challenge(res); } catch {} });
@@ -377,8 +460,8 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
377
460
  // OPEN with the full roster, so the whole change would be silently inert. Never throws; see
378
461
  // connectedProviders() ([[failed-read-is-not-empty]]).
379
462
  const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
380
- const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
381
- 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) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
463
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
464
+ 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
382
465
  const transport = new StreamableHTTPServerTransport({
383
466
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
384
467
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
@@ -400,7 +483,15 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
400
483
  // published app cannot be observed from here). It costs one frame after the handshake, it is only sent to
401
484
  // widget hosts, and if the host does honour it the stale-snapshot problem heals itself. The real belt is
402
485
  // LEGACY_TOOL_NAMES in tools.mjs, which keeps every name a snapshot could hold answering.
403
- if (isWidgetHost(entry.client, req)) { const t = setTimeout(() => { try { server.sendToolListChanged(); } catch {} }, 2500); if (t && typeof t.unref === 'function') t.unref(); }
486
+ //
487
+ // ── MEASURED NOT GUILTY OF "No app tools available yet" (2026-09-25) ─────────────────────────────────────────
488
+ // A fresh ChatGPT developer-mode app shows "No app tools available yet" right after Allow. This nudge was the
489
+ // first suspect (it lands on the notification stream ChatGPT opens at connect), so it was switched off and the
490
+ // connect repeated: same panel. What IS true: our tools/list answered 200 with the whole roster inside the
491
+ // connect every time, and a plain page reload — with ZERO further requests from ChatGPT to us — lists every
492
+ // tool. The panel is rendered before ChatGPT's own background sync finishes and never re-reads it. So the
493
+ // nudge is back on, as it had been since 2026-09-14; MCP_LIST_CHANGED_NUDGE=0 turns it off.
494
+ if (process.env.MCP_LIST_CHANGED_NUDGE !== '0' && isWidgetHost(entry.client, req)) { const t = setTimeout(() => { try { server.sendToolListChanged(); } catch {} }, 2500); if (t && typeof t.unref === 'function') t.unref(); }
404
495
  // If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
405
496
  // objects are otherwise reachable only from this request's still-open response — close them explicitly
406
497
  // rather than leaving a session pinned by a dead socket.
@@ -414,7 +505,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
414
505
  // Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
415
506
  // the SDK answered — a refused tools/call is still a call the roster earned.
416
507
  for (const m of methodsOf(req.body)) {
417
- if (m === 'tools/list') entry.listed = true;
508
+ if (m === 'tools/list') { if (!entry.listed) connectEvent(req, { step: 'tools-list', ok: true, tokenHash: createHash('sha256').update(token).digest('hex'), client: entry.client || '' }); entry.listed = true; }
418
509
  else if (m === 'tools/call') {
419
510
  entry.calls = (entry.calls || 0) + 1;
420
511
  // THE FIRST CALL IS THE MILESTONE, NOT THE LISTING (2026-09-07). The connect_mcp reward was granted on "an API
package/mcp/tools.mjs CHANGED
@@ -122,7 +122,7 @@ const timelineReviewText = (rv) => rv ? `\nREVIEW (${rv.verdict || 'unread'}${rv
122
122
  const seamsText = (d) => {
123
123
  const rows = Array.isArray(d?.seams) ? d.seams.filter((x) => x && x.before) : [], b = d?.budget, n = (x) => `${x >= 0 ? '+' : ''}${x}`;
124
124
  const dl = (x) => x ? `exposure ${n(x.exposurePct)}%, black ${n(x.black)}, WB u${n(x.wbU)} v${n(x.wbV)}, grain ${n(x.grain)}, sharpness x${x.sharpness}` : 'unread';
125
- return `${rows.length ? `\nSEAMS MATCHED: ${rows.map((x) => `seam ${x.seam} (${x.at}s) before ${dl(x.before)} -> after ${dl(x.after)}; ${x.applied}`).join(' | ')}` : ''}${b ? `\nBUDGET: ${b.total}s total - ${b.intro}s intro = ${b.survivingWindow.seconds}s of ${b.footage}${b.dropped?.length ? `; not shown: ${b.dropped.map((x) => `${x.from}-${x.to}s (${x.why})`).join(', ')}${b.fixes ? `. To keep it: ${b.fixes.join(' / ')}` : ''}` : ''}` : ''}`;
125
+ return `${rows.length ? `\nSEAMS MATCHED: ${rows.map((x) => `seam ${x.seam} (${x.at}s) before ${dl(x.before)} -> after ${dl(x.after)}; ${x.applied}${x.reframe ? `; ${x.reframe}` : ''}`).join(' | ')}` : ''}${b ? `\nBUDGET: ${b.total}s total - ${b.intro}s intro = ${b.survivingWindow.seconds}s of ${b.footage}${b.dropped?.length ? `; not shown: ${b.dropped.map((x) => `${x.from}-${x.to}s (${x.why})`).join(', ')}${b.fixes ? `. To keep it: ${b.fixes.join(' / ')}` : ''}` : ''}` : ''}`;
126
126
  };
127
127
  const okVideo = async (text, r) => {
128
128
  if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; const t = text + geoLine(r) + qaLine(r); return { content: [{ type: 'text', text: p ? t + '\n(first frame attached — open the URL for the full video)' : t }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
@@ -299,7 +299,8 @@ export const MCP_INSTRUCTIONS = [
299
299
  // disagree are worse than either one. What the user-facing line has to carry is only that omitting `model` works.
300
300
  // So the honest shape is a TRIGGER, not a gate: call it when you have a question it answers. Keeping the tool
301
301
  // discoverable is the other half of the fix, so the reasons to call it are spelled out rather than merely permitted.
302
- 'ACT ON THE REQUEST, DO NOT SURVEY IT: when the user asks for something to be made, make it. generate_image, generate_video and render_ad all run with `model` omitted, and an unnamed render goes to the server’s own default model, which is a sound general-purpose pick, so there is nothing you have to look up before rendering. Call hermoso_capabilities (free) when you actually need what it holds: a specific model id, an exact credit cost, a model’s live durations / aspect ratios / resolutions, or whether a capability is enabled on this account. Reporting the model catalog back is never the answer to a request to create something.',
302
+ // REMOVED 2026-09-25: the long restatement of this rule that sat here repeated the head's own 'ACT ON THE REQUEST' line
303
+ // word for word in substance; every host already reads the head copy, and the non-truncating ones paid for it twice.
303
304
  'Capability map:',
304
305
  '• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
305
306
  '• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
@@ -645,9 +646,9 @@ const HOOK_ATTR = {
645
646
  // is silently attributed to it. Naming the brand per call is the fix, and it is the most specific statement of
646
647
  // intent there is, so the server lets it beat the key pin, the workspace header and the active-brand default — for
647
648
  // that one request only, which is what makes a one-off post to a second client safe.
648
- brand: z.string().optional().describe('WHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.'),
649
- hook: z.string().optional().describe('WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.'),
650
- subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.'),
649
+ brand: z.string().optional().describe('WHICH BRAND this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection\'s pin for THIS CALL ONLY; a name that matches no brand, or two, is REFUSED and nothing is posted.'),
650
+ hook: z.string().optional().describe('WHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.'),
651
+ subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance\'s second grouping axis: reuse the exact wording, as with hook.'),
651
652
  // THE FORMAT AND THE IDEA (2026-09-23). The server's publish seam has recorded `recipe` since 2026-09-11, and no
652
653
  // agent surface could send it, so every post published over MCP or the CLI said nothing about its format and
653
654
  // post_performance's recipe axis returned no groups at all. Same spread, so every publish and schedule tool gains both.
@@ -674,7 +675,7 @@ const PUBLISH_SAFETY = {
674
675
  // `?brandId=` on a GET/DELETE (the server belt `brandRefOf` consumes both; `?brand=` is /api/product/find's).
675
676
  // tools/brand-per-call-roster-check.mjs derives the tool set by running registerTools and fails on a new one without it.
676
677
  const MANAGE_BRAND = {
677
- brand: z.string().optional().describe('WHICH BRAND the post lives in — the id or exact name from list_brands (a workspace shared with you: its profile id). Needed when it was published in a brand this connection is not pinned to: a post you can CREATE in a brand is manageable there too, for THIS CALL ONLY, without switching the connection. A name that matches no brand, or two, is REFUSED and nothing is done.'),
678
+ brand: z.string().optional().describe('WHICH BRAND the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a brand this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no brand, or two, is REFUSED and nothing is done.'),
678
679
  };
679
680
  const namedBrand = (a) => (a && typeof a.brand === 'string' && a.brand.trim() ? a.brand.trim() : '');
680
681
  // POST body: the belt reads a string `brand` and deletes it before the route sees the body.
@@ -1623,8 +1624,16 @@ function registerAppResources(server) {
1623
1624
  // /api/workspace, where resolveWs re-authorizes the pin per request, so hosted and stdio now resolve identically.
1624
1625
  const pk = async (base) => { const s = await storeSuffix(); return s ? `${base}.${s}` : base; };
1625
1626
  async function readStore(base) {
1627
+ // A SIGNED-OUT READ IS NOT AN EMPTY STORE (2026-09-25). /api/store/bootstrap answers an anonymous caller 200 with
1628
+ // nothing in it, so with no key list_library said "The Library is empty for this workspace" — measured over stdio
1629
+ // on prod — and every other store-backed tool (memory, skills, swipefile, playbooks) would answer the same kind of
1630
+ // lie. A process with no credential that reads back NOTHING has no workspace, so it says how to sign in; a 401 on
1631
+ // the read means the same. Decided on what came back, not on the missing token alone, so a stubbed or local store
1632
+ // that does answer is still read.
1626
1633
  const key = await pk(base); // deliberately OUTSIDE the try: an unresolvable workspace must fail loudly, not read the wrong one
1627
- let dump; try { dump = await apiGet('/api/store/bootstrap'); } catch { return null; }
1634
+ const signIn = () => Object.assign(new Error(SIGN_IN_HINT), { status: 401, _signedOut: true });
1635
+ let dump; try { dump = await apiGet('/api/store/bootstrap'); } catch (e) { if (signedOut()) throw signIn(); if (e?.status === 401) throw e; return null; }
1636
+ if (signedOut() && (!dump || typeof dump !== 'object' || !Object.keys(dump).length)) throw signIn();
1628
1637
  const raw = dump && dump[key] && dump[key].value;
1629
1638
  if (typeof raw !== 'string') return null;
1630
1639
  try { return JSON.parse(raw); } catch { return null; }
@@ -2050,8 +2059,24 @@ export const CORE_FIRST_HEADLINE = Object.freeze([
2050
2059
  ]);
2051
2060
  export const CORE_FIRST_EXTRA = Object.freeze(['get_brand', 'get_job', 'list_jobs', 'list_connectors', 'list_scheduled', ...CORE_FIRST_HEADLINE]);
2052
2061
  // Hosts that have been SEEN to follow find_tools → call_tool. A name that does not match keeps the full roster.
2053
- export const CORE_FIRST_VERIFIED_HOST_RE = /claude|chatgpt|openai-mcp/i;
2054
- export const hostTakesCoreFirst = (client) => CORE_FIRST_VERIFIED_HOST_RE.test(String(client || ''));
2062
+ // WIDENED 2026-09-25 ON EVIDENCE: Gemini CLI (0.60, clientInfo "gemini-cli-mcp-client"), Grok (clientInfo "grok", UA
2063
+ // grok-connectors-manager) and Mistral Le Chat / Vibe (clientInfo "mcp", so it is recognised by its UA MistralAI-MCPClient)
2064
+ // were each asked for something no short list carries ("show my Google Ads campaigns") and each ran find_tools ->
2065
+ // call_tool(list_google_ads_campaigns) to the real campaigns — Gemini CLI on `?tools=core`, stricter than the short list.
2066
+ // Measured on Gemini CLI, the same one-question task sent 473K input tokens on the full roster and 127K on the core list:
2067
+ // these hosts send every listed schema on every model call. Mistral is matched by UA only: "mcp" names nothing.
2068
+ export const CORE_FIRST_VERIFIED_HOST_RE = /claude|chatgpt|openai-mcp|^grok\b|gemini-cli/i;
2069
+ export const CORE_FIRST_VERIFIED_UA_RE = /^(grok-connectors-manager|MistralAI-MCPClient|gemini-cli)/i;
2070
+ export const hostTakesCoreFirst = (client, ua) => CORE_FIRST_VERIFIED_HOST_RE.test(String(client || '')) || CORE_FIRST_VERIFIED_UA_RE.test(String(ua || ''));
2071
+ // HOSTS WITH A HARD TOOL CAP GET THE SHORT LIST TOO (2026-09-25, measured on prod). VS Code sends at most 128 tools per
2072
+ // request (code.visualstudio.com/docs/agents/run/tools) and Windsurf holds 100 across every server (docs.devin.ai
2073
+ // cascade/mcp). The full default roster is ~180, so on both the list was over the cap before the user's other servers
2074
+ // were counted: VS Code blocks the request or folds the overflow behind its own activate_* stubs, and Windsurf makes the
2075
+ // user untick tools by hand. Neither shows the product as it is, so the core-first list (under 50, the headline verb of
2076
+ // every area, find_tools + call_tool for the rest) is the one roster that fits. Matched on the clientInfo name VS Code
2077
+ // sends ("Visual Studio Code", seen on prod) or a Windsurf name/UA. NOT /vscode/: Cursor has called itself "cursor-vscode".
2078
+ export const TOOL_CAPPED_HOST_RE = /^visual studio code\b|windsurf/i;
2079
+ export const hostHasToolCap = (client, ua) => TOOL_CAPPED_HOST_RE.test(String(client || '')) || /windsurf/i.test(String(ua || ''));
2055
2080
  export function defaultToolGroups(env = process.env) { return coreFirstRoster(env) ? ['core'] : [...DEFAULT_TOOL_GROUPS]; }
2056
2081
 
2057
2082
  // Parse a `tools=` scope. Returns {groups} or {error} — an unknown name is REFUSED BY NAME rather than dropped,
@@ -2609,7 +2634,7 @@ function newToolScope(opts) {
2609
2634
  // whose connector was tested on exactly that route. Cursor, Codex and anything unnamed are unverified, so on the
2610
2635
  // HOSTED transport they keep the full default roster and nothing can look smaller than it is. The stdio CLI has no
2611
2636
  // host to ask and stays a plain env opt-in. Widen CORE_FIRST_VERIFIED_HOST_RE on evidence, never on a guess.
2612
- const coreFirst = !opts.only && coreFirstRoster() && (!opts.hosted || hostTakesCoreFirst(opts.client));
2637
+ const coreFirst = !opts.only && coreFirstRoster() && (!opts.hosted || hostTakesCoreFirst(opts.client, opts.ua) || hostHasToolCap(opts.client, opts.ua));
2613
2638
  const asked = opts.only ? new Set(opts.only) : new Set(coreFirst ? ['core'] : [...DEFAULT_TOOL_GROUPS]);
2614
2639
  asked.add('core'); // discovery/credits/billing/jobs must exist in EVERY roster or the connection is unusable
2615
2640
  // `connectors` is `{connected:Set<provider>, readOk:boolean}` from the transport's own free read, or absent.
@@ -3064,6 +3089,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
3064
3089
  for (const m of String(h.description || '').matchAll(/\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+\b|\b[a-z]+(?:[A-Z][a-z0-9]+)+\b/g)) if (m[0].length >= 6) set.add(sq(m[0]));
3065
3090
  _squashIdx.set(h, set); return set;
3066
3091
  };
3092
+ // THE MATCH COUNT find_tools REPORTS (2026-09-25). A row is a STRONG match when a query word ITSELF landed in its NAME
3093
+ // (or it is the exact tool name asked for) and it answers as many of the query words as the best-covering such row.
3094
+ // Tiered so a search always counts something: a synonym landing in the name ("tweet" → post_to_x) is the next tier,
3095
+ // and a description-only search is the last. An empty query (browsing a group) counts every row. Rows carry `_cov`
3096
+ // (words answered), `_nd` (direct name hits), `_nh` (name hits incl. synonyms) and `_exact`.
3097
+ const findToolsMatchCount = (rows, hasQuery) => {
3098
+ if (!hasQuery) return rows.length;
3099
+ const exact = rows.filter((r) => r._exact).length;
3100
+ const rest = rows.filter((r) => !r._exact);
3101
+ const direct = rest.filter((r) => r._nd > 0), named = rest.filter((r) => r._nh > 0);
3102
+ const pool = direct.length ? direct : named.length ? named : rest;
3103
+ const best = pool.reduce((m, r) => Math.max(m, r._cov || 0), 0);
3104
+ return exact + pool.filter((r) => (r._cov || 0) >= best).length;
3105
+ };
3067
3106
  const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12, onlyHealthy = false } = {}) => {
3068
3107
  const q = String(query || '').toLowerCase().trim();
3069
3108
  const g = String(group || '').toLowerCase().trim();
@@ -3081,7 +3120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3081
3120
  // returned WITH their real group so the caller learns which group to enable instead of giving up.
3082
3121
  if (g && grp !== g) { if (!_offGroup.has(name)) _offGroup.set(name, { grp, h }); continue; }
3083
3122
  const desc = String(h.description || '');
3084
- let score = 0;
3123
+ let score = 0, _cov = 0, _nh = 0, _nd = 0; // words answered, NAME hits, and name hits that are the word itself rather than a synonym (the match count below)
3085
3124
  if (q) {
3086
3125
  // A NAME-SHAPED ASK IS ALSO ITS WORDS (2026-09-12). Agents search the name they guess (list_meta_campaigns,
3087
3126
  // update_meta_ad, edit_meta): kept as one literal token it matched nothing and filed a dead end, while its parts
@@ -3092,24 +3131,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
3092
3131
  if (_fieldHits) score += 4 * _fieldHits;
3093
3132
  const descLc = desc.toLowerCase();
3094
3133
  const nameTokens = name.split('_');
3095
- let nameHits = 0, covered = 0;
3134
+ let nameHits = 0, covered = 0, directHits = 0;
3096
3135
  for (const { w, alts, literal } of words) {
3097
- if (literal) { if (name.includes(w)) { score += 5; nameHits++; covered++; } continue; } // "find_tools" typed as-is
3136
+ if (literal) { if (name.includes(w)) { score += 5; nameHits++; directHits++; covered++; } continue; } // "find_tools" typed as-is
3098
3137
  const exactTok = nameTokens.find((t) => t === w);
3099
- if (exactTok) { score += 4 * tokenWeight(exactTok); nameHits++; covered++; continue; } // the word IS a name token
3138
+ if (exactTok) { score += 4 * tokenWeight(exactTok); nameHits++; directHits++; covered++; continue; } // the word IS a name token
3100
3139
  // the best-weighted synonym/stem that is a name token — "tweet" must land on post_to_x's `x` (rare), not its `post` (everywhere)
3101
3140
  let synBest = 0;
3102
3141
  for (const t of nameTokens) for (const a of alts) if (a !== w && (t === a || (a.length >= 4 && t.startsWith(a) && t.length - a.length <= 2))) synBest = Math.max(synBest, 3 * stemAwareWeight(ctx, tokenWeight, t, a));
3103
3142
  if (synBest) { score += synBest; nameHits++; covered++; continue; }
3104
- if (w.length >= 4 && name.includes(w)) { score += 2; nameHits++; covered++; continue; } // the literal word inside a name token
3143
+ if (w.length >= 4 && name.includes(w)) { score += 2; nameHits++; directHits++; covered++; continue; } // the literal word inside a name token
3105
3144
  const typoTok = nameTokens.find((t) => withinOneEdit(w, t));
3106
- if (typoTok) { score += 2 * tokenWeight(typoTok); nameHits++; covered++; continue; } // a typo of a name token
3145
+ if (typoTok) { score += 2 * tokenWeight(typoTok); nameHits++; directHits++; covered++; continue; } // a typo of a name token
3107
3146
  if (alts.some((a) => a.length >= 3 && descLc.includes(a))) { score += 1; covered++; continue; } // any form in the description
3108
3147
  if (alts.some((a) => grp.includes(a))) { score += 1; covered++; }
3109
3148
  }
3110
3149
  if (!score) continue;
3111
3150
  if (words.length > 1) score += covered; // coverage: a tool that answers MORE of the words outranks one that answers one of them loudly
3112
3151
  if (words.length > 1 && nameHits === words.length) score += 2; // every word landed in the NAME: a phrase hit
3152
+ _cov = covered; _nh = nameHits; _nd = directHits;
3113
3153
  }
3114
3154
  const hold = toolHoldReason(name, ctx);
3115
3155
  const health = toolHealth(name);
@@ -3117,7 +3157,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3117
3157
  // endpoint in outage by default; we do not, because "Hermoso has no such tool" is the most expensive wrong
3118
3158
  // answer this product can give, and a hidden row is indistinguishable from an absent capability.
3119
3159
  if (onlyHealthy && (hold || health.state === 'failing')) continue;
3120
- rows.push({ name, group: grp, score, inRoster: !!h.enabled, callable: !hold, hold, cost: costOf(name, grp, hold), health, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
3160
+ rows.push({ name, group: grp, score, _cov, _nh, _nd, inRoster: !!h.enabled, callable: !hold, hold, cost: costOf(name, grp, hold), health, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
3121
3161
  }
3122
3162
  // AN EXACT TOOL NAME OUTRANKS THE GROUP FILTER (2026-09-12). The name-shaped split above made a scoped search for a real
3123
3163
  // tool in the wrong group find its WORDS in-group (tiktok_creator_info in channels → post_to_tiktok), so `total` was no
@@ -3127,7 +3167,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3127
3167
  for (const lit of new Set(q.split(/[\s,]+/).map((r) => r.replace(/[^a-z0-9_]/g, '')).filter((r) => r.includes('_')))) {
3128
3168
  const off = _offGroup.get(lit); if (!off) continue;
3129
3169
  const hold = toolHoldReason(lit, ctx);
3130
- rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, inRoster: !!off.h.enabled, callable: !hold, hold, cost: costOf(lit, off.grp, hold), health: toolHealth(lit), title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
3170
+ rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, _exact: true, inRoster: !!off.h.enabled, callable: !hold, hold, cost: costOf(lit, off.grp, hold), health: toolHealth(lit), title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
3131
3171
  }
3132
3172
  }
3133
3173
  // WHAT IS SHOWN IS DECIDED BY RELEVANCE; THE ORDER WITHIN IT IS DECIDED BY HEALTH (2026-09-17). (A plain comment,
@@ -3141,13 +3181,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
3141
3181
  // what is shown. An exact name hit (score MAX_SAFE_INTEGER) carries no penalty at all — an agent that named a
3142
3182
  // tool outright gets it first, with its hold and its health printed beside it.
3143
3183
  rows.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name)); // ties: the shorter, more specific name first
3144
- const total = rows.length, top = rows.slice(0, cap);
3184
+ // HOW MANY "MATCH" IS A RELEVANCE COUNT, NEVER THE ROSTER (2026-09-25). Every tool whose description merely
3185
+ // contains one of the words scores, so "google ads report" reported 740 matches, which is the whole catalog and
3186
+ // tells an agent nothing. `total` now counts the STRONG matches (findToolsMatchCount): tools with a query word in
3187
+ // their NAME that answer as many of the words as the best such tool does. The ranking and the rows shown are
3188
+ // unchanged; the looser description-only hits are still ranked below and counted separately as `related`.
3189
+ const total = findToolsMatchCount(rows, !!q), related = rows.length, top = rows.slice(0, cap);
3145
3190
  for (const r of top) r._penalty = r.score === Number.MAX_SAFE_INTEGER ? 0 : healthPenalty(r.health, r.hold);
3146
3191
  top.sort((a, b) => a._penalty - b._penalty || b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name));
3147
3192
  // THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
3148
3193
  // not name. Unquoted and lowercased on purpose — the ledger collapses quoted strings to <q>, and one group per
3149
3194
  // distinct ask is exactly what we want to read.
3150
- if (!total && g && _offGroup.size) {
3195
+ if (!related && g && _offGroup.size) {
3151
3196
  // Re-score the excluded tools by NAME only (the cheap, unambiguous half): an exact or token hit outside the
3152
3197
  // asked-for group is an answer, not a dead end — "it exists, in channel_admin; enable that group".
3153
3198
  const qw = String(q || '').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
@@ -3155,11 +3200,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
3155
3200
  .map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
3156
3201
  if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable that group with enable_tools, then call the tool by name):\n${off.join('\n')}`, { query: q, group: g, offGroup: off.length });
3157
3202
  }
3158
- if (!total) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
3203
+ if (!related) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
3159
3204
  for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
3160
3205
  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)'}`);
3161
- const text = total
3162
- ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing ${cap} — narrow the query)` : ''}. 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')}`
3206
+ const looser = top.length > total ? top.length - total : 0;
3207
+ const text = related
3208
+ ? `${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')}`
3163
3209
  : `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
3164
3210
  // THE NEXT STEP, NAMED. The text already ends "Run any of them with call_tool({name, args})"; this is the same
3165
3211
  // instruction with the actual name in it, plus the connect step when the best match is the one that is held.
@@ -3171,7 +3217,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3171
3217
  : { 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' : ''}` });
3172
3218
  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` });
3173
3219
  }
3174
- return withHints({ content: [{ type: 'text', text }], structuredContent: { total, tools: top.map(({ score, _penalty, ...r }) => r) } }, hints);
3220
+ return withHints({ content: [{ type: 'text', text }], structuredContent: { total, related, tools: top.map(({ score, _penalty, _cov, _nh, _nd, _exact, ...r }) => r) } }, hints);
3175
3221
  };
3176
3222
  // "DID YOU MEAN" HAS TO DISCRIMINATE, AND THE OLD ONE DID NOT (2026-09-17, off the defect board). Not a `// ──`
3177
3223
  // banner on purpose: tools/docs-data.mjs turns every banner into a public docs section, and this note sits
@@ -4791,7 +4837,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4791
4837
  videoUrl: z.string().optional().describe('public https URL, data: URI, or /generated path — FB video post / IG Reel'),
4792
4838
  productTags: z.array(z.any()).optional().describe('INSTAGRAM SHOPPING — make the post SHOPPABLE by tagging products from the brand’s own catalog; tapping a tag opens the product’s price sheet inside Instagram. Instagram only. ON A PHOTO each tag is {product_id, x, y}, where x and y are FRACTIONS of the image from 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle — and BOTH are required, max 20. ON A REEL it is {product_id} ALONE with no coordinates, max 30. ON A CAROUSEL it is an array PER SLIDE ([[{…}], [], [{…}]]) because Instagram tags each slide’s own container, max 5 per slide and 20 across the post. Ids come from search_instagram_shopping_products; call list_instagram_shopping_catalogs FIRST, because tagging needs an APPROVED Instagram Shop and without one this fails after the media is already uploaded. A tag whose product is not “approved” is stored and shown to nobody.'),
4793
4839
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
4794
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
4840
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
4795
4841
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
4796
4842
  async: z.boolean().optional().describe('publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.'),
4797
4843
  link: z.string().optional().describe('a URL to attach (FB text post only)'),
@@ -4860,7 +4906,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4860
4906
  // no need for one edge case just for Facebook."
4861
4907
  server.registerTool('schedule_post', {
4862
4908
  title: 'Schedule a post for later',
4863
- description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word. YOUTUBE: `description` (≤5000 chars) is the box under the video for the links and CTA, and the caption stands in when it is omitted; `tags` up to 30; `thumbnailUrl` sets the custom thumbnail. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
4909
+ description: 'Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, every one live. google_business is in the schema but HELD BACK (Google’s API allowlist) and is refused at enqueue. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
4864
4910
  inputSchema: {
4865
4911
  ...HOOK_ATTR,
4866
4912
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
@@ -5057,7 +5103,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5057
5103
  title: 'Change a scheduled post',
5058
5104
  description: 'Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:"" moves a company-Page post back to the person\u2019s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.',
5059
5105
  inputSchema: {
5060
- brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5106
+ brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
5061
5107
  ...POST_INTENT,
5062
5108
  id: z.string().describe('the scheduled post id from list_scheduled'),
5063
5109
  at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
@@ -5150,7 +5196,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5150
5196
  // schedule_post put a post in another brand, and then cancelling it needed use_brand — i.e. changing the whole
5151
5197
  // connection to undo one call. The wire spelling is `brandId` because these are GET/DELETE calls and `?brand=`
5152
5198
  // already means a brand NAME on /api/product/find.
5153
- inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled'), brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.') },
5199
+ inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled'), brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.') },
5154
5200
  outputSchema: { cancelled: z.string().optional() },
5155
5201
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5156
5202
  }, wrap(async (a) => {
@@ -5166,7 +5212,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5166
5212
  title: 'Retry a failed scheduled post',
5167
5213
  description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
5168
5214
  inputSchema: {
5169
- brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5215
+ brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
5170
5216
  id: z.string().describe('the scheduled post id from list_scheduled'),
5171
5217
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
5172
5218
  at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
@@ -5190,7 +5236,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5190
5236
  title: 'Duplicate a scheduled post',
5191
5237
  description: 'Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.',
5192
5238
  inputSchema: {
5193
- brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5239
+ brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
5194
5240
  id: z.string().describe('the post to copy, from list_scheduled'),
5195
5241
  ...POST_INTENT, // the copy inherits the original's format and idea (and hook and subject); pass either to change it
5196
5242
  at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
@@ -5295,7 +5341,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5295
5341
  text: z.string().describe('the post text'),
5296
5342
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves).'),
5297
5343
  imageUrls: z.array(z.string()).optional().describe('A CAROUSEL IS NOT AVAILABLE ON A PERSONAL PROFILE — LinkedIn\'s organic multi-image post publishes from a COMPANY PAGE. Passing several here is refused by name rather than posting slide 1; use post_to_linkedin_page instead.'),
5298
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
5344
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
5299
5345
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
5300
5346
  visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
5301
5347
  },
@@ -5616,7 +5662,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5616
5662
  imageUrl: z.string().optional().describe('a Hermoso render image URL (or an upload_file url)'),
5617
5663
  videoUrl: z.string().optional().describe('a Hermoso render video URL — takes 1–2 minutes to ingest'),
5618
5664
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
5619
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
5665
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
5620
5666
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
5621
5667
  title: z.string().optional().describe('Pin title, max 100 characters'),
5622
5668
  description: z.string().optional().describe('Pin description, max 800 characters — this is what Pinterest search reads'),
@@ -7035,7 +7081,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7035
7081
  };
7036
7082
  server.registerTool('create_meta_ad', {
7037
7083
  title: 'Build a full Meta ad (campaign -> ad set -> ad, paused)',
7038
- description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it now carries Meta-rendered PREVIEW LINKS for the first ad, valid 24 hours — hand them to the user so they can see the ad; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — an IG media id and a Facebook post id are both bare digits, so Hermoso will NOT guess which one you meant, and a Facebook boost given an IG media id is refused rather than built against a fabricated id. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
7084
+ description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it carries 24-hour Meta PREVIEW LINKS for the first ad — hand them to the user; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — IG media ids and Facebook post ids are both bare digits, so Hermoso never guesses, and a Facebook boost given an IG media id is refused. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
7039
7085
  inputSchema: {
7040
7086
  boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs, and creative fields do not apply — a boost shows the post as published.'), boostTarget: z.enum(['facebook','instagram']).optional().describe("Which surface the boosted post lives on. Default facebook. REQUIRED for an Instagram post: an IG media id and a Facebook post id are both bare digits, so this is never inferred — Meta takes a different creative for each (object_story_id for a Page post; object_id + instagram_user_id + source_instagram_media_id for an IG post). list_meta_posts(target:'instagram') returns the ids."),
7041
7087
  adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
@@ -8711,7 +8757,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8711
8757
  }));
8712
8758
  server.registerTool('create_google_ads_performance_max_campaign', {
8713
8759
  title: 'Create a Google Ads Performance Max campaign',
8714
- description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps) and the one Google pushes hardest at small advertisers. ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
8760
+ description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps). ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
8715
8761
  inputSchema: {
8716
8762
  customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
8717
8763
  name: z.string().describe('campaign name'),
@@ -12717,7 +12763,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12717
12763
  inputSchema: {
12718
12764
  adAccountId: z.string().optional(),
12719
12765
  budget: z.number().describe('budget in the ad account’s currency (not micro-currency — the conversion is handled)'),
12720
- objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12766
+ objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12721
12767
  goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional(),
12722
12768
  bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional(),
12723
12769
  bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional(),
@@ -12738,7 +12784,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12738
12784
  inputSchema: {
12739
12785
  adAccountId: z.string().optional(),
12740
12786
  budget: z.number().describe('budget in the ad account’s currency'),
12741
- objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12787
+ objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12742
12788
  bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional().describe('default CPC — must fit the campaign objective'),
12743
12789
  bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional(),
12744
12790
  goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional(),
@@ -12810,14 +12856,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
12810
12856
  }));
12811
12857
  server.registerTool('create_reddit_ads_campaign', {
12812
12858
  title: 'Create a Reddit campaign',
12813
- description: 'Create the top tier of a Reddit ad — the campaign, which sets the OBJECTIVE everything under it optimises toward and (optionally) a lifetime spend cap. ALWAYS created PAUSED, with no override; it spends nothing until set_reddit_ads_status(confirm:true). Pick the objective deliberately, because the ad group\u2019s bid type has to match it and it cannot be changed afterwards: CLICKS is Reddit\u2019s name for traffic to a website (there is no TRAFFIC), CONVERSIONS optimises toward pixel events and needs a working pixel, LEAD_GENERATION drives in-feed lead forms, IMPRESSIONS and VIDEO_VIEWABLE_IMPRESSIONS buy reach, APP_INSTALLS and CATALOG_SALES are for apps and product feeds. A campaign on its own can never serve: create an ad group under it, then an ad pointing at a post. The result is read back from Reddit.',
12859
+ description: 'Create the top tier of a Reddit ad — the campaign, which sets the OBJECTIVE everything under it optimises toward and (optionally) a lifetime spend cap. ALWAYS created PAUSED, with no override; it spends nothing until set_reddit_ads_status(confirm:true). Pick the objective deliberately, because the ad group\u2019s bid type has to match it and it cannot be changed afterwards: CLICKS is Reddit\u2019s name for traffic to a website (there is no TRAFFIC), CONVERSIONS optimises toward pixel events and needs a working pixel, LEAD_GENERATION optimises toward LEAD / SIGN_UP pixel events (Reddit retired onsite lead forms), IMPRESSIONS and VIDEO_VIEWABLE_IMPRESSIONS buy reach, APP_INSTALLS and CATALOG_SALES are for apps and product feeds. Reddit\u2019s NEW names (since 2026-09-21) are accepted too: BRAND_AWARENESS (= IMPRESSIONS / VIDEO_VIEWABLE_IMPRESSIONS) and SALES (= CONVERSIONS, or CATALOG_SALES when useCatalog is true); Reddit rolls them out per account, so an account without them yet answers with Reddit\u2019s own refusal \u2014 use the legacy name then. A campaign on its own can never serve: create an ad group under it, then an ad pointing at a post. The result is read back from Reddit.',
12814
12860
  inputSchema: {
12815
12861
  adAccountId: z.string().optional().describe('Reddit ad account id (a2_\u2026) \u2014 omit when only one is shared'),
12816
12862
  name: z.string(),
12817
- objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
12863
+ objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
12818
12864
  spendCapCents: z.number().optional().describe('lifetime spend ceiling for the whole campaign, in minor units of the ad account\u2019s currency'),
12865
+ useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog'),
12819
12866
  },
12820
- outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), adAccountId: z.string().optional() },
12867
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), useCatalog: z.boolean().optional(), adAccountId: z.string().optional() },
12821
12868
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
12822
12869
  }, wrap(async (a) => {
12823
12870
  const d = await apiPost('/api/reddit-ads/campaigns', a);
@@ -14812,7 +14859,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14812
14859
  }));
14813
14860
  server.registerTool('create_tiktok_ads_ad', {
14814
14861
  title: 'Create a TikTok ad',
14815
- description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with "Unsupported image size", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a Warning: warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
14862
+ description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here, for free. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a Warning: warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
14816
14863
  inputSchema: {
14817
14864
  advertiserId: z.string().optional(),
14818
14865
  adgroupId: z.string().describe('the ad group whose targeting, budget and schedule this ad runs under'),
@@ -15846,7 +15893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15846
15893
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused.'),
15847
15894
  videoUrl: z.string().optional().describe('a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute.'),
15848
15895
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
15849
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
15896
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
15850
15897
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
15851
15898
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Not available on a PERSONAL-profile post: LinkedIn\u2019s member posting API has no alt-text field at all.'),
15852
15899
  title: z.string().optional().describe('video title'),
@@ -17085,9 +17132,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
17085
17132
  server.group('create');
17086
17133
  server.registerTool('make_thumbnail', {
17087
17134
  title: 'Make video thumbnail',
17088
- description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover — the full production pipeline (concept framework -> casting -> scene -> render -> surgical tweaks -> text), not a bare image prompt. Use this for any \"thumbnail\", \"video cover\", \"video preview\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — every thumbnail must open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks before you pick, and feel free to combine two. Frameworks (pass as `framework`): before_after · social_ui · three_step · screenshot · posed_portrait (the default) · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality. Call hermoso_capabilities for each one's full 'realize it with' note plus the emotion, overlay-style, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume and never silently substitute a stranger. If the framework puts a person in frame and no face photo is attached, the tool refuses (nothing rendered, nothing charged) and tells you to ask the user once: themselves (send a face photo -> the identity gets locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — the default is a CLEAN render with the headline TYPESET OVER THE TOP afterwards (free, always legible, correctly spelled). Just pass `headline`. Only set `bakeText:true` if the user explicitly asks for the words painted INTO the image — verified live, that renders the asked-for words correctly but leaks garbled invented text across the rest of the frame. Never infer text intent from the topic or the framework.\n3. HOW MANY — ask once whether they want one thumbnail or a SET (offer 4: the same concept at different emotions and/or camera takes). Default is 1; `variants` caps at 16.\n\nIDENTITY LOCK is automatic for every attached face photo. `emotion` is the single biggest CTR lever on a face: shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh (or your own phrase). Finished thumbnail needs a fix? Re-call with `tweak` + `sourceImage` for a surgical, pixel-faithful edit (emotion / background / background_color / rim_light) instead of re-rendering — tweaks chain. ALWAYS check the returned postRenderCheck against the image before you present it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field in ENGLISH (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, and every `reference` field), translating the user's wording where needed: the image models are trained on English and a non-English scene description renders noticeably worse. Text that gets BAKED OR TYPESET stays verbatim in the user's own language — `headline`, `headlineLines` and `bakedUiText` are never translated.",
17135
+ description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any \"thumbnail\", \"video cover\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks (ids on `framework`; combining two is fine) before you pick; hermoso_capabilities has each one's 'realize it with' note and the emotion, overlay, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. Never infer text intent from the topic.\n3. HOW MANY — ask once: one, or a SET (offer 4: one concept at different emotions / camera takes). Default 1; `variants` caps at 16.\n\n`emotion` is the biggest CTR lever on a face (identity lock is automatic for every face photo). To fix a finished one, re-call with `tweak` + `sourceImage` for a surgical edit (emotion / background / background_color / rim_light) — tweaks chain. ALWAYS check the returned postRenderCheck against the image before presenting it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, every `reference`) in ENGLISH, translating the user's words: the models render English better. `headline`, `headlineLines` and `bakedUiText` stay verbatim in the user's language.",
17089
17136
  inputSchema: {
17090
- framework: z.string().optional().describe("concept framework id (default 'posed_portrait'), or your own concept in words"),
17137
+ framework: z.string().optional().describe("concept framework id (default 'posed_portrait') — before_after · social_ui · three_step · screenshot · posed_portrait · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality — or your own concept in words"),
17091
17138
  frameworkRequested: z.boolean().optional().describe('true ONLY when the USER named this framework — it is what authorizes a text-carrying framework (social_ui / news_clip / specific_day / map_aerial) to bake its short UI label'),
17092
17139
  sceneBrief: z.string().optional().describe('what the thumbnail depicts — the concept in one dense sentence, rendered exactly'),
17093
17140
  topic: z.string().optional().describe("the video's topic — used to pick the hero object when you don't name keyElements"),
@@ -17101,7 +17148,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17101
17148
  faceImages: z.array(z.string()).optional().describe('up to 3 face photos (URLs or local paths) — each becomes a locked CHARACTER identity, in order'),
17102
17149
  people: z.array(z.object({ describe: z.string() }).passthrough()).optional().describe('people described in prose instead of by photo (each still gets the chosen expression)'),
17103
17150
  castGenericPerson: z.boolean().optional().describe('pass true only after the user has explicitly chosen a generated stranger over their own face'),
17104
- emotion: z.string().optional().describe("the expression on the face (default 'shock') — a preset id or your own phrase"),
17151
+ emotion: z.string().optional().describe("the expression on the face (default 'shock') — shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh, or your own phrase"),
17105
17152
  emotions: z.array(z.string()).optional().describe('render one variant per emotion (variants = emotions × takes, max 16)'),
17106
17153
  takes: z.number().optional().describe('camera takes per emotion, 1–4: designed framing / low-angle hero / extreme close-up / wide dutch tilt'),
17107
17154
  variants: z.number().optional().describe('how many thumbnails to render (default 1, max 16). Each is its own billed render — offer a set of 4 rather than assuming'),
@@ -17269,9 +17316,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
17269
17316
 
17270
17317
  server.registerTool('make_template_ad', {
17271
17318
  title: 'Make template ad',
17272
- description: "An ad or post rendered from HTML: no AI model, ~30s, a couple of credits. Presets are SHORTCUTS; 'custom' is YOUR OWN design as config.html (+ css), so no layout, type, colour or motion is 'unsupported'. custom: { html, css?, size? ('9:16' default | '4:5' | '1:1' | '16:9' | any 'W:H' | {w,h} px), durationSeconds? (1-60 = VIDEO; CSS/SVG animation and <video> are frame-stepped, scripts stripped), slides?:[{html, css?}] (2-35 = carousel) }; {{logo}} {{brandName}} {{domain}} {{accent}} fill from the brand; images and fonts load by https URL; notes[] lists what failed to load. YOU author preset copy: short, casual, believable, finished phrases within budget. '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. config.music on a VIDEO: omit for the format's free library bed, 'off' for silence, or any words (a mood or a description) to compose a bed to them (a flat music fee, in hermoso_capabilities). Image URLs may be any public URL.",
17319
+ description: "An ad or post rendered from HTML: no AI model, ~30s, a couple of credits. Presets are SHORTCUTS; 'custom' is YOUR OWN design as config.html (+ css), so no layout, type, colour or motion is 'unsupported'. custom: { html, css?, size? ('9:16' default | '4:5' | '1:1' | '16:9' | any 'W:H' | {w,h} px), durationSeconds? (1-60 = VIDEO; CSS/SVG animation and <video> are frame-stepped, scripts stripped), slides?:[{html, css?}] (2-35 = carousel) }; {{logo}} {{brandName}} {{domain}} {{accent}} fill from the brand; images and fonts load by https URL; notes[] lists what failed to load. YOU author preset copy: short, casual, believable, finished phrases within budget. The preset ids — slideshow, imessage-chat, chatgpt-chat, apple-notes, value-prop, static-mockup, airdrop-carousel, app-ui-tour, imessage-cascade, photo-grid, vignette, kinetic-type, myth-vs-fact, carousel — and each one's fields are listed on `config`. config.music on a VIDEO: omit for the format's free library bed, 'off' for silence, or any words (a mood or a description) to compose a bed to them (a flat music fee, in hermoso_capabilities). Image URLs may be any public URL.",
17273
17320
  inputSchema: {
17274
- config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id above, plus its fields"),
17321
+ 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."),
17275
17322
  },
17276
17323
  outputSchema: { ...JOB_OUT },
17277
17324
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -17369,6 +17416,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17369
17416
  // a constant, or keyframes [{t | src, v, ease}] (shape spelled out on `segments`: one description beats thirteen copies)
17370
17417
  const KF = z.union([z.number(), z.array(z.any())]);
17371
17418
  // seam matching (lib/seam-match.mjs): 'auto' | 'off' | {grade, level, grain, blur: booleans, strength 0-1}
17419
+ const REFRAME_OPT = z.union([z.enum(['auto', 'off']), z.number()]);
17372
17420
  const SEAM_MATCH = z.union([z.enum(['auto', 'off']), z.object({ grade: z.boolean().optional(), level: z.boolean().optional(), grain: z.boolean().optional(), blur: z.boolean().optional(), strength: z.number().optional() })]);
17373
17421
  server.registerTool('edit_timeline', {
17374
17422
  title: 'Compose an edit (timeline)',
@@ -17379,7 +17427,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17379
17427
  + "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
17380
17428
  + "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
17381
17429
  + "PRO, NOT IMOVIE: ease every curve (never linear on a move); keep the picture filling the frame through a move (scale up while it moves: two frames sliding side by side with a seam is the amateur tell); hide the handoff under the fastest, blurriest frames; carry direction into the next shot; cut on motion; end every effect cleanly; 0.2-0.6 s in total; a sound whose peak lands on the handoff (sfx whoosh at handoff minus 0.45 s, or the hook's own payoff sound). Moving segments get a real shutter blur automatically (motionBlur). "
17382
- + "EVERY SEAM IS MATCHED AUTOMATICALLY, hard cuts too: each cut is measured and the incoming clip graded (exposure, white balance, black level), grained UP (never smoothed) and softened while it moves toward the outgoing one; the reply gives before/after deltas per seam. match (timeline: every cut; segment: the cut into it): 'auto' default, 'off' for a deliberate contrast, or {grade, level, grain, blur: false to skip one, strength 0-1}; a segment's own constant exposure / contrast / saturation replaces the automatic grade. Still yours: subject size and headroom (scale it, never a jump from a third of the frame to two thirds) and sound (a 0.25-0.5 s J/L-cut, never a sonic wall). A clip placed after a hook gets a BUDGET (total - intro = its surviving window, and what was dropped). The plainest thing that links wins: a straight cut on action beats a decorative effect; over 0.5 s is too long in anything under 20 s; never flash more than 3 times a second. "
17430
+ + "EVERY SEAM IS MATCHED AUTOMATICALLY, hard cuts too: each cut is measured and the incoming clip graded (exposure, white balance, black level), grained UP (never smoothed) and softened while it moves toward the outgoing one; the reply gives before/after deltas per seam. match (timeline: every cut; segment: the cut into it): 'auto' default, 'off' for a deliberate contrast, or {grade, level, grain, blur: false to skip one, strength 0-1}; a segment's own constant exposure / contrast / saturation replaces the automatic grade. A JUMP CUT (two moments of one shot at one framing) is punched in ~1.2x on the face automatically (reframe: 'off' or the step 1.1-1.5; a segment with its own scale is left alone). Still yours: subject size and headroom (scale it, never a jump from a third of the frame to two thirds) and sound (a 0.25-0.5 s J/L-cut, never a sonic wall). A clip placed after a hook gets a BUDGET (total - intro = its surviving window, and what was dropped). The plainest thing that links wins: a straight cut on action beats a decorative effect; over 0.5 s is too long in anything under 20 s; never flash more than 3 times a second. "
17383
17431
  + "RECIPES (c = the cut second, adapt freely): whip pan: A over its last 0.22 s x 0 to -0.22, scale 1 to 1.35, mblur 0 to 220, all ease in; B overlap 0.08, opacity 0 to 1 over 0.08, x 0.22 to 0, scale 1.35 to 1, mblur 220 to 0, all ease out over 0.3 s; whoosh at c-0.45. Zoom through: A over its last 0.35 s scale 1 to 3 ease in anchored on the object, blur 0 to 10; B overlap 0.12, opacity 0 to 1, scale 1.5 to 1 and blur 10 to 0 ease out over 0.4 s. Cut on action: A out ON the motion, B scale 1.08 to 1 ease out over 0.25 s, audio.lead 0.2. Speed ramp: speed [{src:t0,v:1},{src:t0+0.25,v:0.3}] then [{src:t1,v:0.3},{src:t1+0.1,v:2}] into the cut. Circle wipe: B overlap 0.5 + overlays [{mode:'mask', segment:1, start, end, html: a white div whose clip-path circle grows via @keyframes}]. Card (picture in picture: a proven ad playing in a rounded card over the host watching it, any length): the host segment full frame, then the clip ON TOP with at:0, fit:'contain' (crop to reframe it), scale ~0.6-0.7, y ~0.12, radius ~0.04-0.06; a card is not a cut, so it is never graded toward the host; duck it under the host's first line with audio.gain keys (the host's voice leads the switch) and end it on a hard cut at a sentence break. "
17384
17432
  + "SELF-CRITIQUE: the reply carries a vision REVIEW of each seam (pro / ok / amateur, linked or not, with fixes; about 2 credits, review:false skips it) and the seam frames. When it says ok or amateur, fix what it names and re-run the same sources (twice at most) before presenting; on a re-run give a generated segment {src: its URL, between: true} so it is not paid for twice. Then look at the WHOLE result once with video_frames, not only the seams: the first frame is not black or frozen, no dead air over ~0.3 s at the head, no lone black, flash or repeated frame at a cut, and nothing static for more than ~4-5 s (recut it or add a re-hook). "
17385
17433
  + "FOLLOW-UPS ('cut earlier', 'no splat', 'whip pan instead', 'use the second hook') re-run this with the SAME sources and the one change; the result echoes the resolved timeline (e.g. the found payoff cut) to edit from. Refusals are free and name the field.",
@@ -17402,6 +17450,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17402
17450
  reverse: z.boolean().optional(),
17403
17451
  between: z.boolean().optional().describe('a bridge clip between its neighbours (a re-used generated shot): trimmed and graded to them automatically'),
17404
17452
  match: SEAM_MATCH.optional().describe('the cut INTO this segment (default: the timeline match)'),
17453
+ reframe: REFRAME_OPT.optional().describe('the cut INTO this segment'),
17405
17454
  slowmo: z.enum(['blend', 'hold', 'flow']).optional().describe('how slow motion fills frames (flow = motion-interpolated)'),
17406
17455
  scale: KF.optional(), x: KF.optional().describe('canvas widths'), y: KF.optional().describe('canvas heights'), rotate: KF.optional().describe('degrees'),
17407
17456
  opacity: KF.optional(), blur: KF.optional(), mblur: KF.optional().describe('directional motion blur px'), mblurAngle: z.number().optional(),
@@ -17416,11 +17465,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
17416
17465
  motionBlur: z.boolean().optional().describe('default true: anything that moves gets a real shutter blur along its path'),
17417
17466
  review: z.boolean().optional().describe('default true: a vision read of each seam (about 2 credits) comes back with the render, with the seam frames'),
17418
17467
  match: SEAM_MATCH.optional().describe("every cut: 'auto' default"),
17468
+ reframe: REFRAME_OPT.optional().describe("every cut: 'auto' default"),
17419
17469
  },
17420
17470
  outputSchema: { ...JOB_OUT },
17421
17471
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17422
17472
  }, wrap(async (a) => {
17423
- const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}) };
17473
+ const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}), ...(a.reframe != null ? { reframe: a.reframe } : {}) };
17424
17474
  const r = await renderJob('postedit', { ...(a.videoUrl ? { videoUrl: a.videoUrl } : {}), ops: [op] }, 'MCP timeline');
17425
17475
  const tl = r?.raw?.timeline;
17426
17476
  const gen = Array.isArray(r?.raw?.generatedShots) && r.raw.generatedShots.length ? `\nGENERATED SHOT: ${r.raw.generatedShots.map((g) => `${g.model} ${g.seconds}s ${abs(g.video)}`).join('; ')} (billed as its own render)` : '';
@@ -17489,7 +17539,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17489
17539
  // leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
17490
17540
  server.registerTool('clip_video', {
17491
17541
  title: 'Clip a long video',
17492
- description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut -> Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip. It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
17542
+ description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED), a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source ~15s to ~600MB; only the first ~40 minutes is analysed (truncated:true says so). Cost: a ~7-credit hold settled to the exact transcription + encode cost, plus the clip-selection model's tokens as their own small event. RETURNS clips[] — each its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode. SUBTITLES ARE BURNED IN BY DEFAULT (slim white CAPS, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL — cues follow the transcript's per-sentence timestamps, split by character count; never promise frame-accurate sync. captionsBurned counts the clips that really carry a burned track and captionNote says why any are bare.",
17493
17543
  inputSchema: {
17494
17544
  video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
17495
17545
  count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
@@ -17608,7 +17658,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17608
17658
 
17609
17659
  server.registerTool('generate_video', {
17610
17660
  title: 'Generate video',
17611
- description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them and not by omitting the field. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: your prompt is NOT dispatched verbatim by default — a few small guards are appended (packaging/label safety when no reference image rides, a negative prompt on the models that take one, reference-binding lines when references ride) and hex colour codes are rewritten to colour names. Pass raw:true for none of that. ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
17661
+ description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
17612
17662
  inputSchema: {
17613
17663
  prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
17614
17664
  raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence. Billing, durable delivery and per-model validation are unchanged.'),
@@ -17857,7 +17907,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17857
17907
  }))).filter(Boolean);
17858
17908
  } catch {}
17859
17909
  const d = await apiGet('/api/skills').catch(() => ({ skills: [] }));
17860
- let custom = await readStore('heist.skills.v1'); if (!Array.isArray(custom)) custom = []; // the workspace's OWN skills (built-ins alone came from /api/skills)
17910
+ let custom = await readStore('heist.skills.v1').catch((e) => { if (e?._signedOut) return []; throw e; }); if (!Array.isArray(custom)) custom = []; // the workspace's OWN skills (built-ins alone came from /api/skills); signed out, the built-ins still list
17861
17911
  const inApp = (d.skills || []).map(s => `${s.id} (${s.kind || s.group})`).join(', ');
17862
17912
  const customLine = custom.map(s => `- ${s.name} (${s.id})`).join('\n');
17863
17913
  const text = `Skill bundles (call get_skill with the name):\n${bundles.map(b => `- ${b.name}: ${b.description}`).join('\n') || '(none bundled)'}\n\nIn-app strategy skills + creative recipes (pass as plan_ad's recipe / create's skill): ${inApp}\n\nYour custom skills (save_skill / delete_skill):\n${customLine || '(none yet)'}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.305",
3
+ "version": "0.1.310",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 863 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",