hermoso 0.1.419 → 0.1.422

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/client.mjs CHANGED
@@ -500,6 +500,22 @@ export async function submitJob(type, input, { label = '' } = {}) {
500
500
  return apiPost('/api/jobs', { type, input, label }); // → publicJob {id, status, ...}
501
501
  }
502
502
  export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
503
+ // ONE READ THAT A ROLLOVER CANNOT TURN INTO "IT DOES NOT EXIST" (2026-10-19). get_job is a single read, and its 404
504
+ // answer tells the caller to STOP POLLING — so a 404 from a revision that has not yet seen a job (see pollMissVerdict)
505
+ // ended the follow-up for good. A 404 is re-read for a short window before it is reported as absent; any other answer
506
+ // returns at once, and an id that really does not exist costs only that window. null = absent after the window.
507
+ export const JOB_READ_GRACE_MS = 12_000;
508
+ export async function getJobSettled(id, { graceMs = JOB_READ_GRACE_MS, intervalMs = 3000, getJobFn = getJob } = {}) {
509
+ const until = Date.now() + Math.max(0, graceMs);
510
+ for (;;) {
511
+ try { return await getJobFn(id); }
512
+ catch (e) {
513
+ if (Number(e?.status) !== 404) throw e;
514
+ if (Date.now() + intervalMs > until) return null;
515
+ await new Promise((r) => setTimeout(r, intervalMs));
516
+ }
517
+ }
518
+ }
503
519
  export function jobResult(job) { const r = job?.result; return r && Object.prototype.hasOwnProperty.call(r, 'data') ? r.data : r; }
504
520
 
505
521
  // HOW LONG A RENDER TOOL HOLDS ITS CALLER, in ms. `cap` is the transport's own maximum (45s hosted, 10min local) and
@@ -519,9 +535,17 @@ export function jobWaitMs(requested, cap) {
519
535
  // 404 (and a 502/503/504, which is what a rollover looks like from outside) is retried; past it a 404 is final and is
520
536
  // said in words, never as the bare route error. PURE, so tools/job-readthrough-check.mjs runs it.
521
537
  export const JOB_MISS_GRACE_MS = 30_000;
522
- export function pollMissVerdict(status, { startedAt, now = Date.now(), deadline = Infinity, graceMs = JOB_MISS_GRACE_MS } = {}) {
538
+ // THE GRACE RUNS FROM THE FIRST MISS, NOT FROM THE SUBMIT (2026-10-19). A rollover can land at ANY point in a 1-3 minute
539
+ // render, not only in its first seconds: job_mvfhwfft5wijd was submitted at 16:55:21 to revision 02505, the poll at
540
+ // 16:56:09 reached 02506 (booted 16:55:48), which had not yet seen it and answered 404, and because the grace had been
541
+ // counted from the submit (48s earlier) that first 404 was FINAL: the caller read "no record of job" about a render
542
+ // that answered 200 nine seconds later and finished. `missSince` is when the current run of 404s began (pollJob resets
543
+ // it on any answer), so a miss is final only after it has lasted the whole grace; a caller with no `missSince` keeps
544
+ // the old reading (startedAt). The deadline still bounds every wait.
545
+ export function pollMissVerdict(status, { startedAt, missSince = null, now = Date.now(), deadline = Infinity, graceMs = JOB_MISS_GRACE_MS } = {}) {
523
546
  const st = Number(status);
524
- if (st === 404) return (now - startedAt < graceMs && now < deadline) ? 'retry' : 'final';
547
+ const since = Number.isFinite(+missSince) && missSince != null ? +missSince : startedAt;
548
+ if (st === 404) return (now - since < graceMs && now < deadline) ? 'retry' : 'final';
525
549
  // a throttle is not news about the render: a poll that was turned away waits and asks again (2026-10-04)
526
550
  if ((st === 429 || st === 502 || st === 503 || st === 504) && now < deadline) return 'retry';
527
551
  return 'throw';
@@ -531,14 +555,16 @@ export function jobMissMessage(id) {
531
555
  + 'If it was just submitted, the server that took it was replaced before it wrote the job down, so it cannot be followed from here. A render lost that way is not charged: the credits held for it are released automatically. '
532
556
  + 'Call list_jobs to see this workspace\'s recent jobs. Do not re-run the render on the strength of this message alone.';
533
557
  }
534
- export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick, getJobFn = getJob } = {}) {
558
+ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick, getJobFn = getJob, graceMs = JOB_MISS_GRACE_MS } = {}) {
535
559
  const startedAt = Date.now();
536
560
  const deadline = startedAt + timeoutMs;
561
+ let missSince = null; // when the current run of 404s began (see pollMissVerdict); any answer resets it
537
562
  for (;;) {
538
563
  let job;
539
- try { job = await getJobFn(id); }
564
+ try { job = await getJobFn(id); missSince = null; }
540
565
  catch (e) {
541
- const v = pollMissVerdict(e?.status, { startedAt, now: Date.now(), deadline });
566
+ if (Number(e?.status) === 404 && missSince == null) missSince = Date.now();
567
+ const v = pollMissVerdict(e?.status, { startedAt, missSince, now: Date.now(), deadline, graceMs });
542
568
  if (v === 'final') throw Object.assign(new Error(jobMissMessage(id)), { status: 404, _viaApi: true, _jobMissing: true });
543
569
  if (v === 'throw') throw e;
544
570
  await new Promise(r => setTimeout(r, Math.min(intervalMs, Math.max(50, deadline - Date.now()))));
package/mcp/tools.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
6
6
  import { z } from 'zod';
7
7
  import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
8
- import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT, brandScope } from './client.mjs';
8
+ import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, getJobSettled, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT, brandScope } from './client.mjs';
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -287,7 +287,7 @@ export const MCP_INSTRUCTIONS = [
287
287
  // stop an agent answering a render request with a model catalog was itself the thing truncated. Whatever gets
288
288
  // clipped from the tail must cost tool NAMES, which the roster still carries, never a rule, which it does not.
289
289
  '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:',
290
- '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.',
290
+ '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, a credit cost or a live duration — never the answer to a request to create something.',
291
291
  '• 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.',
292
292
  '• 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.',
293
293
  '• RAW MODELS, prompt only: generate_image / generate_video with useBrand:false, generate_voice, generate_text, upload_file (any file becomes a URL every tool accepts).',
@@ -336,7 +336,7 @@ export const MCP_INSTRUCTIONS = [
336
336
  'Capability map:',
337
337
  '• 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.',
338
338
  '• 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.',
339
- '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
339
+ '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (credit costs in hermoso_capabilities), no ad framing.',
340
340
  '• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
341
341
  '• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send), and the Instagram-only save_instagram_dm_automation / test_instagram_dm_automation / list_instagram_dm_automations / delete_instagram_dm_automation; X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
342
342
  'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This route needs no reload and works on every host, including claude.ai and ChatGPT, which fix their tool list when the connection is made. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call \u2014 some clients cache their tool list for the whole conversation \u2014 do NOT tell the user the capability does not exist and do not keep retrying: just use call_tool, which does not depend on the roster changing at all. Reconnecting with `?tools=all` on the server URL lists everything at connect time. NEVER tell a user Hermoso cannot manage their campaigns or read their analytics, cannot read a comment, answer a DM or pull a channel\u2019s numbers, because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
@@ -379,7 +379,7 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
379
379
  // Every other host keeps MCP_INSTRUCTIONS unchanged.
380
380
  export const MCP_INSTRUCTIONS_WIDGET = [
381
381
  "Hermoso runs a brand's marketing: research the ads other brands are running, create on-brand image and video ads, schedule posts to the user's own connected accounts, and read how posts and campaigns perform. Each area works on its own; none needs another first.",
382
- 'Act on the request: asked to make something, make it. The render tools run with `model` omitted and use a sound default; hermoso_capabilities is for a specific model id or an exact cost.',
382
+ 'Act on the request: asked to make something, make it. The render tools run with `model` omitted and use a sound default; hermoso_capabilities is for a specific model id or a credit cost.',
383
383
  '• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
384
384
  '• CREATE: get_brand / draft_brand (the saved brand), plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants of a finished ad with edit_image, headline_variants, hook_variants, multiply_ad. Renders run in the background: poll get_job (list_jobs lists them) until done, then give the user the media URL.',
385
385
  "• FILES & POSTING: upload_file turns a file into a URL; schedule_post queues a post to the user's own connected accounts; list_scheduled shows the queue; list_connectors shows what is connected (the user connects accounts in the Hermoso app under Settings > Connectors); list_library lists past creations.",
@@ -602,12 +602,16 @@ async function renderJob(type, input, label) {
602
602
  // A FIX IS PRICED BEFORE IT RUNS (2026-09-25). dryRun on edit_image / fix_beat / post_edit asks the server for the credits
603
603
  // that exact job would reserve: its own worker's pricing, stopped at the hold (jobHoldQuote), so an agent states a LIVE
604
604
  // number, never a remembered one. Nothing is queued, rendered or charged.
605
+ // A PRICE BEFORE A RUN IS AN ESTIMATE (2026-10-19, the owner: "charge the real amount not just what we quoted. the quotes are
606
+ // estimates anyway"). The server no longer caps a charge at its hold or quote: a render is charged its actual cost. Every
607
+ // pre-run figure an agent reads says so, and every finished render says what it WAS charged (chargeNote).
608
+ const PRICE_ESTIMATE_LINE = 'The final charge is the actual cost of the run, usually within a few percent of this estimate.';
605
609
  async function quoteJob(type, input) {
606
610
  const d = await apiPost('/api/jobs', { type, input, dryRun: true });
607
611
  return d && d.quote ? d.quote : null;
608
612
  }
609
613
  function quoteText(q, what) {
610
- if (q && q.credits != null && Number.isFinite(+q.credits)) return `DRY RUN (nothing rendered, nothing charged): ${what} reserves ${q.credits} credits when it runs. The charge settles to the measured cost and never above that hold. Call again without dryRun to run it.`;
614
+ if (q && q.credits != null && Number.isFinite(+q.credits)) return `DRY RUN (nothing rendered, nothing charged): ${what} is estimated at ${q.credits} credits (it reserves that much when it runs). ${PRICE_ESTIMATE_LINE} Call again without dryRun to run it.`;
611
615
  return `DRY RUN (nothing rendered, nothing charged): ${what} would not start${q && q.refused ? `: ${q.refused}` : ', so it has no price'}.`;
612
616
  }
613
617
  // The wait half of renderJob, for a job some other route queued (generate_image's queue mode). One waiting rule.
@@ -625,7 +629,7 @@ async function awaitRenderJob(job) {
625
629
  // 30s step budget (an automation platform) asked for the handle, not for a status.
626
630
  if (waitMs <= 0) return { jobId: job.id, url: null, stillRendering: true, raw: null };
627
631
  try {
628
- const { result } = await pollJob(job.id, { timeoutMs: waitMs });
632
+ const { result, job: doneJob } = await pollJob(job.id, { timeoutMs: waitMs });
629
633
  const url = abs(result?.video || result?.image || result?.url);
630
634
  // creditsUsed AND the delivered geometry ride out here deliberately. The widget's own registered
631
635
  // description promises "the model that rendered it and credits spent", and the pill only ever appeared
@@ -633,7 +637,7 @@ async function awaitRenderJob(job) {
633
637
  // the same job showed "Seedance 2.5" direct and "Seedance 2.5 · 463 credits" through get_job.
634
638
  // The dimensions are what stop a 9:16 clip rendering in a full-width band with black either side.
635
639
  return { jobId: job.id, url, model: result?.model || null,
636
- creditsUsed: result?.creditsUsed ?? null, poster: abs(result?.poster) || null,
640
+ creditsUsed: doneJob?.chargedCredits ?? result?.creditsUsed ?? null, chargedCredits: doneJob?.chargedCredits ?? null, poster: abs(result?.poster) || null,
637
641
  deliveredWidth: result?.deliveredWidth ?? null, deliveredHeight: result?.deliveredHeight ?? null,
638
642
  raw: result };
639
643
  } catch (e) {
@@ -656,7 +660,12 @@ const renderPayload = (r) => (r?.raw ?? jobResult(r) ?? null);
656
660
  // is not the model the caller named — an entry-belt coercion, or an in-flight content-filter/quota fallback — and the
657
661
  // bill always follows the model that RAN. An agent that asked for one model and reads a one-line "ready" reply would
658
662
  // otherwise never learn it got another, so the note rides the reply text every render tool prints.
659
- const switchNote = (r) => { const n = renderPayload(r)?.modelNote || r?.modelNote; return n ? `\n⚠ ${n}` : ''; };
663
+ // WHAT A FINISHED RENDER WAS CHARGED (2026-10-19). Pre-run prices are estimates now, so the reply that delivers a render
664
+ // says the real charge: the job's own chargedCredits (every leg it billed), else the worker's settle. Rides switchNote
665
+ // because switchNote is already on every render tool's delivery sentence, inline and resumed via get_job alike.
666
+ const chargedOf = (r) => { const v = r?.chargedCredits ?? r?.creditsUsed ?? renderPayload(r)?.creditsUsed ?? r?.result?.creditsUsed; return Number.isFinite(+v) && +v > 0 ? Math.round(+v) : null; };
667
+ const chargeNote = (r) => { const c = chargedOf(r); return c != null ? `\nCharged ${c} credit${c === 1 ? '' : 's'} (the actual cost of this run).` : ''; };
668
+ const switchNote = (r) => { const n = renderPayload(r)?.modelNote || r?.modelNote; return (n ? `\n⚠ ${n}` : '') + chargeNote(r); };
660
669
  // THE VISION-QA READ-BACK (2026-08-03). The server WATCHES every generatively-rendered clip before it reports it
661
670
  // done — free ffmpeg cadence/freeze probes plus ONE vision call over frames sampled at the hook, either side of
662
671
  // each cut, and the tail — and puts what it saw on the result as `qaNote` (see vision-qa.mjs). An agent reading a
@@ -4273,7 +4282,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4273
4282
  // The title a directory renders. It has to say CALL THIS FIRST without saying "call this first", because the
4274
4283
  // listing shows it out of context, beside 680 siblings, to somebody who has never used Hermoso.
4275
4284
  title: 'Start here: what Hermoso can do and what it costs',
4276
- description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call it when you need a specific model id, an exact cost, or a capability you are not sure of. It is NOT a prerequisite for rendering: generate_image, generate_video and render_ad all run with `model` omitted and route to the server’s own default. Read-only, free.',
4285
+ description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their credit costs (estimates: a render is charged its actual cost), aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call it when you need a specific model id, a credit cost, or a capability you are not sure of. It is NOT a prerequisite for rendering: generate_image, generate_video and render_ad all run with `model` omitted and route to the server’s own default. Read-only, free.',
4277
4286
  inputSchema: {}, outputSchema: {
4278
4287
  image: z.boolean().optional().describe('TRUE when image generation is available on this account. A BOOLEAN, never a provider name — /api/generate/status deliberately reports availability only and never leaks the underlying provider, so there is no label here to report. The model ids live in options.image.models[].'),
4279
4288
  video: z.boolean().optional().describe('TRUE when video generation is available. A BOOLEAN, never a provider name (same never-leak-the-provider rule as image). The model ids live in options.video.models[].'),
@@ -4339,8 +4348,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4339
4348
  const _rmLine = _rm ? `\nRemake engines (recast_motion, restyle_video, edit_video engine): auto picks, for a clip with no real-looking person, ${Object.entries(_rm.defaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}; with a person in the clip, ${Object.entries(_rm.faceDefaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}${_rm.personClipLibraryDefaults ? ` (a person clip our face library can carry, i.e. one of our renders or faceRoute on a paid plan: ${Object.entries(_rm.personClipLibraryDefaults).map(([k, v]) => `${k} → ${v}`).join(', ')})` : ''}. seedance ${(_rm.seconds || []).join('-')}s, up to ${_rm.imagesMax} pictures, ${Object.entries(_rm.seedanceCredits || {}).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanCredits ? `. wan clips up to ${_rm.wanMaxSourceSeconds}s, ${Object.entries(_rm.wanCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanLabel ? ` (${_rm.wanLabel})` : ''}` : ''}${_rm.wanPrimeCredits ? `. wan-prime (Wan 3.0 Prime by name) ${Object.entries(_rm.wanPrimeCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}` : ''}` : '';
4340
4349
  const _je = d.jobEngines; // the certified engine per job (watched side-by-side tests, 2026-10-19)
4341
4350
  const _jeLine = _je && _je.defaults ? `\nDefault engine per job (watched side-by-side tests): ${Object.entries(_je.defaults).map(([k, v]) => `${k} → ${v}`).join(', ')}; a talking ad is one take of up to ${_je.talkingMaxSeconds}s. A model the user picks by name is kept; never switch it.` : '';
4342
- const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}${_extLine ? `\nExtend a clip (generate_video extend:true + refVideo): ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`;
4343
- return ok(text + connLine, d);
4351
+ const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}${_extLine ? `\nExtend a clip (generate_video extend:true + refVideo): ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
4344
4352
  }));
4345
4353
 
4346
4354
  server.registerTool('hermoso_credits', {
@@ -4364,7 +4372,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4364
4372
  // THE CHARGES THEMSELVES (2026-09-12): the same ledger list as Billing ▸ Usage, so "where did my credits go" has an
4365
4373
  // answer here too. Best effort: a reply about the balance never fails on the history read.
4366
4374
  let recent = ''; try { const u = await apiGet('/api/billing/usage', { limit: 10 }); if (u?.items?.length) recent = `\nRecent charges (newest first):\n${u.items.map((r) => `• ${String(r.at).slice(0, 16).replace('T', ' ')} UTC · ${r.label} · ${r.credits} credits`).join('\n')}`; } catch {}
4367
- return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}. ${d.costModel || 'Credits are spent ONLY on AI models and Ad Spy research; posting, scheduling, campaign management, analytics and connectors are free (X is the one per-call exception).'}${recent}`, d);
4375
+ return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}. ${d.costModel || 'Credits are spent ONLY on AI models and Ad Spy research; posting, scheduling, campaign management, analytics and connectors are free (X is the one per-call exception).'} ${d.costEstimate || PRICE_ESTIMATE_LINE}${recent}`, d);
4368
4376
  }));
4369
4377
 
4370
4378
  // AGENT BILLING: out of credits → top up. With a saved card + billing-admin rights this is the SAME one-click
@@ -18235,9 +18243,38 @@ function buildTools(rawServer, opts = {}, sink = null) {
18235
18243
 
18236
18244
  // ---------- planning (LLM, 0 SC credits) ----------
18237
18245
  server.group('create');
18246
+ // THE USER'S OWN PICTURES CUT INTO THE VIDEO (2026-10-19, parity with the web Studio's create_ad `inserts`). Each
18247
+ // image may be an https URL (an upload_file URL included), a Library id from list_library (L0, L1… newest first), or a
18248
+ // local path on the CLI (sent as bytes, stored once by the server). An id or path that resolves to nothing is refused
18249
+ // by name, free, before anything is planned or rendered.
18250
+ const INSERTS_Z = z.array(z.object({
18251
+ image: z.string().describe('the picture: an https URL, an upload_file URL, a Library id from list_library (L0, L1…), or a local file path on the CLI'),
18252
+ start: z.number().describe('second of the finished video it appears at'),
18253
+ end: z.number().describe('second it ends at (at least half a second after start)'),
18254
+ })).max(8);
18255
+ const insertsResolved = async (list) => {
18256
+ if (!Array.isArray(list)) return undefined;
18257
+ let lib = null; const out = [];
18258
+ for (const x of list.slice(0, 8)) {
18259
+ if (!x || typeof x !== 'object') continue;
18260
+ const raw = String(x.image || '').trim();
18261
+ let image = raw;
18262
+ const lid = /^L(\d{1,3})$/i.exec(raw);
18263
+ if (lid) {
18264
+ if (!lib) { const all = await readStore('heist.assets.v1').catch(() => null); lib = Array.isArray(all) ? all.filter((a) => a && a.url) : []; }
18265
+ const a = lib[+lid[1]];
18266
+ if (!a || (a.kind && a.kind !== 'image')) throw Object.assign(new Error(`${raw} is not an image in this workspace's Library (list_library numbers them L0, L1… newest first). Nothing was planned, rendered or charged.`), { status: 400, _userInput: true });
18267
+ image = String(a.url);
18268
+ } else image = await toRef(raw);
18269
+ if (!image) throw Object.assign(new Error(`The picture "${raw.slice(0, 80)}" could not be read: pass an https URL, an upload_file URL or a Library id (L0…). Nothing was planned, rendered or charged.`), { status: 400, _userInput: true });
18270
+ out.push({ image: /^\/generated\//.test(image) ? abs(image) : image, start: +x.start, end: +x.end });
18271
+ }
18272
+ return out;
18273
+ };
18274
+ const insertsLine = (ins, verb = 'are laid') => (Array.isArray(ins) && ins.length ? `\nCut-ins: ${ins.length === 1 ? 'the user\'s picture' : `${ins.length} of the user's pictures`} ${ins.length === 1 ? verb.replace(/^are/, 'is') : verb} full-screen at ${ins.map((x) => `${x.start}–${x.end}s`).join(', ')}.` : '');
18238
18275
  server.registerTool('plan_ad', {
18239
18276
  title: 'Plan an ad concept',
18240
- description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends credits.',
18277
+ description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. The user’s OWN screenshots or pictures cut into the video full-screen go in `inserts`: the board keeps those seconds free of any screen, and render_ad lays the real pictures in. Spends credits.',
18241
18278
  inputSchema: {
18242
18279
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
18243
18280
  product: z.string().describe('what to advertise + any angle/offer the user specified'),
@@ -18250,9 +18287,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
18250
18287
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
18251
18288
  talent: z.enum(['auto', 'creator', 'product_only']).optional().describe("who is on camera; omit to follow the format. Say 'someone new' in product to skip reusing a saved creator."),
18252
18289
  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."),
18290
+ inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's OWN pictures (app or report screenshots, a web page, a chart) shown FULL-SCREEN over the video at these seconds while its voice carries on underneath. The plan keeps the shot under each window on camera with no screen in it (the real picture covers it, never a drawn copy), the pictures are never used as references for the video itself, and they ride the returned creative as `inserts` so render_ad lays them in right after the render, in the same job (about 2 credits more). Each picture goes over the seconds that talk about what it shows; at most 8, never two at once. A picture the planned length has no room for is left out and the reply says so."),
18253
18291
  },
18254
18292
  outputSchema: {
18255
18293
  format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
18294
+ inserts: z.array(z.any()).optional().describe("the user's pictures render_ad will lay in full-screen: [{image, start, end}], fitted to the planned length"),
18295
+ inserts_note: z.string().optional().describe('set when a picture was left out because the planned length has no room for it'),
18256
18296
  concept: z.string().optional().describe('the one-line creative concept'),
18257
18297
  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"),
18258
18298
  budget_pick: z.any().optional().describe('{format, reason, videoCredits, balance, seconds} when format_note is set'),
@@ -18270,7 +18310,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18270
18310
  presence_note: z.string().optional(),
18271
18311
  },
18272
18312
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
18273
- }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent }) => {
18313
+ }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent, inserts }) => {
18274
18314
  // LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
18275
18315
  // 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
18276
18316
  // duration at all). /api/create has honored `durationSeconds` all along (it becomes the planner's "Target video
@@ -18290,7 +18330,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
18290
18330
  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 {}
18291
18331
  }
18292
18332
  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
18293
- 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 || ''), ...(talent && talent !== 'auto' ? { talent } : {}) });
18333
+ const _ins = await insertsResolved(inserts); // the user's own pictures, laid over the video after it renders (resolved before anything is billed)
18334
+ const d = await apiPost('/api/create', { brand: brandObj, product, format: _draft ? 'video' : (_ins && _ins.length && format === 'auto' ? '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 || ''), ...(talent && talent !== 'auto' ? { talent } : {}), ...(_ins && _ins.length ? { inserts: _ins } : {}) });
18294
18335
  const c = d.creative || d;
18295
18336
  // EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
18296
18337
  // grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
@@ -18315,7 +18356,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18315
18356
  + (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
18316
18357
  + (_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` : '');
18317
18358
  }
18318
- const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.presence_note ? '\n' + c.presence_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}${c.for_fun ? '\nJust for fun: a plain picture of what they asked for, with no headline, button or ad copy. Render it as it is and do not call it an ad.' : `\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'}.`;
18359
+ const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.presence_note ? '\n' + c.presence_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}${c.for_fun ? '\nJust for fun: a plain picture of what they asked for, with no headline, button or ad copy. Render it as it is and do not call it an ad.' : `\nHeadline: ${c.copy?.[0]?.headline || ''}`}${insertsLine(c.inserts, 'are laid in by render_ad')}${c.inserts_note ? `\n${c.inserts_note}` : ''}${Array.isArray(inserts) && inserts.length && !(Array.isArray(c.inserts) && c.inserts.length) && !c.inserts_note ? (c.format === 'video' ? '\n⚠ None of the pictures could be kept on this plan; render_ad will not lay any in.' : '\nThe pictures were not used: this plan is an image, and cut-ins go into a video.') : ''}\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'}.`;
18319
18360
  return ok(text, c);
18320
18361
  }));
18321
18362
 
@@ -18424,7 +18465,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18424
18465
  if (r.stillRendering) return { content: [{ type: 'text', text: `Image queued as job ${r.jobId}. It is rendering; read it with get_job.${q.productNote ? `\n${q.productNote}` : ''}` }], structuredContent: { jobId: r.jobId, stillRendering: true, ...(q.productNote ? { productNote: q.productNote } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
18425
18466
  const img = await imageBlock(r.url);
18426
18467
  const _qNote = [q.productNote, r.raw?.productNote].filter(Boolean).join(' '); // the route's note (logo, lineup) + the worker's (crop, the product check)
18427
- return { content: [{ type: 'text', text: `${_imgHead(r.raw?.productCheck)}${r.url}${r.model ? ` (${r.model})` : ''}${switchNote({ raw: r.raw || {} })}${_qNote ? `\n${_qNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { image: r.url, model: r.model, jobId: r.jobId, creditsUsed: r.creditsUsed, ...(_qNote ? { productNote: _qNote } : {}), ...(r.raw?.productCheck ? { productCheck: r.raw.productCheck } : {}), ...(r.raw?.labelPass ? { labelPass: r.raw.labelPass } : {}), ...(r.raw?.logoCheck ? { logoCheck: r.raw.logoCheck } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
18468
+ return { content: [{ type: 'text', text: `${_imgHead(r.raw?.productCheck)}${r.url}${r.model ? ` (${r.model})` : ''}${switchNote({ raw: r.raw || {}, chargedCredits: r.chargedCredits ?? null })}${_qNote ? `\n${_qNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { image: r.url, model: r.model, jobId: r.jobId, creditsUsed: r.creditsUsed, ...(_qNote ? { productNote: _qNote } : {}), ...(r.raw?.productCheck ? { productCheck: r.raw.productCheck } : {}), ...(r.raw?.labelPass ? { labelPass: r.raw.labelPass } : {}), ...(r.raw?.logoCheck ? { logoCheck: r.raw.logoCheck } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
18428
18469
  }
18429
18470
  const d = await apiPost('/api/generate/image', _imgBody); // explicit boolean so the server's saved-brand hydration default is unambiguous
18430
18471
  const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
@@ -18451,7 +18492,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18451
18492
  instruction: z.string().describe('the change to make, in plain words (pass the user’s own words for a removal or plain photo edit)'),
18452
18493
  removal: z.boolean().optional().describe('true when the edit REMOVES text, branding, a logo, a watermark, a person or an object, so the brand name and logo are not re-added'),
18453
18494
  mask: z.string().optional().describe('optional mask image (URL or local path) marking the region to change: transparent = change, or white = change on an opaque mask'),
18454
- dryRun: z.boolean().optional().describe('true = return the exact credits this edit reserves and render nothing'),
18495
+ dryRun: z.boolean().optional().describe('true = return the estimated credits this edit reserves and render nothing'),
18455
18496
  fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
18456
18497
  },
18457
18498
  outputSchema: {
@@ -18649,7 +18690,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18649
18690
  server.group('create');
18650
18691
  server.registerTool('render_ad', {
18651
18692
  title: 'Render ad video',
18652
- description: 'Use this when the user wants a finished video ad for their brand. 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), no music unless asked, real product references. UGC and cinematic plans are rendered from a painted STORYBOARD board (cleaned, then bound first, ahead of the creator and the product); a cinematic spot paints a location anchor first (and a hero when someone is on camera). A product-only commercial renders from the real product photo by default; board:true or foundations:\'choose\' adds a product identity sheet, a four-up moodboard and a board. 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.',
18693
+ description: 'Use this when the user wants a finished video ad for their brand. 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), no music unless asked, real product references. UGC and cinematic plans are rendered from a painted STORYBOARD board (cleaned, then bound first, ahead of the creator and the product); a cinematic spot paints a location anchor first (and a hero when someone is on camera). A product-only commercial renders from the real product photo by default; board:true or foundations:\'choose\' adds a product identity sheet, a four-up moodboard and a board. 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. The user’s own pictures that plan_ad was given (creative.inserts) are laid in full-screen at their seconds by the same job, or pass `inserts` here. Spends credits.',
18653
18694
  inputSchema: {
18654
18695
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
18655
18696
  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”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). 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 when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), 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 a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
@@ -18668,8 +18709,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
18668
18709
  lockup: z.boolean().optional().describe('brand wordmark + tagline composited over the closing seconds. DEFAULT FALSE — set true ONLY when the user asks for branding on the close'),
18669
18710
  textStyle: TEXT_STYLE_Z.optional().describe('THE LOOK of captions and the end card, only with captions or endCard and only when the user described one: the look in WORDS ("chunky yellow comic letters, purple outline"), a preset (editorial: big serif title + small italic line; bold: condensed caps, outline; minimal; handwritten; boxed; pill, the default), or fields. "TITLE · small line" puts the part after the dot on a second line.'),
18670
18711
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
18671
- dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) and the exact credits the real render reserves, WITHOUT submitting a render — free, nothing charged'),
18712
+ dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) and the estimated credits the real render reserves, WITHOUT submitting a render — free, nothing charged'),
18672
18713
  allowGenericProduct: z.boolean().optional().describe('proceed even though this brand has NO product photo on file and the ad features a product — the packaging will be INVENTED. Only pass true after telling the user that and hearing they are fine with a generic stand-in'),
18714
+ inserts: INSERTS_Z.optional().describe("the user's OWN pictures (screenshots, a page, a chart) laid FULL-SCREEN over the finished video at these seconds, its sound running underneath, by the same job right after the render (about 2 credits more). Omit to use the ones plan_ad was given (creative.inserts); [] renders without them. Passing them here when plan_ad never saw them still keeps the shots under them free of screens, but plan_ad with inserts writes the board around them."),
18673
18715
  },
18674
18716
  outputSchema: {
18675
18717
  ...JOB_OUT,
@@ -18691,6 +18733,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18691
18733
  const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
18692
18734
  if (_len) a = { ...a, durationSeconds: _len };
18693
18735
  const _clampNote = (_askedLen && _askedLen !== _len) ? `\n(${_askedLen}s is outside the supported 4–180s range — rendered at ${_len}s.)` : '';
18736
+ if (Array.isArray(a.inserts)) a = { ...a, inserts: await insertsResolved(a.inserts) }; // the user's pictures, resolved before anything is billed
18694
18737
  const { input, jobType, notes, needsProductPhoto, creator, ownRefNotice, quote, brandPhoto } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
18695
18738
  // THE CAST IS THE READ-BACK, NEVER THE ASK. `creator` is the row the SERVER resolved out of this workspace's own
18696
18739
  // roster; a half-remembered name that matched nobody, matched two people, or belongs to an unconsented real
@@ -18704,7 +18747,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18704
18747
  // belted input the job would queue, so the credits printed here are what the render reserves, and the model/length
18705
18748
  // are the ones it prices (the belt may re-route an auto pick). A refusal the real run would throw is said as one.
18706
18749
  const _q = quote && typeof quote === 'object' ? quote : null;
18707
- const _qLine = _q ? (Number.isFinite(+_q.credits) && _q.credits != null ? `\nCOST: ${_q.credits} credits reserved when it renders (${_q.label || _q.model}, ${_q.durationSeconds}s${_q.resolution ? ` at ${_q.resolution}` : ''}); the charge settles to the measured cost and never above that hold.` : _q.refused ? `\nThe real render would be refused before it starts: ${_q.refused}` : '') : '\nCOST: not quoted for this render; hermoso_capabilities lists per-model credit costs.';
18750
+ const _qLine = _q ? (Number.isFinite(+_q.credits) && _q.credits != null ? `\nESTIMATED COST: about ${_q.credits} credits, reserved when it renders (${_q.label || _q.model}, ${_q.durationSeconds}s${_q.resolution ? ` at ${_q.resolution}` : ''}). ${PRICE_ESTIMATE_LINE}` : _q.refused ? `\nThe real render would be refused before it starts: ${_q.refused}` : '') : '\nCOST: not quoted for this render; hermoso_capabilities lists per-model credit costs.';
18708
18751
  if (a.dryRun) return ok(`DRY RUN — routing decision (no job submitted, nothing charged): jobType=${jobType || 'video'}, model=${_q?.model || input.model}, durationSeconds=${_q?.durationSeconds || input.durationSeconds}${Array.isArray(input.scenes) ? `, acts=[${(_q?.acts || input.scenes.map(s => Math.round(s.seconds * 10) / 10)).join(', ')}]s` : ' (single pass)'}${input.modelExplicit ? ', modelExplicit (ask-don’t-swap)' : ''}.${_qLine}${_clampNote}${_castLine}\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', ...(_q && _q.credits != null ? { credits: _q.credits } : {}), ...(_q ? { quote: _q } : {}), ...(creator ? { creator } : {}), input });
18709
18752
  // ASK BEFORE SPENDING (2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
18710
18753
  // spent"). `notes` alone was not enough here: on the real path it only reaches the model AFTER renderJob has
@@ -18723,11 +18766,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
18723
18766
  // catalog pick, the cast) were dropped, and get_job cannot say them later: they are not on the job. `brandPhoto`
18724
18767
  // rides structuredContent on both paths, so a surface shows the left-out photo as its own line.
18725
18768
  const _bp = brandPhoto && typeof brandPhoto === 'object' && brandPhoto.note ? brandPhoto : null;
18726
- if (r?.stillRendering) return ok(`${stillMsg(r)}${_bp ? `\nALSO TELL THE USER, in one short line: ${_bp.note}` : ''}${_clampNote}${_castLine}${notes ? `\nNOTES: ${String(notes).trim()}` : ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}), ...(notes ? { notes: String(notes).trim() } : {}) });
18769
+ if (r?.stillRendering) return ok(`${stillMsg(r)}${insertsLine(input?.layInserts, 'are laid in by this same job before it is delivered, so the file get_job returns already has them')}${_bp ? `\nALSO TELL THE USER, in one short line: ${_bp.note}` : ''}${_clampNote}${_castLine}${notes ? `\nNOTES: ${String(notes).trim()}` : ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}), ...(notes ? { notes: String(notes).trim() } : {}) });
18727
18770
  // FOUNDATIONS ONLY (foundations:'choose'): no video was rendered — hand back the pictures to pick from.
18728
18771
  const _fd = renderPayload(r);
18729
18772
  if (_fd && _fd.foundationsOnly) { const F = _fd.foundations || {}; return ok(`Foundations painted (${_fd.boardCreditsUsed || 0} credits, billed as images) [job ${r.jobId}]:\n${Object.entries(F).filter(([, v]) => typeof v === 'string').map(([k, v]) => `• ${k}: ${abs(v)}`).join('\n')}\n${_fd.note || ''}`, { ...r, foundations: F }); }
18730
- return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_clampNote}${switchNote(r)}${_castLine}${Array.isArray(_fd?.boards) && _fd.boards.length ? `\nStoryboard: ${_fd.boards.map((u) => abs(u)).join(' , ')}${_fd.boardCreditsUsed ? ` (${_fd.boardCreditsUsed} credits of image paints)` : ''}` : ''}\n${notes || ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}) });
18773
+ return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_fd?.inserts?.laid ? insertsLine(_fd.inserts.windows) : ''}${_fd?.inserts?.failed && _fd.note ? `\nTELL THE USER: ${_fd.note}` : ''}${_clampNote}${switchNote(r)}${_castLine}${Array.isArray(_fd?.boards) && _fd.boards.length ? `\nStoryboard: ${_fd.boards.map((u) => abs(u)).join(' , ')}${_fd.boardCreditsUsed ? ` (${_fd.boardCreditsUsed} credits of image paints)` : ''}` : ''}\n${notes || ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}) });
18731
18774
  }));
18732
18775
 
18733
18776
 
@@ -18835,7 +18878,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18835
18878
  brandName: z.string().optional().describe('override the workspace brand name'),
18836
18879
  domain: z.string().optional().describe('override the brand website'),
18837
18880
  accent: z.string().optional().describe('override the brand accent hex'),
18838
- dryRun: z.boolean().optional().describe('true = return the exact credits this edit reserves and run nothing'),
18881
+ dryRun: z.boolean().optional().describe('true = return the estimated credits this edit reserves and run nothing'),
18839
18882
  },
18840
18883
  outputSchema: { ...JOB_OUT },
18841
18884
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -18964,7 +19007,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18964
19007
  prompt: z.string().describe('what the replacement footage should show — describe the shot, matching the master\'s style'),
18965
19008
  refImage: z.string().optional().describe('optional product/style anchor image URL'),
18966
19009
  speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
18967
- dryRun: z.boolean().optional().describe('true = return the exact credits this fix reserves and render nothing'),
19010
+ dryRun: z.boolean().optional().describe('true = return the estimated credits this fix reserves and render nothing'),
18968
19011
  },
18969
19012
  outputSchema: { ...JOB_OUT },
18970
19013
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -19067,7 +19110,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19067
19110
  upscale: z.number().optional().describe("optional FINAL upscale — 2 doubles each side, 4 quadruples. Captions and the end card are burned BEFORE it so they upscale with the frame. It is priced BY LENGTH and it is the expensive part — several times the cost of rendering the film itself. hermoso_capabilities reports the exact figures per length as explainerUpscaleCredits. Never turn it on unasked: quote the number and let the user choose."),
19068
19111
  endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
19069
19112
  brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
19070
- dryRun: z.boolean().optional().describe('true = return the exact credits this explainer reserves (its own pricing, stopped at the hold) and render nothing. Quote it before running one; try frameDensity lean or minimal when the balance is short.'),
19113
+ dryRun: z.boolean().optional().describe('true = return the estimated credits this explainer reserves (its own pricing, stopped at the hold) and render nothing. Quote it before running one; try frameDensity lean or minimal when the balance is short.'),
19071
19114
  },
19072
19115
  outputSchema: { ...JOB_OUT },
19073
19116
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -19329,7 +19372,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19329
19372
  // `structuredContent` from an error, and `window.openai.callTool` REJECTS on one, so the widget would be back to
19330
19373
  // guessing from prose. The sentence below is unambiguous for the model; `status:'not_found'` is unambiguous for
19331
19374
  // the card; and both say STOP rather than leaving a poller to grind for six minutes.
19332
- const j = await getJob(id).catch((e) => { if (Number(e?.status) === 404) return null; throw e; });
19375
+ const j = await getJobSettled(id); // a 404 is re-read for a few seconds first: a revision taking over mid-render has not yet seen the job (getJobSettled)
19333
19376
  if (!j) return ok(`No job with id "${id}". Nothing is rendering under that id — it does not exist on this workspace (it may have been mistyped, or it may belong to another account or brand). STOP POLLING IT. Do NOT re-run the render on the strength of this: call list_jobs to see this workspace's recent jobs and their real ids.`, { id, status: 'not_found', progress: 0, error: `No job with id "${id}" on this workspace.`, url: null });
19334
19377
  const res = jobResult(j);
19335
19378
  const url = abs(res?.video || res?.image || res?.url);
@@ -19354,7 +19397,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19354
19397
  // one an OpenAI reviewer sees. Still cheap for the model to read past: five short scalars.
19355
19398
  const rawJ = j.result || j.raw || {};
19356
19399
  const wireText = `${text}\n${JSON.stringify({ id: j.id, status: j.status, progress: j.progress ?? null, url: url || null, error: j.error || null,
19357
- model: j.model || rawJ.model || null, poster: abs(rawJ.poster) || null, creditsUsed: j.creditsUsed ?? null,
19400
+ model: j.model || rawJ.model || null, poster: abs(rawJ.poster) || null, creditsUsed: j.chargedCredits ?? rawJ.creditsUsed ?? null,
19358
19401
  deliveredWidth: rawJ.deliveredWidth ?? null, deliveredHeight: rawJ.deliveredHeight ?? null })}`;
19359
19402
  if (j.status === 'done' && res?.video && res?.review) { // a finished TIMELINE edit: its seam review + frames, as edit_timeline would have shown them
19360
19403
  const out = await okVideo(`${wireText}${seamsText(res)}${timelineReviewText(res.review)}`, { ...j, url });
@@ -19363,7 +19406,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
19363
19406
  return out;
19364
19407
  }
19365
19408
  const seamLine = seamsText(res); // a timeline / join's measured seams + intro budget ('' for any other video)
19366
- if (j.status === 'done' && res?.video) return okVideo(wireText + seamLine, { ...j, url }); // resumed video → same inline poster as a direct return
19409
+ // a render that laid the user's pictures in (render_ad inserts): say they are in it, or that they could not be
19410
+ const insLine = res?.inserts?.laid ? `\nCut-ins: ${res.inserts.laid === 1 ? 'the user\'s picture is' : `${res.inserts.laid} of the user's pictures are`} laid in full-screen at ${(res.inserts.windows || []).map((x) => `${x.start}–${x.end}s`).join(', ')}.` : (res?.inserts?.failed && res.note ? `\nTELL THE USER: ${res.note}` : '');
19411
+ if (j.status === 'done' && res?.video) return okVideo(wireText + seamLine + insLine, { ...j, url }); // resumed video → same inline poster as a direct return
19367
19412
  if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text: wireText }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
19368
19413
  return ok(wireText, { ...j, url });
19369
19414
  }));
@@ -21252,22 +21297,23 @@ function memoryNoteVerdict(text) {
21252
21297
 
21253
21298
  server.registerTool('list_library', {
21254
21299
  title: 'List library',
21255
- description: "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's kind, model, and age. Free, read-only.",
21300
+ description: "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
21256
21301
  inputSchema: {
21257
21302
  kind: z.enum(['image', 'video', 'all']).optional().describe("filter by asset kind (default 'all')"),
21258
21303
  limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
21259
21304
  },
21260
- outputSchema: { assets: z.array(z.object({ url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
21305
+ outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
21261
21306
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
21262
21307
  }, wrap(async (a) => {
21263
21308
  let list = await readStore('heist.assets.v1'); // via /api/store/bootstrap — there is no GET /api/store/:key route
21264
21309
  if (!Array.isArray(list)) list = [];
21265
21310
  const kind = a.kind && a.kind !== 'all' ? a.kind : null;
21266
21311
  const lim = Math.min(60, Math.max(1, +a.limit || 20));
21267
- const assets = list.filter(x => x && x.url && (!kind || x.kind === kind)).slice(0, lim)
21268
- .map(x => ({ url: abs(x.url), kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
21312
+ // id: L0, L1… over the whole Library newest first (a kind filter does not renumber), the id plan_ad / render_ad `inserts` take
21313
+ const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i })).filter(({ x }) => !kind || x.kind === kind).slice(0, lim)
21314
+ .map(({ x, id }) => ({ id, url: abs(x.url), kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
21269
21315
  if (!assets.length) return ok('The Library is empty for this workspace — render something first.', { assets: [] });
21270
- return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x, i) => ` ${i + 1}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
21316
+ return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
21271
21317
  }));
21272
21318
 
21273
21319
  server.registerTool('fetch_asset', {
@@ -21410,7 +21456,7 @@ function memoryNoteVerdict(text) {
21410
21456
  inputSchema: {
21411
21457
  draft: z.string().optional().describe('the draft id from the render result (draft.id)'),
21412
21458
  video: z.string().optional().describe('or: the URL of the 480p draft video'),
21413
- dryRun: z.boolean().optional().describe('true = return the exact credits and render nothing'),
21459
+ dryRun: z.boolean().optional().describe('true = return the estimated credits and render nothing'),
21414
21460
  },
21415
21461
  outputSchema: { ...JOB_OUT },
21416
21462
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.419",
3
+ "version": "0.1.422",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 912 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, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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",