hermoso 0.1.269 → 0.1.272

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
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **855 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **856 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
@@ -216,7 +216,7 @@ block entirely if you signed in above; it is there for CI, where the process can
216
216
 
217
217
  Then ask your agent: *“Generate an image ad with Hermoso.”*
218
218
 
219
- ### What the 855 tools cover
219
+ ### What the 856 tools cover
220
220
 
221
221
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
222
222
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/client.mjs CHANGED
@@ -107,7 +107,7 @@ async function unwrap(res) {
107
107
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
108
108
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
109
109
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
110
- throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}) });
110
+ throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}) }); // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
111
111
  }
112
112
  return body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
113
113
  }
@@ -316,12 +316,47 @@ export async function connectedProviders() {
316
316
  }
317
317
  // Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
318
318
  // the server reads the raw body. Lets an agent post ARBITRARY user files (not just Hermoso renders).
319
+ // A FILE BIGGER THAN ONE REQUEST GOES UP IN PARTS (2026-09-22). The hosted server sits behind Google's front door,
320
+ // which refuses any request body over 32 MiB before Hermoso sees it (measured: 30MB reached the app, 40MB was a
321
+ // 413). So above UPLOAD_SINGLE_MAX the bytes go through an upload ticket in parts, each a PUT with ?offset=&total=,
322
+ // and the last part answers the same {url, kind, bytes} a single POST does. The ticket carries the credential, so
323
+ // the part PUTs need no auth header. A 409 answers with `received`, which is where the next part starts.
324
+ const UPLOAD_SINGLE_MAX = 24 * 1024 * 1024, UPLOAD_PART = 8 * 1024 * 1024;
319
325
  export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
326
+ if (buf && buf.length > UPLOAD_SINGLE_MAX && p === '/api/upload') return apiUploadInParts(buf, { contentType, fileName });
320
327
  const h = headers({ 'Content-Type': contentType });
321
328
  if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
322
329
  const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: h, body: buf });
323
330
  return unwrap(res);
324
331
  }
332
+ export async function apiUploadInParts(buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
333
+ const t = await apiPost('/api/upload/ticket', {});
334
+ if (!t || !t.uploadUrl) throw new Error('Hermoso did not hand back an upload link — try again.');
335
+ let path0; try { path0 = new URL(t.uploadUrl).pathname; } catch { path0 = String(t.uploadUrl); }
336
+ const part = Math.min(UPLOAD_PART, Number(t.partMaxBytes) || UPLOAD_PART);
337
+ const total = buf.length; let offset = 0, last = null;
338
+ while (offset < total) {
339
+ const end = Math.min(total, offset + part);
340
+ const h = { 'Content-Type': contentType }; if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
341
+ let res, body, tries = 0;
342
+ for (;;) {
343
+ // A part IS safe to resend, unlike the writes fetchWrite refuses to retry: the server acknowledges a part it
344
+ // already holds (`duplicate`) and never appends it twice, so a transport drop here is retried.
345
+ try { res = await fetchWrite(`${API_BASE}${path0}?offset=${offset}&total=${total}`, { method: 'PUT', headers: h, body: buf.subarray(offset, end) }); body = await res.json().catch(() => ({})); if (res.status < 500) break; }
346
+ catch (e) { if (!e?._transport || ++tries >= 4) throw e; await new Promise((r) => setTimeout(r, 800 * tries)); continue; }
347
+ if (++tries >= 4) break;
348
+ await new Promise((r) => setTimeout(r, 800 * tries));
349
+ }
350
+ if (res.status === 409 && Number.isFinite(body.received)) { offset = body.received; continue; }
351
+ if (!res.ok) throw Object.assign(new Error((body && body.error) || `HTTP ${res.status}`), { status: res.status, _viaApi: true });
352
+ // A server that predates parts ingests the first part AS the file and answers a url with no `received` — never
353
+ // report that truncated file as the upload.
354
+ if (!Number.isFinite(body.received)) throw new Error('This Hermoso server does not take uploads in parts yet, so a file this large cannot be sent to it — pass a public `url` instead.');
355
+ last = body; offset = body.received;
356
+ }
357
+ if (!last || !last.url) throw new Error('The upload finished without a file URL — try again.');
358
+ return last;
359
+ }
325
360
  // Ingest by URL: the SERVER fetches the bytes (SSRF-guarded on every redirect hop) so nothing has to cross this
326
361
  // transport. Deliberately no body — /api/upload treats "a body AND a url" as an error rather than picking one.
327
362
  export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
@@ -387,7 +422,7 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
387
422
  // make_template_ad refusals (a 400 the server had classed as the caller's config) sat on the admin board as
388
423
  // "could not tell". `_viaApi` is the marker that says the server has seen it. It deliberately carries NO `jobId`:
389
424
  // the tool layer reads a jobId on an error as "timed out, still rendering", which a failed job is not.
390
- if (job.status === 'error') throw Object.assign(new Error(job.error || 'Render failed'), { _viaApi: true });
425
+ if (job.status === 'error') throw Object.assign(new Error(job.error || 'Render failed'), { _viaApi: true, ...(job.errorMeta?.videoChoice && typeof job.errorMeta.videoChoice === 'object' ? { videoChoice: job.errorMeta.videoChoice, status: 402 } : {}) }); // a queued video the balance could not cover refuses at the reserve with its options (errorMeta.videoChoice) — carry them so wrap() spells the choice
391
426
  if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
392
427
  // NEVER SLEEP PAST THE DEADLINE. A 3s interval made every wait 3s-granular: a caller who asked for 1s was held 3s,
393
428
  // and one who asked for 29s was held 30s, which is the whole 30-second step budget the ask exists to stay inside.
@@ -55,3 +55,46 @@ export function withHints(result, hints) {
55
55
 
56
56
  /** Read them back — the shape a check and a client both use, so neither has to know the key. */
57
57
  export const hintsOf = (result) => (result && result._meta && Array.isArray(result._meta[HINTS_KEY])) ? result._meta[HINTS_KEY] : [];
58
+
59
+
60
+ // ── A VIDEO THE CALLER EXPECTS AND CANNOT AFFORD IS A CHOICE, NOT A SWAP (2026-09-22, Dave) ─────────────────────
61
+ // The server refuses BEFORE planning or reserving — nothing billed — and the refusal carries `videoChoice`
62
+ // (server.js videoChoiceFor): the video's price against the balance, the image alternative priced, a top-up, and,
63
+ // only when one fits the balance together with the plan, a light draft. The text spells the same three options so
64
+ // a model can act on them; the hints are those options keyed as {do, why}, so an agent can branch instead of parsing.
65
+ // Which call makes the image depends on the tool that refused: plan_ad plans again with format image, render_ad goes
66
+ // back to plan_ad for an image plan, generate_video becomes generate_image. Pure, no imports — the twin rule above.
67
+ export function videoChoiceImageCall(tool) {
68
+ const t = String(tool || '');
69
+ if (t === 'generate_video') return 'generate_image({prompt: the same prompt})';
70
+ if (t === 'render_ad') return "plan_ad({…the same brief, format: 'image'}) then generate_image with its image_concept.prompt";
71
+ if (t === 'clone_video') return "plan_ad({reference: the same link, format: 'image'}) then generate_image";
72
+ return `${t || 'the same call'}({…the same arguments, format: 'image'})`;
73
+ }
74
+ export function videoChoiceDraftCall(tool, d) {
75
+ const t = String(tool || '');
76
+ const m = String(d?.model || ''), s = Math.round(Number(d?.durationSeconds) || 0);
77
+ if (t === 'plan_ad' || t === 'clone_video') return `${t}({…the same arguments, format: 'video', draft: {model: '${m}', durationSeconds: ${s}}})`;
78
+ return `${t || 'the same call'}({…the same arguments, model: '${m}', durationSeconds: ${s}})`;
79
+ }
80
+ export function videoChoiceHints(tool, choice) {
81
+ const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
82
+ const out = [
83
+ { do: videoChoiceImageCall(tool), why: `the image version is ~${o.image?.credits ?? '?'} credits against a balance of ${c.balance ?? '?'}; the video needs ~${c.videoCredits ?? '?'}` },
84
+ { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short of the video; a pack or a plan covers it, then the same call plans the video` },
85
+ ];
86
+ if (o.draft && o.draft.model) out.push({ do: videoChoiceDraftCall(tool, o.draft), why: `a light draft on ${o.draft.label || o.draft.model} (${o.draft.durationSeconds}s) is ~${(Number(o.draft.credits) || 0) + (Number(o.draft.planCredits) || 0)} credits and fits the balance; render the premium version after topping up` });
87
+ return out;
88
+ }
89
+ export function videoChoiceText(tool, choice) {
90
+ const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
91
+ const d = o.draft && o.draft.model ? o.draft : null;
92
+ const lines = [
93
+ `${c.seconds ? `A ${c.seconds}s video` : 'This video'} would cost about ${c.videoCredits ?? '?'} credits and the account has ${c.balance ?? '?'} (${c.short ?? '?'} short). Nothing was planned, rendered or charged. Tell the user and let them choose — never switch the format for them:`,
94
+ ` 1. Make it as an image instead (~${o.image?.credits ?? '?'} credits): ${videoChoiceImageCall(tool)}.`,
95
+ ` 2. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and the video goes ahead as asked.`,
96
+ ];
97
+ if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits): ${videoChoiceDraftCall(tool, d)}. Premium models once they top up.`);
98
+ else lines.push(` (No light-model draft fits this balance, so there is no "render anyway" option here.)`);
99
+ return lines.join('\n');
100
+ }
package/mcp/tools.mjs CHANGED
@@ -21,7 +21,7 @@ import { toolHeldBackByConnectors, toolProvider, toolUnoffered, metaAlternativeN
21
21
  import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
22
22
  import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
23
23
  // THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
24
- import { withHints } from './tool-hints.mjs';
24
+ import { withHints, videoChoiceText, videoChoiceHints } from './tool-hints.mjs';
25
25
 
26
26
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
27
27
  // /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
@@ -65,6 +65,7 @@ const creatorLine = (c) => {
65
65
  const bits = [c.source === 'generated' ? 'AI creator' : c.source === 'social' ? 'from a social profile' : c.source === 'upload' ? 'uploaded photo' : c.source];
66
66
  if (c.source !== 'generated') bits.push(c.consented ? 'likeness consented' : 'NO likeness consent on file');
67
67
  if (c.poses) bits.push(`${c.poses} pose plate${c.poses === 1 ? '' : 's'}`);
68
+ if (c.lowQualityRef && !c.refAccepted) bits.push('PHOTO TOO UNCLEAR TO CAST — not cast by default; replace it with save_creator (same name, a better photo url) or accept it with save_creator(name, useAnyway: true)');
68
69
  if (c.voiceClone) bits.push('cloned voice');
69
70
  else if (c.voice) bits.push(`voice ${c.voice}`);
70
71
  return ` • ${c.name} — ${bits.join(', ')}${c.id ? ` [${c.id}]` : ''}\n ${c.image || '(portrait stored in-app as an uploaded photo — cast them by NAME; there is no url to hand a render tool)'}`;
@@ -233,7 +234,7 @@ export const MCP_INSTRUCTIONS = [
233
234
  // ACT-DO-NOT-SURVEY last and the 2,048-byte cut landed inside it — 'DO NOT SURVE' — so the rule that exists to
234
235
  // stop an agent answering a render request with a model catalog was itself the thing truncated. Whatever gets
235
236
  // clipped from the tail must cost tool NAMES, which the roster still carries, never a rule, which it does not.
236
- 'Hermoso is an AI ad studio you drive over MCP. FIVE INDEPENDENT AREAS — none is a step in a pipeline and no tool needs you to have used another one first:',
237
+ 'Hermoso is marketing on autopilot, driven over MCP. FIVE INDEPENDENT AREAS — none is a step in a pipeline and no tool needs you to have used another one first:',
237
238
  'ACT ON THE REQUEST, DO NOT SURVEY IT: asked to make something, make it. render_ad, generate_image and generate_video all run with `model` omitted and go to a sound default. hermoso_capabilities (free) is for a specific model id, an exact credit cost or a live duration — never the answer to a request to create something.',
238
239
  '• RESEARCH the ads already winning: find_competitors, competitor_teardown, pull_competitor_ads, research_ads, search_meta_ads, search_google_ads, search_linkedin_ads, search_tiktok, search_instagram, search_youtube, search_reddit, search_threads, mine_angles, analyze_video, check_ad_policy.',
239
240
  '• CREATE finished on-brand ads: render_ad, generate_image, generate_video, generate_avatar, make_template_ad, make_thumbnail, make_explainer, plan_ad, plan_variations; get_brand / draft_brand / update_brand; list_creators / save_creator; edit_video, dub_video, clip_video, reframe_video, upscale_video, stitch_video.',
@@ -437,12 +438,19 @@ const wrap = (fn) => {
437
438
  // BOTH phrasings. The gates say "You're out of credits" while the reserve path says "Not enough credits";
438
439
  // matching only the latter meant research, X posting and the competitor watch hit a 402 and told the agent
439
440
  // nothing about how to fix it, so the top-up path this whole flow depends on was unreachable from those tools.
440
- if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
441
+ // A VIDEO THE CALLER EXPECTED AND CANNOT AFFORD IS A CHOICE (2026-09-22): the server refused before planning or
442
+ // reserving and sent the options (image priced, top-up, a light draft that fits) — spell them out, never a silent
443
+ // format swap and never just "top up". Read from the STRUCTURED field, exactly like the connector marker below.
444
+ if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice); _hints.push(...videoChoiceHints(_tool, e.videoChoice)); }
445
+ else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
441
446
  // connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
442
447
  // in-agent) — Dave 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
443
448
  else {
444
449
  msg += notConnectedHint(e, msg);
445
450
  // Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
451
+ // META'S SECURITY HOLD (code 31/3858385): the server's sentence already carries the steps; this names the move
452
+ // so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
453
+ if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security hold: as the Facebook profile that connected Hermoso, turn on two-factor authentication, then in Ads Manager open Billing and payments and click Start authentication (or facebook.com/accountquality if there is no button), then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
446
454
  if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
447
455
  }
448
456
  // ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
@@ -454,7 +462,7 @@ const wrap = (fn) => {
454
462
  // `_meta` is MCP's own sanctioned extension point (spec: any result MAY carry it), so every MCP client
455
463
  // sees an ordinary error result and ignores a key it does not recognise. `publishWrap` spreads the result,
456
464
  // so its ambiguous-publish advice keeps the marker rather than dropping it.
457
- return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '' } } }, _hints);
465
+ return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '', ...(e?.videoChoice?.options && typeof e.videoChoice.options === 'object' ? { options: e.videoChoice.options } : {}) } } }, _hints); // `options` = the video choice's three ways forward, structured, so /v1 answers its 402 with them
458
466
  }
459
467
  };
460
468
  return outer;
@@ -4433,7 +4441,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4433
4441
  dataUri: z.string().optional().describe('base64 data: URI of the file bytes (data:<mime>;base64,<…>) — bytes travel over this connection, so keep it small'),
4434
4442
  name: z.string().optional().describe('original file name — helps pick the right extension'),
4435
4443
  },
4436
- outputSchema: { url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional(), uploadUrl: z.string().optional().describe('the one-time PUT url, when getUploadUrl was asked for'), expiresAt: z.string().optional(), maxBytes: z.number().optional() },
4444
+ outputSchema: { url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional(), uploadUrl: z.string().optional().describe('the one-time PUT url, when getUploadUrl was asked for'), expiresAt: z.string().optional(), maxBytes: z.number().optional(), partMaxBytes: z.number().optional().describe('the largest single PUT the link takes; a bigger file goes in parts with ?offset=&total= (see the howto)') },
4437
4445
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4438
4446
  }, wrap(async (a) => {
4439
4447
  // THE TICKET BRANCH RUNS FIRST AND ALONE: it is a request for a url, not an upload, so a source passed beside
@@ -4442,7 +4450,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4442
4450
  const conflict = ['url', 'path', 'dataUri'].filter(k => String(a[k] || '').trim());
4443
4451
  if (conflict.length) throw new Error(`\`getUploadUrl\` asks for a link to send bytes to; \`${conflict.join('` and `')}\` is a file to upload right now. Do one or the other.`);
4444
4452
  const t = await apiPost('/api/upload/ticket', {});
4445
- return ok(`PUT the file's raw bytes to this url and it answers with the durable Hermoso url:\n\n${t.uploadUrl}\n\n${t.howto}`, { uploadUrl: t.uploadUrl, expiresAt: t.expiresAt, maxBytes: t.maxBytes });
4453
+ return ok(`PUT the file's raw bytes to this url and it answers with the durable Hermoso url:\n\n${t.uploadUrl}\n\n${t.howto}`, { uploadUrl: t.uploadUrl, expiresAt: t.expiresAt, maxBytes: t.maxBytes, ...(t.partMaxBytes ? { partMaxBytes: t.partMaxBytes } : {}) });
4446
4454
  }
4447
4455
  // EXACTLY ONE SOURCE. Two is an ERROR: a caller who passes both has two different files in mind, and quietly
4448
4456
  // preferring one of them ingests the wrong file and reports success.
@@ -7073,7 +7081,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7073
7081
  title: 'Subscribe a Facebook Page to real-time events',
7074
7082
  description: 'Have Meta PUSH events to Hermoso instead of Hermoso polling for them. Subscribe a Page to `feed` (comments, posts, likes and shares — this is what makes a new comment ARRIVE rather than be polled for), `mention` (someone mentions the Page), `leadgen` (a lead-ad form submission delivered the moment it happens, which turns "read the leads when asked" into "the leads arrive"), and `messages` (an inbound Messenger or Instagram DM — THE ONLY WAY to be told about one, and Meta’s 24-hour reply window starts the moment it arrives, so being told rather than polling is time on the clock). Read what has arrived with list_meta_webhook_events. THE ANSWER IS READ BACK FROM META, never the 200: Meta can accept a subscription and record FEWER fields than you asked for, and a caller told "subscribed to leadgen" whose leads never arrive has no way to discover why — so any field Meta did not record is named. Fields Hermoso does not consume (message_reactions, messaging_postbacks, message_echoes) are refused by name with the reason, so an agent gets a fact rather than "unsupported". 0 credits.',
7075
7083
  inputSchema: {
7076
- fields: z.array(z.enum(['feed', 'mention', 'leadgen', 'messages'])).describe('what to be told about. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about.'),
7084
+ fields: z.array(z.enum(['feed', 'mention', 'leadgen', 'messages', 'marketing_messages', 'marketing_message_echoes', 'marketing_message_deliveries', 'marketing_message_reads', 'marketing_message_clicks', 'marketing_message_delivery_failed'])).describe('what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about.'),
7077
7085
  pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
7078
7086
  },
7079
7087
  outputSchema: { pageId: z.string().optional(), page: z.string().optional(), asked: z.array(z.string()).optional(), subscribed: z.boolean().nullable().optional(), fields: z.array(z.string()).optional(), summary: z.string().optional(), retry: z.string().optional(), callback: z.string().optional() },
@@ -7651,8 +7659,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
7651
7659
 
7652
7660
  // ── MESSENGER MARKETING MESSAGES (2026-09-05). Meta's paid re-engagement channel: messages OUTSIDE the 24-hour
7653
7661
  // window to people who opted in, billed by Meta to the brand's ad account per delivered message. Flow 2 of Meta's
7654
- // onboarding (user token + marketing_messages_messenger) rides the existing Meta connection. App-Review gated:
7655
- // until Meta grants the scope it works for app-role holders only, and list_messenger_subscribers says which.
7662
+ // onboarding (user token + marketing_messages_messenger) rides the existing Meta connection. The scope was
7663
+ // APPROVED in App Review round 5 (2026-09-22); list_messenger_subscribers reports whether THIS connection carries it.
7656
7664
  // Every endpoint, limit and error code: lib/messenger-marketing.mjs (read live 2026-09-05).
7657
7665
  // MESSENGER MARKETING MESSAGES ARE A CHANNEL CAPABILITY, NOT PAID ADS (2026-09-07). This block was registered inside
7658
7666
  // the ads section, so it rode the heaviest opt-in group while dispatching to no paid-advertising route — unreachable
@@ -7697,6 +7705,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
7697
7705
  const d = await apiGet('/api/meta/marketing-messages/campaigns', a);
7698
7706
  return ok(`${d.note}${(d.campaigns || []).length ? `\n${d.campaigns.map(c => `• ${c.name || '(unnamed)'} — ${c.campaignId}${c.dailyBudgetUsd != null ? `, $${c.dailyBudgetUsd}/day` : ''}${c.lifetimeBudgetUsd != null ? `, $${c.lifetimeBudgetUsd} lifetime` : ''}`).join('\n')}` : ''}`, d);
7699
7707
  }));
7708
+ // DID IT LAND? (2026-09-22). The send returns a tracking id meaning ACCEPTED. The outcome arrives only on the
7709
+ // Page webhook; every payload shape and what each one lacks is in lib/messenger-marketing.mjs.
7710
+ server.registerTool('get_messenger_marketing_message_status', {
7711
+ title: 'Did a Messenger marketing message land?',
7712
+ description: 'Whether a paid Messenger marketing message was delivered. send_messenger_marketing_message returns a tracking id, which only means Meta ACCEPTED it; this reads what Meta pushed back on the Page webhook for each tracking id: sent, delivered (the event Meta bills on), read, clicked (with count), or failed (with Meta’s reason). "unknown" means NO event has arrived, never that it failed: events arrive only when the Page is subscribed to the marketing_message_* fields (the reply says whether it is, read back from Meta; fix with subscribe_meta_webhooks fields ["marketing_messages"]) and only for messages sent after Hermoso began recording them on 2026-09-22. A failure carries no tracking id, so it is matched through Hermoso’s own send log. Read-only, 0 credits.',
7713
+ inputSchema: {
7714
+ trackingId: z.string().optional().describe('the marketing_message_tracking_id send_messenger_marketing_message returned'),
7715
+ trackingIds: z.array(z.string()).optional().describe('several at once, ≤200'),
7716
+ campaignId: z.string().optional().describe('instead of ids: every send Hermoso logged from this campaign'),
7717
+ pageId: z.string().optional().describe('the sending Page, for the subscription check; defaults to the brand’s one shared Page'),
7718
+ },
7719
+ outputSchema: { count: z.number().optional(), counts: z.record(z.number()).optional(), results: z.array(z.any()).optional(), pageId: z.string().nullable().optional(), subscribed: z.boolean().nullable().optional(), missingFields: z.array(z.string()).optional(), note: z.string().optional() },
7720
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
7721
+ }, wrap(async (a) => {
7722
+ const q = { ...a, ...(Array.isArray(a.trackingIds) ? { trackingIds: a.trackingIds.join(',') } : {}) };
7723
+ const d = await apiGet('/api/meta/marketing-messages/status', q);
7724
+ const lines = (d.results || []).slice(0, 50).map(r => `• ${r.trackingId}: ${r.state} — ${r.note}`);
7725
+ return ok(`${d.note}${lines.length ? `\n${lines.join('\n')}` : ''}`, d);
7726
+ }));
7700
7727
  server.registerTool('estimate_messenger_marketing_delivery', {
7701
7728
  title: 'Estimate Messenger marketing-message delivery and cost',
7702
7729
  description: 'Meta’s own estimate of how many marketing messages a budget would deliver from a Page and what it would cost, before anything is created. Free. Meta marks the metric “in development”, so treat it as a range.',
@@ -7705,7 +7732,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7705
7732
  }, wrap(async (a) => { const d = await apiGet('/api/meta/marketing-messages/estimate', a); return ok(d.note, d); }));
7706
7733
  server.registerTool('send_messenger_marketing_message', {
7707
7734
  title: 'Send a paid Messenger marketing message',
7708
- description: 'Send a PAID marketing message on Messenger to opted-in subscribers (Meta act_<AD>/messages). A campaign created in the last ~hour answers 2300012 then 2300041 (Meta still preparing it) with the seconds left; prefer an existing campaign from list_messenger_marketing_campaigns. One message per subscriber per 12 hours — Meta’s rule, enforced before dispatch and by Meta. message.type: text | button (text + up to 3 web_url buttons) | generic (a card: title, subtitle, image, tap-through url, up to 3 buttons) | media (imageUrl or videoId + buttons). Give subscriptionTokens (≤200 per call, from list_messenger_subscribers) OR customAudienceId of a MESSENGER_SUBSCRIBER_LIST audience of 100+ people for a bulk send. Meta bills the ad account per delivered message; delivery, read and click events arrive on the Page webhook. dryRun:true previews the wire body and sends nothing. Meta’s frequency caps are silent: a refusal saying the person is capped is Meta protecting them, not a broken send.',
7735
+ description: 'Send a PAID marketing message on Messenger to opted-in subscribers (Meta act_<AD>/messages). A campaign created in the last ~hour answers 2300012 then 2300041 (Meta still preparing it) with the seconds left; prefer an existing campaign from list_messenger_marketing_campaigns. One message per subscriber per 12 hours — Meta’s rule, enforced before dispatch and by Meta. message.type: text | button (text + up to 3 web_url buttons) | generic (a card: title, subtitle, image, tap-through url, up to 3 buttons) | media (imageUrl or videoId + buttons). Give subscriptionTokens (≤200 per call, from list_messenger_subscribers) OR customAudienceId of a MESSENGER_SUBSCRIBER_LIST audience of 100+ people for a bulk send. Meta bills the ad account per delivered message. A successful send means ACCEPTED, not delivered: read sent / delivered / read / clicked / failed per tracking id with get_messenger_marketing_message_status (Meta pushes them on the Page webhook; the Page must be subscribed with subscribe_meta_webhooks fields ["marketing_messages"]). dryRun:true previews the wire body and sends nothing. Meta’s frequency caps are silent: a refusal saying the person is capped is Meta protecting them, not a broken send.',
7709
7736
  inputSchema: {
7710
7737
  adAccountId: z.string(),
7711
7738
  campaignId: z.string().describe('the message campaign id (from create_messenger_marketing_campaign or list_messenger_marketing_campaigns) — OR the campaign NAME as the user said it: a non-numeric value is resolved against the account\'s own campaigns, so you never need to ask for an id'),
@@ -16729,10 +16756,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
16729
16756
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
16730
16757
  reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post, YouTube Short/video or a direct video file — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
16731
16758
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
16759
+ draft: z.object({ model: z.string(), durationSeconds: z.number() }).optional().describe("ONLY after a video refusal that offered a light draft: the {model, durationSeconds} it named. The plan is then authored to that length and priced on that model. Never invent one — a video the account cannot cover is refused BEFORE planning with the three options (image / add credits / this draft when one fits), and the user chooses."),
16732
16760
  },
16733
16761
  outputSchema: {
16734
16762
  format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
16735
16763
  concept: z.string().optional().describe('the one-line creative concept'),
16764
+ format_note: z.string().optional().describe("the plan's own read-back when it chose an IMAGE on an open format because the balance could not cover the default video — states the video's real credit price"),
16765
+ budget_pick: z.any().optional().describe('{format, reason, videoCredits, balance, seconds} when format_note is set'),
16766
+ concept_count: z.number().optional().describe('how many distinct concepts the brief asked for; the plan builds out the first and sets variants to it'),
16736
16767
  recipe: z.string().optional().describe('the resolved recipe id'),
16737
16768
  recipe_label: z.string().optional().describe('the resolved recipe display name'),
16738
16769
  copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
@@ -16744,7 +16775,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16744
16775
  brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
16745
16776
  },
16746
16777
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
16747
- }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting }) => {
16778
+ }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft }) => {
16748
16779
  // LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
16749
16780
  // 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
16750
16781
  // duration at all). /api/create has honored `durationSeconds` all along (it becomes the planner's "Target video
@@ -16763,7 +16794,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
16763
16794
  const _n = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
16764
16795
  try { const cur = await apiGet('/api/brand/current'); if (cur?.hasBrand && cur.brand && _n(cur.brand.name) && _n(cur.brand.name) === _n(brand)) brandObj = cur.brand; } catch {}
16765
16796
  }
16766
- const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_len ? { durationSeconds: _len } : {}), hook: hook || '', setting: setting || '', userAsk: String(product || '') });
16797
+ const _draft = (draft && typeof draft === 'object' && String(draft.model || '').trim()) ? { model: String(draft.model).trim(), durationSeconds: Math.round(+draft.durationSeconds || 0) } : null; // the accepted light draft rides as itself; its length is the plan's length
16798
+ const d = await apiPost('/api/create', { brand: brandObj, product, format: _draft ? 'video' : format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_draft ? { draft: _draft, durationSeconds: _draft.durationSeconds || _len || undefined } : (_len ? { durationSeconds: _len } : {})), hook: hook || '', setting: setting || '', userAsk: String(product || '') });
16767
16799
  const c = d.creative || d;
16768
16800
  // EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
16769
16801
  // grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
@@ -16788,7 +16820,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16788
16820
  + (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
16789
16821
  + (_len && _planned && Math.abs(_planned - _len) > 1 ? ` — ⚠ this does NOT match the ${_len}s you asked for; tell the user before rendering, or re-plan` : '');
16790
16822
  }
16791
- const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
16823
+ const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.concept_count > 1 ? `\nYou asked for ${c.concept_count} concepts: the concept line names each; this plan builds out the first (variants = ${c.variants || c.concept_count}). Call plan_ad again with a different angle for the others, or render_ad with variants for takes of this one.` : ''}${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
16792
16824
  return ok(text, c);
16793
16825
  }));
16794
16826
 
@@ -17083,7 +17115,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17083
17115
  description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), an optional brand end card (only when the user asks), licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
17084
17116
  inputSchema: {
17085
17117
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
17086
- creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if the plan has nobody on camera, or if they are a REAL person with no likeness consent on file.'),
17118
+ creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default on any plan with a person on camera, and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if they are a REAL person with no likeness consent on file.'),
17087
17119
  model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
17088
17120
  durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
17089
17121
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
@@ -17127,12 +17159,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
17127
17159
  const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
17128
17160
  if (_len) a = { ...a, durationSeconds: _len };
17129
17161
  const _clampNote = (_askedLen && _askedLen !== _len) ? `\n(${_askedLen}s is outside the supported 4–180s range — rendered at ${_len}s.)` : '';
17130
- const { input, jobType, notes, needsProductPhoto, creator } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
17162
+ const { input, jobType, notes, needsProductPhoto, creator, ownRefNotice } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
17131
17163
  // THE CAST IS THE READ-BACK, NEVER THE ASK. `creator` is the row the SERVER resolved out of this workspace's own
17132
17164
  // roster; a half-remembered name that matched nobody, matched two people, or belongs to an unconsented real
17133
17165
  // person never reaches here at all (the assemble route refuses, free, before a job exists). So this line names
17134
17166
  // who is actually in the ad, and it names them from the resolution — the same law the ads tree follows.
17135
- const _castLine = creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consented ? ', likeness consent on file' : '') : ''}.` : '';
17167
+ const _castLine = (creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consented ? ', likeness consent on file' : '') : ''}.` : '') + (ownRefNotice ? `\n⚠ ${ownRefNotice.text} ${ownRefNotice.fix}` : ''); // a creator's own photo too poor to cast is never cast silently (lowQualityRef) — say so and name the fix
17136
17168
  // LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
17137
17169
  // (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
17138
17170
  // acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
@@ -17982,17 +18014,37 @@ function memoryNoteVerdict(text) {
17982
18014
  description: 'Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + > Pick a creator > save. Pass the portrait’s public url (a generate_image render of a person, a headshot, any public photo) plus a name to call them by; from then on list_creators returns them and their url can be re-passed to generate_avatar / generate_video / recast_motion. Saving is FREE and renders nothing. LIKENESS — `source` says what the portrait IS: leave it "generated" for an AI-made person, and use "upload"/"social" ONLY for a REAL person. Pass consented:true only when the user has told you that person agreed to their likeness being used; never assert that on their behalf.',
17983
18015
  inputSchema: {
17984
18016
  name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
17985
- image: z.string().describe('public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns'),
18017
+ image: z.string().optional().describe('REQUIRED except with useAnyway. public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns'),
17986
18018
  source: z.enum(['generated', 'upload', 'social']).optional().describe('"generated" (default) = an AI-made person; "upload" / "social" = a REAL person'),
17987
18019
  consented: z.boolean().optional().describe('REAL people only: the user has confirmed that person consented to their likeness being used in ads'),
17988
18020
  voice: z.string().optional().describe('a default voice name for this persona (engines + voices are in hermoso_capabilities)'),
17989
18021
  poses: z.array(z.string()).optional().describe('up to 4 extra full-body / angle plates of the SAME person (public urls) — they make a wider shot hold the identity'),
17990
18022
  look: z.string().optional().describe('their canonical wardrobe/appearance in words — reused to hold the look steady across ads'),
18023
+ useAnyway: z.boolean().optional().describe('only for a creator whose saved photo was flagged too unclear to cast (render_ad says so): true casts the current photo as it is, no new image needed'),
17991
18024
  },
17992
- outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional() },
18025
+ outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional(), refQuality: z.any().optional() },
17993
18026
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17994
18027
  }, wrap(async (a) => {
17995
18028
  const name = String(a.name || '').trim(), image = String(a.image || '').trim();
18029
+ // A PHOTO FLAGGED TOO UNCLEAR TO CAST (lowQualityRef, 2026-09-22) is REPLACED in place by a new portrait under the
18030
+ // same name — scored by the same rule the onboarding used — or accepted as it is with useAnyway. A twin row would
18031
+ // make the creator's own cast ambiguous (two rows named as the brand → nobody is cast).
18032
+ if (name) {
18033
+ let _l = await readStore('heist.avatars.v1'); if (!Array.isArray(_l)) _l = [];
18034
+ const low = _l.find(x => x && x.lowQualityRef && !x.refAccepted && String(x.name || '').trim().toLowerCase() === name.toLowerCase());
18035
+ if (low && a.useAnyway === true && !image) {
18036
+ low.refAccepted = true; await writeStore('heist.avatars.v1', _l);
18037
+ return ok(`“${low.name}” will be cast with the current photo as it is.`, { ok: true, id: low.id, creator: { id: low.id, name: low.name, image: abs(low.image), source: low.source } });
18038
+ }
18039
+ if (low && image) {
18040
+ if (!/^https?:\/\//i.test(image) && !image.startsWith('/generated/')) return { content: [{ type: 'text', text: 'The new photo must be a public https url — upload the file with upload_file first, then pass the url it returns.' }], isError: true };
18041
+ const r = await apiPost('/api/creator/ref', { image, name: low.name });
18042
+ low.image = r?.image || image; low.refQuality = r?.refQuality || null; low.lowQualityRef = !!r?.lowQualityRef; delete low.refAccepted; low.poses = [];
18043
+ await writeStore('heist.avatars.v1', _l);
18044
+ const q = r?.refQuality?.score != null ? ` (photo quality ${r.refQuality.score}/100)` : '';
18045
+ return ok(r?.lowQualityRef ? `Replaced “${low.name}”’s photo${q}, but this one is still too unclear to cast well — try a sharper, front-facing, well-lit photo, or save_creator(name: "${low.name}", useAnyway: true).` : `Replaced “${low.name}”’s photo${q} — renders now cast them from it.`, { ok: true, id: low.id, creator: { id: low.id, name: low.name, image: abs(low.image), source: low.source }, refQuality: r?.refQuality || null });
18046
+ }
18047
+ }
17996
18048
  if (!name || !image) return { content: [{ type: 'text', text: 'A creator needs both a name and a portrait url.' }], isError: true };
17997
18049
  // The portrait must be FETCHABLE by every render lane that will consume it. A data: blob or a local path is a
17998
18050
  // reference nothing downstream can resolve, so refuse here with the fix rather than saving a dead entry that
@@ -19262,13 +19314,20 @@ function memoryNoteVerdict(text) {
19262
19314
  hasBrand: z.boolean().optional().describe('whether a brand is saved for this workspace'),
19263
19315
  brand: z.any().optional().describe('the saved brand profile (name, domain, category, products, palette, …) or null'),
19264
19316
  memoryCount: z.number().optional().describe('how many learned memory notes the workspace holds'),
19317
+ persona: z.string().nullable().optional().describe("who the user said they are at onboarding: creator | business | agency | marketer | explorer, or null when they never said. The same resolver the web Studio steers by."),
19318
+ personaNote: z.string().optional().describe('how to work for that kind of user (a creator wants organic content for their own feed, not ads by default; an explorer came to play with the models and needs no brand). Empty when persona is null.'),
19265
19319
  },
19266
19320
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
19267
19321
  }, wrap(async () => {
19268
19322
  const d = await apiGet('/api/brand/current');
19323
+ // WHO IS ASKING (2026-09-22): a creator's saved "brand" is their personal brand and an explorer has none on purpose,
19324
+ // so the text says so before it says what the create tools do with the profile — the same note the web Studio reads.
19325
+ const who = d?.personaNote ? `\n${d.personaNote}` : '';
19269
19326
  const text = d?.hasBrand
19270
- ? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.`
19271
- : 'No saved brand for this workspace yet — onboard one with draft_brand (it saves automatically), or the user can onboard in the web Studio.';
19327
+ ? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${who}`
19328
+ : (d?.persona === 'explorer'
19329
+ ? `No saved brand for this workspace, and that is the user's choice: they picked "Just exploring". Make what they ask with no brand at all; a brand is optional (draft_brand, or the web Studio) and only worth mentioning if THEY ask for something about their own business.${who}`
19330
+ : `No saved brand for this workspace yet — onboard one with draft_brand (it saves automatically), or the user can onboard in the web Studio. Not a precondition: every tool works from what the user tells you.${who}`);
19272
19331
  return ok(text, d);
19273
19332
  }));
19274
19333
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.269",
3
+ "version": "0.1.272",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "Marketing on autopilot, run from your own AI agent. 855 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.",
5
+ "description": "Marketing on autopilot, run from your own AI agent. 856 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"