hermoso 0.1.418 → 0.1.420
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/tools.mjs +33 -25
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
|
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[].'),
|
|
@@ -4338,9 +4347,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4338
4347
|
const _rm = d.remake; // the remake engines (recast_motion / restyle_video / edit_video engine), priced live
|
|
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
|
-
const _jeLine = _je && _je.defaults ? `\
|
|
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);
|
|
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.` : '';
|
|
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
|
|
@@ -18424,7 +18432,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18424
18432
|
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
18433
|
const img = await imageBlock(r.url);
|
|
18426
18434
|
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 } : {}) } };
|
|
18435
|
+
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
18436
|
}
|
|
18429
18437
|
const d = await apiPost('/api/generate/image', _imgBody); // explicit boolean so the server's saved-brand hydration default is unambiguous
|
|
18430
18438
|
const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
|
|
@@ -18451,7 +18459,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18451
18459
|
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
18460
|
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
18461
|
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
|
|
18462
|
+
dryRun: z.boolean().optional().describe('true = return the estimated credits this edit reserves and render nothing'),
|
|
18455
18463
|
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
18464
|
},
|
|
18457
18465
|
outputSchema: {
|
|
@@ -18654,7 +18662,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18654
18662
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
18655
18663
|
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),
|
|
18656
18664
|
faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_DESC),
|
|
18657
|
-
model: z.string().optional().describe('video model id from hermoso_capabilities (default: the
|
|
18665
|
+
model: z.string().optional().describe('video model id from hermoso_capabilities (default: the engine the router picks for the plan: seedance-2.5 for every ad, a person talking to camera included). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
|
|
18658
18666
|
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.'),
|
|
18659
18667
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
18660
18668
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
|
|
@@ -18668,7 +18676,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18668
18676
|
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
18677
|
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
18678
|
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
|
|
18679
|
+
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
18680
|
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'),
|
|
18673
18681
|
},
|
|
18674
18682
|
outputSchema: {
|
|
@@ -18704,7 +18712,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18704
18712
|
// belted input the job would queue, so the credits printed here are what the render reserves, and the model/length
|
|
18705
18713
|
// 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
18714
|
const _q = quote && typeof quote === 'object' ? quote : null;
|
|
18707
|
-
const _qLine = _q ? (Number.isFinite(+_q.credits) && _q.credits != null ? `\
|
|
18715
|
+
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
18716
|
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
18717
|
// ASK BEFORE SPENDING (2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
|
|
18710
18718
|
// spent"). `notes` alone was not enough here: on the real path it only reaches the model AFTER renderJob has
|
|
@@ -18835,7 +18843,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18835
18843
|
brandName: z.string().optional().describe('override the workspace brand name'),
|
|
18836
18844
|
domain: z.string().optional().describe('override the brand website'),
|
|
18837
18845
|
accent: z.string().optional().describe('override the brand accent hex'),
|
|
18838
|
-
dryRun: z.boolean().optional().describe('true = return the
|
|
18846
|
+
dryRun: z.boolean().optional().describe('true = return the estimated credits this edit reserves and run nothing'),
|
|
18839
18847
|
},
|
|
18840
18848
|
outputSchema: { ...JOB_OUT },
|
|
18841
18849
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -18964,7 +18972,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18964
18972
|
prompt: z.string().describe('what the replacement footage should show — describe the shot, matching the master\'s style'),
|
|
18965
18973
|
refImage: z.string().optional().describe('optional product/style anchor image URL'),
|
|
18966
18974
|
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
|
|
18975
|
+
dryRun: z.boolean().optional().describe('true = return the estimated credits this fix reserves and render nothing'),
|
|
18968
18976
|
},
|
|
18969
18977
|
outputSchema: { ...JOB_OUT },
|
|
18970
18978
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -19067,7 +19075,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19067
19075
|
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
19076
|
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
|
|
19069
19077
|
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
|
|
19078
|
+
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
19079
|
},
|
|
19072
19080
|
outputSchema: { ...JOB_OUT },
|
|
19073
19081
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -19143,7 +19151,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19143
19151
|
interactionId: z.string().optional().describe('with extend:true on an Omni model: the interactionId returned by an earlier render on that model — continues it from its own stored context instead of re-uploading refVideo.'),
|
|
19144
19152
|
durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the CHOSEN model’s listed durations from hermoso_capabilities (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 40s = 15+15+10).'),
|
|
19145
19153
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
19146
|
-
model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick
|
|
19154
|
+
model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick; its default per job (jobEngines in hermoso_capabilities, from watched side-by-side tests) is seedance-2.5 for product, lifestyle, a person talking to camera and 30s single takes (cheaper engines broke the product there). A model the user picks is kept; never switch it'),
|
|
19147
19155
|
faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_DESC),
|
|
19148
19156
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render. Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
|
|
19149
19157
|
cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage, spelled exactly as the enum gives it: an orbit (a quarter turn, the default), orbiting left, a half turn or a full turntable, a rise, a crane up, a push in, a pull back, or a reveal. Only the camera-controls model renders one (minimax-h3-max-camera, listed with its moves in hermoso_capabilities): pass it with model omitted and that model is picked, or with that model named; any other named model is refused by name with nothing charged. Needs refImage.'),
|
|
@@ -19354,7 +19362,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19354
19362
|
// one an OpenAI reviewer sees. Still cheap for the model to read past: five short scalars.
|
|
19355
19363
|
const rawJ = j.result || j.raw || {};
|
|
19356
19364
|
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,
|
|
19365
|
+
model: j.model || rawJ.model || null, poster: abs(rawJ.poster) || null, creditsUsed: j.chargedCredits ?? rawJ.creditsUsed ?? null,
|
|
19358
19366
|
deliveredWidth: rawJ.deliveredWidth ?? null, deliveredHeight: rawJ.deliveredHeight ?? null })}`;
|
|
19359
19367
|
if (j.status === 'done' && res?.video && res?.review) { // a finished TIMELINE edit: its seam review + frames, as edit_timeline would have shown them
|
|
19360
19368
|
const out = await okVideo(`${wireText}${seamsText(res)}${timelineReviewText(res.review)}`, { ...j, url });
|
|
@@ -21410,7 +21418,7 @@ function memoryNoteVerdict(text) {
|
|
|
21410
21418
|
inputSchema: {
|
|
21411
21419
|
draft: z.string().optional().describe('the draft id from the render result (draft.id)'),
|
|
21412
21420
|
video: z.string().optional().describe('or: the URL of the 480p draft video'),
|
|
21413
|
-
dryRun: z.boolean().optional().describe('true = return the
|
|
21421
|
+
dryRun: z.boolean().optional().describe('true = return the estimated credits and render nothing'),
|
|
21414
21422
|
},
|
|
21415
21423
|
outputSchema: { ...JOB_OUT },
|
|
21416
21424
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -21432,7 +21440,7 @@ function memoryNoteVerdict(text) {
|
|
|
21432
21440
|
// described look) runs as a video edit: same engines, same price, same free preservation read-back as edit_video.
|
|
21433
21441
|
server.registerTool('restyle_video', {
|
|
21434
21442
|
title: 'Restyle a video',
|
|
21435
|
-
description: "RESTYLE a clip into a new LOOK: every frame redrawn, shots, motion, camera, framing, timing and sound kept. Looks: `Restyle looks` in hermoso_capabilities (claymation, knitted yarn, cel-shaded CG anime…), your own words, or a look BUILT FROM YOUR IMAGES (styleImages); characters draws your own characters in. engine auto (default) picks by what is in the clip: a real-looking person -> kling (Kling O3 Edit, 3-15s); none -> wan (Wan 3.0, recommended, ≤15s) else seedance (Seedance 2.5, 4-30s); wan-prime = Wan 3.0 Prime by name (
|
|
21443
|
+
description: "RESTYLE a clip into a new LOOK: every frame redrawn, shots, motion, camera, framing, timing and sound kept. Looks: `Restyle looks` in hermoso_capabilities (claymation, knitted yarn, cel-shaded CG anime…), your own words, or a look BUILT FROM YOUR IMAGES (styleImages); characters draws your own characters in. engine auto (default) picks by what is in the clip: a real-looking person -> kling (Kling O3 Edit, 3-15s); none -> wan (Wan 3.0, recommended, ≤15s) else seedance (Seedance 2.5, 4-30s); wan-prime = Wan 3.0 Prime by name (costs more). Paid; dryRun quotes it. One change only: edit_video.",
|
|
21436
21444
|
inputSchema: {
|
|
21437
21445
|
video: z.string().describe('the source video URL (a render, job result or list_library)'),
|
|
21438
21446
|
style: z.string().optional().describe('a look id from hermoso_capabilities (e.g. claymation, knitted-yarn, cel-shaded-anime-cg)'),
|
|
@@ -21609,7 +21617,7 @@ function memoryNoteVerdict(text) {
|
|
|
21609
21617
|
|
|
21610
21618
|
server.registerTool('recast_motion', {
|
|
21611
21619
|
title: 'Recast motion',
|
|
21612
|
-
description: "Motion transfer: re-perform a clip's motion, camera and timing with YOUR character, product, clothes or place; sound kept. image (+ images, @Image1, @Image2… in prompt) = who and what. engine auto (default), a person clip recast with people: one photo (talking, dance or full-body) or an AI person made from words, ≤15s -> wan (on the beat, keeps set and framing, follows every blink and mouth shape); several photos (one per person) or >15s -> h3 (H3 Max Recast: ≤4 photos, one per person, 5-30s, no shot over 15s, keeps set, light, cuts; engine h3 picks it by name); otherwise seedance (Seedance 2.5, ≤9 pictures, 4-30s, keeps set and beats; a person clip only if our render or faceRoute, else wan/kling). kling = Kling Motion Control (ONE picture, its background becomes the set, 3-30s); wan = Wan 3.0, recommended (≤9 pictures, keeps the set, ≤15s); wan-prime = Wan 3.0 Prime by name (
|
|
21620
|
+
description: "Motion transfer: re-perform a clip's motion, camera and timing with YOUR character, product, clothes or place; sound kept. image (+ images, @Image1, @Image2… in prompt) = who and what. engine auto (default), a person clip recast with people: one photo (talking, dance or full-body) or an AI person made from words, ≤15s -> wan (on the beat, keeps set and framing, follows every blink and mouth shape); several photos (one per person) or >15s -> h3 (H3 Max Recast: ≤4 photos, one per person, 5-30s, no shot over 15s, keeps set, light, cuts; engine h3 picks it by name); otherwise seedance (Seedance 2.5, ≤9 pictures, 4-30s, keeps set and beats; a person clip only if our render or faceRoute, else wan/kling). kling = Kling Motion Control (ONE picture, its background becomes the set, 3-30s); wan = Wan 3.0, recommended (≤9 pictures, keeps the set, ≤15s); wan-prime = Wan 3.0 Prime by name (costs more). voice/language: the new performer gets a new voice (same timing) or speaks another language (lip-synced), in the same job and price. Billed per output second, ~5 min per 5s; dryRun quotes.",
|
|
21613
21621
|
inputSchema: {
|
|
21614
21622
|
image: z.string().optional().describe('who performs it: the actor/character image URL (@Image1). Required on kling. No one yet: generate_image a portrait of an AI person from words (no reference photo) and pass its url, or a saved / preset creator’s portrait (list_creators). ' + LIKENESS_TERMS),
|
|
21615
21623
|
images: z.array(z.string()).max(8).optional().describe('seedance/wan: more pictures, @Image2…: a character, product, outfit or place'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.420",
|
|
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",
|