hermoso 0.1.489 → 0.1.491
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/tools.mjs +176 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate image & video ad
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**1108 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**No markup on models.** Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
|
|
@@ -242,7 +242,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
242
242
|
|
|
243
243
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
244
244
|
|
|
245
|
-
### What the
|
|
245
|
+
### What the 1108 tools cover
|
|
246
246
|
|
|
247
247
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
248
248
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/tools.mjs
CHANGED
|
@@ -7083,6 +7083,28 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7083
7083
|
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), verified: z.boolean().optional(), deleted: z.boolean().optional(), productGroup: z.any().optional(), note: z.string().optional() },
|
|
7084
7084
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7085
7085
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/product-group', a); return ok(d.note, d); }));
|
|
7086
|
+
// Gap rows 9, 12, 13 (2026-11-01): board sections, followers and websites, on the organic connection.
|
|
7087
|
+
server.registerTool('list_pinterest_board_sections', {
|
|
7088
|
+
title: 'List Pinterest board sections',
|
|
7089
|
+
description: 'List the SECTIONS of a Pinterest board with their ids (the boardSectionId post_to_pinterest and save_pinterest_pin take), or with sectionId the Pins in one section. Read-only, 0 credits.',
|
|
7090
|
+
inputSchema: { boardId: z.string().describe('from list_pinterest_boards'), sectionId: z.string().optional().describe('read the Pins in this section'), bookmark: z.string().optional().describe('the next page, from the last reply') },
|
|
7091
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), sections: z.array(z.any()).optional(), pins: z.array(z.any()).optional(), bookmark: z.string().nullable().optional(), note: z.string().optional() },
|
|
7092
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
7093
|
+
}, wrap(async (a) => { const d = await apiGet('/api/pinterest/board-sections', a); return ok(`${d.note}\n${JSON.stringify(d.sections || d.pins || []).slice(0, 5000)}`, d); }));
|
|
7094
|
+
server.registerTool('manage_pinterest_board_section', {
|
|
7095
|
+
title: 'Create, rename or delete a Pinterest board section',
|
|
7096
|
+
description: 'Create, rename or delete a section of a Pinterest board. action:"create" needs name; "rename" needs sectionId and name; "delete" needs sectionId and confirm:true (without confirm it reports what would go). Read back from Pinterest. 0 credits.',
|
|
7097
|
+
inputSchema: { boardId: z.string().describe('from list_pinterest_boards'), action: z.enum(['create', 'rename', 'delete']).optional().describe('default create'), sectionId: z.string().optional().describe('rename, delete: from list_pinterest_board_sections'), name: z.string().optional().describe('create, rename: up to 180 characters'), confirm: z.boolean().optional().describe('delete: required true') },
|
|
7098
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), verified: z.boolean().optional(), deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), section: z.any().optional(), note: z.string().optional() },
|
|
7099
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7100
|
+
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/board-section', a); return ok(d.note, d); }));
|
|
7101
|
+
server.registerTool('pinterest_account_lists', {
|
|
7102
|
+
title: 'Pinterest followers, following and websites',
|
|
7103
|
+
description: 'Read lists on the connected Pinterest account. what: followers, following, following_boards, websites (each site and whether it is claimed; a site that is not claimed explains a refused link) or website_verification (the code to put on a site to claim it). Claiming itself happens in Pinterest. Read-only, 0 credits.',
|
|
7104
|
+
inputSchema: { what: z.enum(['followers', 'following', 'following_boards', 'websites', 'website_verification']).describe('which list'), limit: z.number().optional().describe('up to 250 per page'), bookmark: z.string().optional().describe('the next page, from the last reply') },
|
|
7105
|
+
outputSchema: { ok: z.boolean().optional(), what: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), verification: z.any().optional(), bookmark: z.string().nullable().optional(), note: z.string().optional() },
|
|
7106
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
7107
|
+
}, wrap(async (a) => { const d = await apiGet('/api/pinterest/account-lists', a); return ok(`${d.note}${d.rows ? `\n${JSON.stringify(d.rows).slice(0, 5000)}` : ''}`, d); }));
|
|
7086
7108
|
server.registerTool('create_pinterest_board', {
|
|
7087
7109
|
title: 'Create a Pinterest board',
|
|
7088
7110
|
description: "Create a board on the connected Pinterest account. Needed because a Pin cannot exist without a board, and a NEW Pinterest business account has none — if list_pinterest_boards comes back empty, make one here rather than telling the user you can't pin. Boards are PUBLIC unless you pass privacy 'SECRET'; a Pin on a secret board is invisible to everyone, so only choose that if the user asked for it.",
|
|
@@ -15752,6 +15774,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15752
15774
|
objective: z.enum(['AWARENESS', 'CONSIDERATION', 'WEB_CONVERSION', 'CATALOG_SALES', 'VIDEO_COMPLETION', 'APP_INSTALL', 'SALES', 'LEADS', 'CTV_CONSIDERATION']).describe('Pinterest requires an objective and will not guess one'),
|
|
15753
15775
|
dailyBudget: z.number().optional().describe('daily cap in the ad account’s currency'),
|
|
15754
15776
|
lifetimeBudget: z.number().optional().describe('lifetime cap instead of a daily one — Pinterest then requires endTime'),
|
|
15777
|
+
isPerformancePlus: z.boolean().optional().describe('Pinterest Performance+ on this campaign; with no lifetime budget Pinterest also requires isFlexibleDailyBudgets:true'),
|
|
15778
|
+
isAutomatedCampaign: z.boolean().optional().describe('Pinterest automated campaign'),
|
|
15779
|
+
isTopOfSearch: z.boolean().optional().describe('top-of-search campaign'),
|
|
15780
|
+
isFlexibleDailyBudgets: z.boolean().optional().describe('flexible daily budgets'),
|
|
15781
|
+
isLtvOptimized: z.boolean().optional().describe('optimize for long-term value'),
|
|
15782
|
+
isCampaignBudgetOptimization: z.boolean().optional().describe('default true at Pinterest: the budget sits on the campaign'),
|
|
15783
|
+
intendedPromotionType: z.enum(['LEGACY', 'STANDARD_AD', 'CATALOG']).optional().describe('the kind of ads it will carry'),
|
|
15784
|
+
bidOptions: z.record(z.any()).optional().describe('Pinterest bid_options object: multipliers by age, gender, placement, audience or frequency'),
|
|
15755
15785
|
startTime: z.number().optional().describe('Unix timestamp in SECONDS'),
|
|
15756
15786
|
endTime: z.number().optional().describe('Unix timestamp in SECONDS'),
|
|
15757
15787
|
adGroup: z.object({ ...pinAdGroupShape, name: z.string().optional().describe('ad group name'), ad: z.object(pinAdShape).optional().describe('the ad created in this ad group; promotes an existing Pin') }).optional().describe('build the serving tree in the same call — WITHOUT this you get a campaign shell that can never show an ad'),
|
|
@@ -16009,6 +16039,68 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16009
16039
|
outputSchema: { ok: z.boolean().optional(), operation: z.string().optional(), sent: z.number().optional(), before: z.any().optional(), after: z.any().optional(), note: z.string().optional() },
|
|
16010
16040
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
16011
16041
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/customer-list/update', a); return ok(d.note, d); }));
|
|
16042
|
+
// Gap rows 6-15 (2026-11-01): keywords, audience size, bulk, and the ad-account extras. lib/pinterest-phase2.mjs reads each off Pinterest's own spec.
|
|
16043
|
+
const pinKeywordList = z.array(z.object({ value: z.string().describe('the search words, up to 120 characters'), matchType: z.enum(['BROAD', 'PHRASE', 'EXACT', 'EXACT_NEGATIVE', 'PHRASE_NEGATIVE']).optional().describe('default BROAD; a campaign takes only the _NEGATIVE types') }));
|
|
16044
|
+
server.registerTool('list_pinterest_ads_keywords', {
|
|
16045
|
+
title: 'List Pinterest ad keywords',
|
|
16046
|
+
description: 'Read the KEYWORDS on a Pinterest ad group or campaign: the searches a SEARCH placement serves on, each with its match type. Pass adGroupId or campaignId (neither lists the account’s own negative keywords). Read-only, 0 credits.',
|
|
16047
|
+
inputSchema: { adAccountId: pinAcct, adGroupId: z.string().optional().describe('the ad group'), campaignId: z.string().optional().describe('the campaign'), matchTypes: z.array(z.enum(['BROAD', 'PHRASE', 'EXACT', 'EXACT_NEGATIVE', 'PHRASE_NEGATIVE'])).optional().describe('only these match types'), bookmark: z.string().optional().describe('the next page, from the last reply') },
|
|
16048
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), keywords: z.array(z.any()).optional(), bookmark: z.string().nullable().optional(), note: z.string().optional() },
|
|
16049
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
16050
|
+
}, wrap(async (a) => { const d = await apiGet('/api/pinterest/ads-keywords', { adAccountId: a.adAccountId, adGroupId: a.adGroupId, campaignId: a.campaignId, matchTypes: (a.matchTypes || []).join(',') || undefined, bookmark: a.bookmark }); return ok(`${d.note}\n${JSON.stringify(d.keywords || []).slice(0, 5000)}`, d); }));
|
|
16051
|
+
server.registerTool('manage_pinterest_ads_keywords', {
|
|
16052
|
+
title: 'Add or archive Pinterest ad keywords',
|
|
16053
|
+
description: 'Add keywords to a Pinterest ad group, or archive them. action:"add" needs parentId (an ad group; a campaign takes only the _NEGATIVE match types) and keywords; Pinterest’s per-keyword refusals are reported. action:"archive" needs keywordIds; archiving is how Pinterest deletes a keyword. Read back from Pinterest. pinterest_ads_keyword_ideas finds candidates. 0 credits.',
|
|
16054
|
+
inputSchema: { adAccountId: pinAcct, action: z.enum(['add', 'archive']).optional().describe('default add'), parentId: z.string().optional().describe('add: the ad group, or a campaign for negative keywords'), keywords: pinKeywordList.optional().describe('add: the keywords'), keywordIds: z.array(z.string()).optional().describe('archive: ids from list_pinterest_ads_keywords') },
|
|
16055
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), verified: z.boolean().optional(), added: z.array(z.any()).optional(), refused: z.array(z.any()).optional(), archived: z.number().optional(), note: z.string().optional() },
|
|
16056
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16057
|
+
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-keywords', a); return ok(d.note, d); }));
|
|
16058
|
+
server.registerTool('pinterest_ads_audience_size', {
|
|
16059
|
+
title: 'Pinterest audience size',
|
|
16060
|
+
description: 'Estimate how many people a Pinterest ad group’s targeting can reach per month, before spending. Pass targetingSpec (e.g. {"GEO":["US"],"AGE_BUCKET":["25-34"]}), keywords and/or productGroupIds. A range from Pinterest’s historical data, not a promise. Read-only, 0 credits.',
|
|
16061
|
+
inputSchema: { adAccountId: pinAcct, targetingSpec: z.record(z.any()).optional().describe('Pinterest targeting object, the same one an ad group takes'), keywords: pinKeywordList.optional().describe('keywords to size'), placementGroup: z.enum(['ALL', 'SEARCH', 'BROWSE', 'OTHER']).optional().describe('default ALL'), autoTargetingEnabled: z.boolean().optional().describe('default true at Pinterest'), creativeTypes: z.array(z.string()).optional().describe('e.g. REGULAR, VIDEO, SHOPPING'), productGroupIds: z.array(z.string()).optional().describe('product groups to size') },
|
|
16062
|
+
outputSchema: { ok: z.boolean().optional(), lower: z.number().nullable().optional(), upper: z.number().nullable().optional(), note: z.string().optional() },
|
|
16063
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
16064
|
+
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-audience-size', a); return ok(d.note, d); }));
|
|
16065
|
+
server.registerTool('pinterest_ads_bulk', {
|
|
16066
|
+
title: 'Pinterest bulk export and upsert',
|
|
16067
|
+
description: 'Pinterest’s BULK lane: many campaigns, ad groups, ads, keywords or product groups in one request. action:"download" exports them; action:"upsert" creates or updates them from create and update, in Pinterest’s own object shapes; action:"status" reads a request by requestId and gives the result file link. Download and upsert run in the background, so read status next. A created object with no status goes in PAUSED; anything that starts delivery or changes a budget or bid is refused until the user agrees and confirm:true is passed. 0 credits.',
|
|
16068
|
+
inputSchema: {
|
|
16069
|
+
adAccountId: pinAcct, action: z.enum(['download', 'upsert', 'status']).optional().describe('default download'), requestId: z.string().optional().describe('status: the id download or upsert answered'),
|
|
16070
|
+
entityTypes: z.array(z.enum(['CAMPAIGN', 'AD_GROUP', 'PRODUCT_GROUP', 'AD', 'KEYWORD', 'LABEL', 'SCHEDULE', 'ENTITY_HISTORY'])).optional().describe('download: up to 6 types'), outputFormat: z.enum(['JSON', 'CSV']).optional().describe('download: default JSON'), entityIds: z.array(z.string()).optional().describe('download: only these ids'), campaignFilter: z.record(z.any()).optional().describe('download: Pinterest’s campaign_filter object'), updatedSince: z.string().optional().describe('download: a Unix timestamp'),
|
|
16071
|
+
create: z.record(z.any()).optional().describe('upsert: objects to create by type, e.g. {"campaigns":[...],"ad_groups":[...],"keywords":[...]}'), update: z.record(z.any()).optional().describe('upsert: objects to change by type'), confirm: z.boolean().optional().describe('upsert: required true when it starts delivery or changes a budget or bid'),
|
|
16072
|
+
},
|
|
16073
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), requestId: z.string().optional(), status: z.string().optional(), resultUrl: z.string().nullable().optional(), counts: z.any().optional(), note: z.string().optional() },
|
|
16074
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16075
|
+
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-bulk', a); return ok(d.note, d); }));
|
|
16076
|
+
server.registerTool('pinterest_ads_account_data', {
|
|
16077
|
+
title: 'Pinterest ad-account data',
|
|
16078
|
+
description: 'Read Pinterest ad-account data. what: conversion_eqs (Event Quality Score of the conversion signals; lookback 1d or 14d, optional sourcePlatform, ingestionSource), metrics_ready_state (whether a date’s numbers are final; date YYYY-MM-DD), report_templates, promotions, targeting_templates. Read-only, 0 credits.',
|
|
16079
|
+
inputSchema: { adAccountId: pinAcct, what: z.enum(['conversion_eqs', 'metrics_ready_state', 'report_templates', 'promotions', 'targeting_templates']).describe('which data'), lookback: z.enum(['1d', '14d']).optional().describe('conversion_eqs: default 14d'), sourcePlatform: z.string().optional().describe('conversion_eqs: e.g. WEB'), ingestionSource: z.string().optional().describe('conversion_eqs: e.g. TAG, CONVERSIONS_API'), date: z.string().optional().describe('metrics_ready_state: YYYY-MM-DD'), searchQuery: z.string().optional().describe('targeting_templates: by name'), includeSizing: z.boolean().optional().describe('targeting_templates: add the audience size'), bookmark: z.string().optional().describe('the next page, from the last reply') },
|
|
16080
|
+
outputSchema: { ok: z.boolean().optional(), what: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), state: z.any().optional(), score: z.any().optional(), bookmark: z.string().nullable().optional(), note: z.string().optional() },
|
|
16081
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
16082
|
+
}, wrap(async (a) => { const d = await apiGet('/api/pinterest/ads-account-data', a); return ok(`${d.note}${d.rows ? `\n${JSON.stringify(d.rows).slice(0, 5000)}` : ''}`, d); }));
|
|
16083
|
+
server.registerTool('manage_pinterest_ads_targeting_template', {
|
|
16084
|
+
title: 'Manage Pinterest targeting templates',
|
|
16085
|
+
description: 'Save a Pinterest targeting set to reuse across ad groups, or remove one. action:"create" needs name and targetingSpec; "remove" needs templateId. Pinterest does not let an ordinary account edit a saved one, so a changed set is a new template. Read back from Pinterest. 0 credits.',
|
|
16086
|
+
inputSchema: { adAccountId: pinAcct, action: z.enum(['create', 'remove']).optional().describe('default create'), templateId: z.string().optional().describe('remove: from pinterest_ads_account_data what:targeting_templates'), name: z.string().optional().describe('create: the template name'), targetingSpec: z.record(z.any()).optional().describe('Pinterest targeting object, e.g. {"GEO":["US"],"AGE_BUCKET":["25-34"]}'), keywords: pinKeywordList.optional().describe('keywords to hold'), placementGroup: z.enum(['ALL', 'SEARCH', 'BROWSE', 'OTHER']).optional().describe('create: default ALL'), autoTargetingEnabled: z.boolean().optional().describe('create: default true at Pinterest') },
|
|
16087
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), verified: z.boolean().optional(), deleted: z.boolean().optional(), template: z.any().optional(), note: z.string().optional() },
|
|
16088
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16089
|
+
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-targeting-template', a); return ok(d.note, d); }));
|
|
16090
|
+
server.registerTool('manage_pinterest_ads_promotion', {
|
|
16091
|
+
title: 'Manage Pinterest promotions',
|
|
16092
|
+
description: 'Create, change or delete a Pinterest PROMOTION, the discount an ad group can show. action:"create" needs title and type (the kind of offer, which sets the text shown) and takes code, startTime, endTime (Unix seconds), templateValues (what fills the text), discountStatus, and any other Pinterest field in fields; "update" needs promotionId; "delete" needs promotionId and confirm:true. Read back from Pinterest. 0 credits.',
|
|
16093
|
+
inputSchema: { adAccountId: pinAcct, action: z.enum(['create', 'update', 'delete']).optional().describe('default create'), promotionId: z.string().optional().describe('update, delete: from pinterest_ads_account_data what:promotions'), title: z.string().optional().describe('create: the internal name'), type: z.enum(['VARIABLE', 'SITEWIDE', 'CHECKOUT', 'SAVE_X_ON_Y', 'BUY_X_GET_Y', 'SPEND_X_SAVE_Y', 'FREE_SHIPPING', 'FREE_SHIPPING_MINIMUM', 'FREE_SHIPPING_WITH_DISCOUNT', 'SITEWIDE_IN_STORES', 'EXTRA_PERCENT_OFF', 'GIFT_WITH_PURCHASE', 'GIFT_WITH_PURCHASE_MINIMUM', 'FIXED', 'PERCENT_OFF_CLEARANCE', 'X_OFF_Y', 'GIFT_WITH_FIRST_PURCHASE', 'BUY_X_GET_ONE_FREE', 'CASH_BACK', 'POINTS_ON_ALL_PURCHASES', 'BONUS', 'POINTS_WITH_PURCHASE', 'CUSTOM']).optional().describe('create: the kind of offer'), templateValues: z.array(z.record(z.any())).optional().describe('what fills the offer text, at most 2, in Pinterest template_values shape'), code: z.string().optional().describe('the code a buyer redeems'), customId: z.string().optional().describe('your own id for it'), discountStatus: z.string().optional().describe('Pinterest’s discount_status value'), startTime: z.number().optional().describe('Unix seconds'), endTime: z.number().optional().describe('Unix seconds'), fields: z.record(z.any()).optional().describe('any other promotion field, by Pinterest’s own name'), confirm: z.boolean().optional().describe('delete: required true') },
|
|
16094
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), verified: z.boolean().optional(), deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), promotion: z.any().optional(), note: z.string().optional() },
|
|
16095
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16096
|
+
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-promotion', a); return ok(d.note, d); }));
|
|
16097
|
+
server.registerTool('run_pinterest_ads_report_template', {
|
|
16098
|
+
title: 'Run a Pinterest report template',
|
|
16099
|
+
description: 'Run a saved Pinterest report template (pinterest_ads_account_data what:report_templates lists them) for an optional date range. It runs in the background and answers a token; pinterest_ads_async_report with that token gives the rows. 0 credits.',
|
|
16100
|
+
inputSchema: { adAccountId: pinAcct, templateId: z.string().describe('the template id'), startDate: z.string().optional().describe('YYYY-MM-DD'), endDate: z.string().optional().describe('YYYY-MM-DD'), granularity: z.enum(['TOTAL', 'DAY', 'HOUR', 'WEEK', 'MONTH']).optional().describe('default the template’s own') },
|
|
16101
|
+
outputSchema: { ok: z.boolean().optional(), token: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
16102
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
16103
|
+
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/ads-report-template', a); return ok(d.note, d); }));
|
|
16012
16104
|
server.registerTool('pinterest_ads_keyword_ideas', {
|
|
16013
16105
|
title: 'Pinterest keyword ideas',
|
|
16014
16106
|
description: 'Pinterest KEYWORD RESEARCH through the Pinterest Ads connection. mode:"related" (default): terms related to each of your terms ("workout" -> "yoga workout", "workout motivation"); mode:"suggested": popular searches that BEGIN with the term; mode:"metrics": Pinterest-wide search volume for up to 2,000 keywords in one country. Feeds ad-group KEYWORD targeting and Pin titles. Read-only, 0 credits.',
|
|
@@ -21905,6 +21997,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21905
21997
|
mood: z.enum(['elegant', 'epic', 'upbeat', 'hype', 'calm', 'tense', 'playful']).optional().describe('music and pace'),
|
|
21906
21998
|
music: z.string().optional().describe("'off' = silent"),
|
|
21907
21999
|
theme: z.enum(['light', 'dark']).optional().describe("logo: dark for a light logo"),
|
|
22000
|
+
energy: z.number().optional().describe('how much the film keeps moving, 0 to 1; 0 films exactly what is written'),
|
|
21908
22001
|
spec: z.object({}).passthrough().optional().describe('the whole film as JSON, from a quote or a saved film; help:true shows the spec language'),
|
|
21909
22002
|
projectUrl: z.string().optional().describe("a past film's url; returns its saved spec to edit and render again"),
|
|
21910
22003
|
help: z.boolean().optional().describe('true returns the spec language and recipes; renders nothing'),
|
|
@@ -21932,7 +22025,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21932
22025
|
let credits = null, previewCredits = null, bpm = null;
|
|
21933
22026
|
if (!spec) {
|
|
21934
22027
|
if (!a.recipe) throw Object.assign(new Error("Pass a recipe ('type', 'logo', 'launch', 'product' or 'social') with words, or a whole spec."), { status: 400, _userInput: true });
|
|
21935
|
-
const d = await apiPost('/api/motion/build', { recipe: a.recipe, size: a.size || null, seconds: a.seconds ?? null, mood: a.mood || null, theme: a.theme || null, brand: { name, palette: pal, logo: brandLogo ? 'yes' : '' }, words: { hook: a.hook || '', lines: a.lines || [], tagline: a.tagline || '' }, images });
|
|
22028
|
+
const d = await apiPost('/api/motion/build', { recipe: a.recipe, size: a.size || null, seconds: a.seconds ?? null, mood: a.mood || null, theme: a.theme || null, ...(a.energy != null ? { energy: a.energy } : {}), brand: { name, palette: pal, logo: brandLogo ? 'yes' : '' }, words: { hook: a.hook || '', lines: a.lines || [], tagline: a.tagline || '' }, images });
|
|
21936
22029
|
spec = d.spec; if (!music) music = d.music || ''; credits = d.credits; previewCredits = d.previewCredits; bpm = d.bpm;
|
|
21937
22030
|
}
|
|
21938
22031
|
const film = { config: { template: 'motion', spec, ...(music ? { music } : {}) }, brand: { name, domain: b.domain || '', logo: brandLogo, palette: pal } };
|
|
@@ -22304,7 +22397,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22304
22397
|
};
|
|
22305
22398
|
server.registerTool('make_series', {
|
|
22306
22399
|
title: 'Make a series of faceless short videos (Content Factory)',
|
|
22307
|
-
description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel / YouTube Short / Facebook / X link or a video file URL whose look, pacing and opening hook every episode copies, with plain subtitles only if the reference burns its own; never its words or characters). Find a trending reference with search_tiktok / search_instagram / search_youtube when the user wants one. THREE STEPS, ALWAYS IN THIS ORDER: (1) call with no `confirm` for a FREE quote of the whole series (per episode and total); (2) optionally `planOnly:true` to see the episode titles and the style card first (a small planning charge); (3) `confirm:true` (pass the `plan` from step 2 to reuse it) queues every episode and returns their job ids; poll get_job. Each finished episode can be scheduled with this tool again: pass `jobIds` plus `schedule` {channels, at, everyHours, timezone} and it books one post per finished episode, spaced `everyHours` apart (default 24). Captions follow the reference (on if it burns words on screen) unless `captions` says otherwise.',
|
|
22400
|
+
description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel / YouTube Short / Facebook / X link or a video file URL whose look, pacing and opening hook every episode copies, with plain subtitles only if the reference burns its own; never its words or characters). Find a trending reference with search_tiktok / search_instagram / search_youtube when the user wants one. THREE STEPS, ALWAYS IN THIS ORDER: (1) call with no `confirm` for a FREE quote of the whole series (per episode and total); (2) optionally `planOnly:true` to see the episode titles and the style card first (a small planning charge); (3) `confirm:true` (pass the `plan` from step 2 to reuse it) queues every episode and returns their job ids; poll get_job. To make MORE of a series later, pass its returned plan as `moreOf`: same style, no episode repeated, the reference not watched again. Each finished episode can be scheduled with this tool again: pass `jobIds` plus `schedule` {channels, at, everyHours, timezone} and it books one post per finished episode, spaced `everyHours` apart (default 24). Captions follow the reference (on if it burns words on screen) unless `captions` says otherwise.',
|
|
22308
22401
|
inputSchema: {
|
|
22309
22402
|
topic: z.string().optional().describe('what the series is about, in the user\'s words'),
|
|
22310
22403
|
episodes: z.number().optional().describe('how many episodes, 1-10 (default 1)'),
|
|
@@ -22318,6 +22411,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22318
22411
|
format: z.enum(['explainer', 'faceless_channel']).optional().describe("renderer lane: 'explainer' (default; exact length) or 'faceless_channel' (2K, whole 10s blocks)"),
|
|
22319
22412
|
planOnly: z.boolean().optional().describe('write the episode plan and style card, render nothing (a small planning charge)'),
|
|
22320
22413
|
plan: z.any().optional().describe('the `plan` a planOnly call returned, to run it without planning again'),
|
|
22414
|
+
moreOf: z.any().optional().describe('MORE EPISODES: the `plan` an earlier call returned. Keeps its style and length, writes new episodes only'),
|
|
22321
22415
|
confirm: z.boolean().optional().describe('REQUIRED true to start rendering (real spend). Without it you get a free quote'),
|
|
22322
22416
|
jobIds: z.array(z.string()).optional().describe('SCHEDULE MODE: the episode job ids to schedule (finished ones are booked)'),
|
|
22323
22417
|
schedule: z.object({ channels: z.array(z.string()).describe('channels to post each episode to, e.g. tiktok, instagram, youtube (as schedule_post takes them)'), at: z.string().describe('time of the first post: YYYY-MM-DDTHH:MM wall-clock, or an ISO date-time'), everyHours: z.number().optional().describe('hours between episodes; default 24'), timezone: z.string().optional().describe('timezone name for at, as schedule_post takes it') }).optional().describe('SCHEDULE MODE: where and when the first episode posts; the rest follow every `everyHours` (default 24)'),
|
|
@@ -22351,30 +22445,79 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22351
22445
|
const titles = (a.episodeTitles || []).map((t) => String(t || '').trim()).filter(Boolean);
|
|
22352
22446
|
const n = titles.length || Math.round(+a.episodes || 1);
|
|
22353
22447
|
if (n < 1 || n > 10) throw new Error('A series is 1 to 10 episodes per run. Nothing was charged.');
|
|
22354
|
-
const
|
|
22448
|
+
const prior = a.moreOf && typeof a.moreOf === 'object' ? a.moreOf : null; // more of an earlier series: its plan comes back
|
|
22449
|
+
const secs = Math.round(+a.durationSeconds || +prior?.durationSeconds || 30);
|
|
22355
22450
|
if (secs < 20 || secs > 120) throw new Error(`Each episode runs 20 to 120 seconds; ${secs}s is outside that. Ask for a length in that range (or a longer single explainer with make_explainer). Nothing was charged.`);
|
|
22356
22451
|
let vo = {}; try { vo = (await apiGet('/api/generate/status'))?.voVoices || {}; } catch {}
|
|
22357
22452
|
// FREE QUOTE: the renderer's own dry run for one episode, times the count, plus the planning estimate.
|
|
22358
|
-
const planEst = a.reference ? 14 : 6;
|
|
22359
|
-
const probe = seriesEpisodeInput(topic, { durationSeconds: secs, style: {} }, { episode: 1, title: topic, brief: '' }, n, a, vo);
|
|
22453
|
+
const planEst = a.reference && !prior ? 14 : 6;
|
|
22454
|
+
const probe = seriesEpisodeInput(topic, { durationSeconds: secs, style: prior?.style || {}, reference: prior?.reference || null }, { episode: 1, title: topic, brief: '' }, n, a, vo);
|
|
22360
22455
|
const q = await quoteJob('explainer', probe);
|
|
22361
22456
|
const per = q && Number.isFinite(+q.credits) ? +q.credits : null;
|
|
22362
|
-
const quoteLine = per == null ? `the renderer would not quote it${q?.refused ? `: ${q.refused}` : ''}` : `about ${per} credits per ${secs}s episode, ${per * n} for ${n} episode${n === 1 ? '' : 's'}, plus about ${planEst} credits to plan the series${a.reference ? ' and watch the reference' : ''} (total about ${per * n + planEst})`;
|
|
22457
|
+
const quoteLine = per == null ? `the renderer would not quote it${q?.refused ? `: ${q.refused}` : ''}` : `about ${per} credits per ${secs}s episode, ${per * n} for ${n} episode${n === 1 ? '' : 's'}, plus about ${planEst} credits to plan the series${a.reference && !prior ? ' and watch the reference' : ''} (total about ${per * n + planEst})`;
|
|
22363
22458
|
if (!a.confirm && !a.planOnly) return ok(`QUOTE (nothing planned, rendered or charged): ${quoteLine}. ${PRICE_ESTIMATE_LINE} Call again with planOnly:true to see the episode titles first, or confirm:true to make the series.`, { perEpisodeCredits: per, episodes: n, totalCredits: per == null ? null : per * n + planEst });
|
|
22364
22459
|
let plan = a.plan && Array.isArray(a.plan.episodes) && a.plan.episodes.length ? a.plan : null;
|
|
22365
|
-
if (!plan) plan = await apiPost('/api/series/plan', { topic, episodes: n, episodeTitles: titles, durationSeconds: secs, reference: a.reference || '' });
|
|
22460
|
+
if (!plan) plan = await apiPost('/api/series/plan', { topic, episodes: n, episodeTitles: titles, durationSeconds: secs, reference: a.reference || '', ...(prior ? { moreOf: prior } : {}) });
|
|
22366
22461
|
const eps = (plan.episodes || []).slice(0, n);
|
|
22367
22462
|
const list = eps.map((e) => `${e.episode}. ${e.title}`).join('\n');
|
|
22368
22463
|
const s = plan.style || {};
|
|
22369
22464
|
const styleLine = s.look ? `Style: ${s.look}${s.captions ? `; captions on (${s.captionLook || 'subtitles'})` : '; no captions'}; ${s.aspectRatio || '9:16'}.` : '';
|
|
22370
22465
|
const watched = plan.reference ? ` Reference watched: ${plan.reference.frames || 0} frames${plan.reference.transcript ? ' + transcript' : ''}${plan.reference.note ? ` (${plan.reference.note})` : ''}.` : '';
|
|
22371
|
-
if (!a.confirm) return ok(`SERIES PLAN (nothing rendered yet):\n${list}\n${styleLine}${watched}\nRendering it: ${quoteLine.replace(/, plus about.*$/, '')}. Call again with confirm:true and this plan to make it.`, { plan });
|
|
22466
|
+
if (!a.confirm) return ok(`SERIES PLAN (nothing rendered yet${plan.planCredits ? `; planning charged ${plan.planCredits} credits` : ''}):\n${list}\n${styleLine}${watched}\nRendering it: ${quoteLine.replace(/, plus about.*$/, '')}. Call again with confirm:true and this plan to make it.`, { plan });
|
|
22372
22467
|
const jobs = [];
|
|
22373
22468
|
for (const e of eps) {
|
|
22374
|
-
const
|
|
22469
|
+
const total = plan.totalEpisodes || n; // a continued series keeps counting: episode 5 of 6, not 2 of 2
|
|
22470
|
+
const j = await submitJob('explainer', seriesEpisodeInput(topic, plan, e, total, a, vo), { label: `Series · ${topic.slice(0, 40)} · Ep ${e.episode}/${total}: ${e.title}` });
|
|
22375
22471
|
jobs.push({ episode: e.episode, title: e.title, jobId: j?.id || null });
|
|
22376
22472
|
}
|
|
22377
|
-
return ok(`Series started: ${jobs.length} episode${jobs.length === 1 ? '' : 's'} rendering (${secs}s each, a few minutes per episode).\n${jobs.map((j) => `${j.episode}. ${j.title} [job ${j.jobId}]`).join('\n')}\n${styleLine}${watched}\nPoll get_job for each id. To schedule them once done, call make_series with jobIds and schedule {channels, at, everyHours}.`, { jobs, plan });
|
|
22473
|
+
return ok(`Series started: ${jobs.length} episode${jobs.length === 1 ? '' : 's'} rendering (${secs}s each, a few minutes per episode).\n${jobs.map((j) => `${j.episode}. ${j.title} [job ${j.jobId}]`).join('\n')}\n${styleLine}${watched}\nPoll get_job for each id. To schedule them once done, call make_series with jobIds and schedule {channels, at, everyHours}. For more episodes later, call it with moreOf set to this plan.`, { jobs, plan });
|
|
22474
|
+
}));
|
|
22475
|
+
|
|
22476
|
+
// THE EFFECTS, FOR AGENTS (2026-11-01; held since the 2026-10-09 parity batch): the same presets as the /tools effect pages, the
|
|
22477
|
+
// same two jobs (still, then clip), each with its own free quote and its own confirm. The server writes the prompt from the id.
|
|
22478
|
+
const effectQuote = (q, what) => { const c = q && (q.priceCredits || q.credits); return c != null && Number.isFinite(+c) ? `QUOTE (nothing made, nothing charged): ${what} is about ${c} credits. ${PRICE_ESTIMATE_LINE} Add confirm:true to make it.` : `${what} would not start${q && q.refused ? `: ${q.refused}` : ', so it has no price'}. Nothing was charged.`; };
|
|
22479
|
+
const effectLine = (x) => `${x.id}: ${x.short} [takes ${x.inputs.map((f) => `${f.id}${f.optional ? ' (optional)' : ''}`).join(', ') || 'no picture'}${x.options.length ? `; ${x.options.map((o) => `${o.id}=${o.values.join('|')}${o.orDescribe ? '|custom' : ''}`).join('; ')}` : ''}${x.text ? `; text up to ${x.text.max}` : ''}; ${x.moves ? 'can move' : 'picture only'}]`;
|
|
22480
|
+
server.registerTool('make_effect', {
|
|
22481
|
+
title: 'Run a one-click effect on a photo',
|
|
22482
|
+
description: "ONE-CLICK EFFECTS, the same ones as the /tools pages: a photo goes in, a styled picture comes out, and most can then become a 5-second clip. Plush toy, 3D figure, mascot, comic book, pixel game, billboard ad, giant product, bullet time, packshot, outfit swap, headshot, relight and many more. Name no `effect` for the free list. `effect` alone: what it takes. `effect` + `images`: a free quote; add confirm:true to make the picture. Then `effect` + `still` (that picture's URL): a free quote for the clip; confirm:true makes it. `describe` adds anything of your own. For a look not on the list use generate_image. A real person's photo follows the same likeness rules as every render.",
|
|
22483
|
+
inputSchema: {
|
|
22484
|
+
effect: z.string().optional().describe('effect id from the list'),
|
|
22485
|
+
images: z.array(z.string()).optional().describe('its pictures, in the order listed: URL, local path or Library id'),
|
|
22486
|
+
options: z.record(z.string(), z.string()).optional().describe('its listed options, e.g. {material:"knit"}; "custom" means use `describe`'),
|
|
22487
|
+
text: z.string().optional().describe('a line printed exactly, on an effect that takes one'),
|
|
22488
|
+
describe: z.string().optional().describe('your own words: added to the look, or the custom choice'),
|
|
22489
|
+
aspectRatio: z.string().optional().describe('one of its listed shapes'),
|
|
22490
|
+
still: z.string().optional().describe('CLIP STEP: the finished picture to animate'),
|
|
22491
|
+
speech: z.string().optional().describe('CLIP STEP: a line said in the clip, on an effect that speaks'),
|
|
22492
|
+
confirm: z.boolean().optional().describe('true = run it (real spend); without it, a free quote'),
|
|
22493
|
+
},
|
|
22494
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22495
|
+
}, wrap(async (a) => {
|
|
22496
|
+
if (!a.effect) {
|
|
22497
|
+
const list = (await apiGet('/api/effects'))?.effects || [];
|
|
22498
|
+
return ok(`${list.length} effects (free to list; each run is quoted first). Call with effect and images:\n${list.map(effectLine).join('\n')}`, { count: list.length });
|
|
22499
|
+
}
|
|
22500
|
+
const row = (await apiGet('/api/effects', { id: a.effect }))?.effect;
|
|
22501
|
+
if (!row) throw new Error(`There is no effect called "${String(a.effect).slice(0, 60)}". Call make_effect with no effect for the list. Nothing was charged.`);
|
|
22502
|
+
const describe = String(a.describe || '').trim();
|
|
22503
|
+
if (a.still) {
|
|
22504
|
+
if (!row.moves) throw new Error(`${row.title} makes a picture only. Nothing was charged.`);
|
|
22505
|
+
const refImage = await toRef(a.still);
|
|
22506
|
+
if (!refImage) throw new Error('The `still` could not be read: pass the picture\'s URL or a Library id. Nothing was charged.');
|
|
22507
|
+
const input = { effect: row.id, refImage, ...(a.aspectRatio ? { aspectRatio: a.aspectRatio } : {}), ...(describe ? { effectDescribe: describe } : {}), ...(a.speech ? { effectSpeech: String(a.speech) } : {}) };
|
|
22508
|
+
if (!a.confirm) return ok(effectQuote(await quoteJob('video', input), `a 5-second ${row.title} clip`));
|
|
22509
|
+
const r = await renderJob('video', input, `Effect clip · ${row.title}`);
|
|
22510
|
+
if (r?.stillRendering) return ok(`${row.title} clip still rendering [job ${r.jobId}]. Call get_job with that id.`, r);
|
|
22511
|
+
return okVideo(`${row.title} clip ready: ${r.url} [job ${r.jobId}]`, r);
|
|
22512
|
+
}
|
|
22513
|
+
if (!a.images?.length && row.inputs.some((f) => !f.optional)) return ok(`${effectLine(row)}\nShapes: ${row.aspects.join(', ')} (default ${row.aspect}). Pass images to get a quote.`, { effect: row });
|
|
22514
|
+
const refs = (await Promise.all((a.images || []).map(toRef))).filter(Boolean);
|
|
22515
|
+
if (refs.length !== (a.images || []).length) throw new Error('One of the pictures could not be read: pass a URL, a local path or a Library id. Nothing was charged.');
|
|
22516
|
+
const input = { effect: row.id, refImages: refs, aspectRatio: a.aspectRatio || row.aspect, ...(a.options && Object.keys(a.options).length ? { effectOptions: a.options } : {}), ...(a.text ? { effectText: String(a.text) } : {}), ...(describe ? { effectDescribe: describe } : {}) };
|
|
22517
|
+
if (!a.confirm) return ok(effectQuote(await quoteJob('image', input), `this ${row.title} picture`));
|
|
22518
|
+
const r = await renderJob('image', input, `Effect · ${row.title}`);
|
|
22519
|
+
if (r?.stillRendering) return ok(`${row.title} still rendering [job ${r.jobId}]. Call get_job with that id.`, r);
|
|
22520
|
+
return ok(`${row.title} ready: ${r.url} [job ${r.jobId}]${row.moves ? `\nTo make it move: make_effect({effect:'${row.id}', still:'${r.url}'}) for its price, then confirm:true.` : ''}${row.grid ? `\nOne ${row.grid} sheet: each panel is a shot.` : ''}`, r);
|
|
22378
22521
|
}));
|
|
22379
22522
|
|
|
22380
22523
|
server.registerTool('product_sizzle', {
|
|
@@ -24943,9 +25086,12 @@ function memoryNoteVerdict(text) {
|
|
|
24943
25086
|
// motion and the ORIGINAL AUDIO untouched. The plan is one server call; every variant is a `multiply` job (opening-frame keyframe edit → motion transfer driven by the source, so length, cut, motion and audio are the original's).
|
|
24944
25087
|
server.registerTool('multiply_ad', {
|
|
24945
25088
|
title: 'Multiply an ad',
|
|
24946
|
-
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). HOW: the source video itself DRIVES each variant (motion transfer from one image-edited opening frame), so every variant comes back the SAME LENGTH as the source with the same cut, the same performance and the original audio — only the person, outfit, set and props change. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is a separate, explicit step — dub_video on a finished variant. `change` is what should change, in the user's own words ('older women', 'winter streets', 'swap the mug for our bottle'): every variant applies it and the variants still differ around it; a part of it about the voice, words, language, music, length or captions cannot change here, and the reply says so and names the tool that can.",
|
|
25089
|
+
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). HOW: the source video itself DRIVES each variant (motion transfer from one image-edited opening frame), so every variant comes back the SAME LENGTH as the source with the same cut, the same performance and the original audio — only the person, outfit, set and props change. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. Your own pictures: images takes up to 9 of the user's product, people, clothes or places, and change names them @Image1, @Image2 in order ('she holds the bottle in @Image1'); the saved brand's product photo is read as a reference to its real look (useBrand:false leaves it out). A TikTok, Reel, Facebook, X or YouTube post link works as the source: one paid lookup reads it into a file. quality: 1080p (default) or 720p; quoteOnly prices both. Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is a separate, explicit step — dub_video on a finished variant. `change` is what should change, in the user's own words ('older women', 'winter streets', 'swap the mug for our bottle'): every variant applies it and the variants still differ around it; a part of it about the voice, words, language, music, length or captions cannot change here, and the reply says so and names the tool that can.",
|
|
24947
25090
|
inputSchema: {
|
|
24948
|
-
video: z.string().describe('the source video URL'),
|
|
25091
|
+
video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook, X or YouTube post link'),
|
|
25092
|
+
images: z.array(z.union([z.string(), z.object({ url: z.string(), role: z.string().optional().describe('what it is, in your words, e.g. "our founder"') })])).optional().describe('up to 9 pictures to put in the variants, in the order @Image1, @Image2 name them: URLs or local paths'),
|
|
25093
|
+
quality: z.enum(['1080p', '720p']).optional().describe('1080p is the default; 720p costs less, and hands holding a product are less reliable at 720p'),
|
|
25094
|
+
useBrand: z.boolean().optional().describe('false = send no brand product photo (for a video that is not this workspace brand)'),
|
|
24949
25095
|
count: z.number().optional().describe('how many variants, 1-12 (default 6)'),
|
|
24950
25096
|
change: z.string().optional().describe("what should change, in the user's own words, e.g. 'older women, winter streets'; every variant applies it (omit to let the variants vary freely)"),
|
|
24951
25097
|
axes: z.array(z.string()).optional().describe('what to vary: character, outfit, location, objects (default all four), or your own, e.g. "season"'),
|
|
@@ -24955,32 +25101,39 @@ function memoryNoteVerdict(text) {
|
|
|
24955
25101
|
dryRun: z.boolean().optional().describe('true = the paid plan and the price, no render'),
|
|
24956
25102
|
plan: z.any().optional().describe('the `plan` a dryRun returned, rendered without planning again'),
|
|
24957
25103
|
},
|
|
24958
|
-
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), perVariantCredits: z.number().nullable().optional(), totalCredits: z.number().nullable().optional(), planCredits: z.number().optional(), planCreditsUsed: z.number().optional(), dryRun: z.boolean().optional() },
|
|
25104
|
+
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), perVariantCredits: z.number().nullable().optional(), perVariantMaxCredits: z.number().nullable().optional(), qualities: z.any().optional(), seconds: z.number().optional(), quoteOnly: z.boolean().optional(), totalCredits: z.number().nullable().optional(), planCredits: z.number().optional(), planCreditsUsed: z.number().optional(), dryRun: z.boolean().optional() },
|
|
24959
25105
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
24960
|
-
}, wrap(async ({ video, count, change, axes, notes, regions, quoteOnly, dryRun, plan: savedPlan }) => {
|
|
25106
|
+
}, wrap(async ({ video, count, change, axes, notes, regions, images, quality, useBrand, quoteOnly, dryRun, plan: savedPlan }) => {
|
|
24961
25107
|
const src = String(video || '').trim();
|
|
24962
|
-
if (!/^https?:\/\//.test(src)) return { content: [{ type: 'text', text: 'Pass the source video as a URL
|
|
25108
|
+
if (!/^https?:\/\//.test(src) && !src.startsWith('/generated/')) return { content: [{ type: 'text', text: 'Pass the source video as a URL: a previous render, a job result, an entry from list_library, or a social post link.' }], isError: true };
|
|
24963
25109
|
const n = Math.max(1, Math.min(12, Math.round(+count) || 6));
|
|
24964
25110
|
if (quoteOnly) {
|
|
24965
|
-
const q = await apiPost('/api/multiply/quote', { video: src, count: n });
|
|
25111
|
+
const q = await apiPost('/api/multiply/quote', { video: src, count: n, ...(quality ? { quality } : {}) });
|
|
24966
25112
|
if (!(q?.perVariantCredits > 0)) return ok(`No price for this source${q?.seconds > 0 ? `: it is ${q.seconds}s, over the length a variant can be driven from` : ': pass a playable video file URL (a post link is priced after its plan, with dryRun)'}. Nothing was charged.`, { perVariantCredits: null, totalCredits: null, quoteOnly: true });
|
|
24967
|
-
|
|
25113
|
+
const _qs = Object.entries(q.qualities || {}).map(([k, c]) => `${k}: ${c}`).join(', ');
|
|
25114
|
+
return ok(`Exact price, nothing charged: ${q.perVariantCredits} credits per variant${q.quality === '720p' ? ' at 720p' : ''}, ${q.totalCredits} credits for ${n} (${q.seconds}s source), plus about ${q.planCredits} credits for the planning read that runs first.${_qs ? ` Per variant by quality: ${_qs}.` : ''}${q.perVariantMaxCredits ? ` A tight close-up the driver cannot read goes through the video editor instead, up to ${q.perVariantMaxCredits} per variant.` : ''} ${PRICE_ESTIMATE_LINE}`, { perVariantCredits: q.perVariantCredits, perVariantMaxCredits: q.perVariantMaxCredits ?? null, totalCredits: q.totalCredits, planCredits: q.planCredits, seconds: q.seconds, qualities: q.qualities, quoteOnly: true });
|
|
24968
25115
|
}
|
|
24969
25116
|
const hasSaved = savedPlan && typeof savedPlan === 'object' && Array.isArray(savedPlan.variants) && savedPlan.variants.length && savedPlan.perVariantCredits > 0;
|
|
24970
|
-
|
|
24971
|
-
const
|
|
25117
|
+
let _imgs;
|
|
25118
|
+
if (!hasSaved && Array.isArray(images) && images.length) _imgs = (await Promise.all(images.map(async (x) => { const u = await toRef(typeof x === 'object' && x ? x.url : x); return u ? (typeof x === 'object' && x?.role ? { url: u, role: x.role } : u) : null; }))).filter(Boolean);
|
|
25119
|
+
const plan = hasSaved ? savedPlan : await apiPost('/api/multiply/plan', { video: src, count: n, ...(change ? { change } : {}), ...(axes ? { axes } : {}), ...(notes ? { notes } : {}), ...(regions ? { regions } : {}), ...(_imgs ? { images: _imgs } : {}), ...(quality ? { quality } : {}), ...(useBrand === false ? { useBrand: false } : {}) });
|
|
25120
|
+
const p = { ...(plan?.data || plan) };
|
|
25121
|
+
// A saved plan rendered at another quality is priced at that quality before anything is queued.
|
|
25122
|
+
if (hasSaved && quality && quality !== p.quality) { const rq = await apiPost('/api/multiply/quote', { video: p.video || src, count: 1, quality }); if (rq?.perVariantCredits > 0) { p.perVariantCredits = rq.perVariantCredits; p.perVariantMaxCredits = rq.perVariantMaxCredits; } p.quality = quality; }
|
|
25123
|
+
const _jobSrc = p.video || src;
|
|
25124
|
+
const _srcLine = `${p.sourceLink ? `\nRead from the post into ${p.video}. Pass that file next time so it is not looked up again.` : ''}${(p.images || []).length ? `\nPictures: ${p.images.map((im, i) => `@Image${i + 1}${im.role ? ` (${im.role})` : ''}`).join(', ')}.` : ''}`;
|
|
24972
25125
|
const changeLine = `${p.change ? `\nEvery variant: ${p.change}` : ''}${p.changeNote ? `\n${p.changeNote}` : ''}`; // WHAT SHOULD CHANGE (2026-09-29), and what of it a variant cannot change
|
|
24973
25126
|
const lines = (p.variants || []).map((v, i) => `${i + 1}. ${v.label} — ${['character', 'outfit', 'location', 'objects'].filter(a => v[a] && v[a] !== 'same').map(a => a + ': ' + v[a]).join('; ')}`);
|
|
24974
25127
|
const _nV = (p.variants || []).length;
|
|
24975
25128
|
const _planPaid = hasSaved ? 0 : (Number(p.planCreditsUsed) || 0); // only the read this call paid for; a saved plan was paid for by the dry run that made it
|
|
24976
|
-
const quote = `${p.perVariantCredits} credits per variant, ${p.perVariantCredits * _nV} credits for ${_nV}${_planPaid ? ` (the planning read just billed ${_planPaid} credits)` : ''}`;
|
|
24977
|
-
if (dryRun) return { content: [{ type: 'text', text: `Plan (nothing rendered). Source: ${Object.entries(p.source || {}).map(([k, v]) => k + ': ' + v).join('; ')}\n${lines.join('\n')}${changeLine}\n${quote}. ${PRICE_ESTIMATE_LINE} Call again with plan set to this plan (and no dryRun) to render exactly these without paying to plan again.` }], structuredContent: { plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.perVariantCredits * _nV, planCreditsUsed: _planPaid, dryRun: true } };
|
|
25129
|
+
const quote = `${p.perVariantCredits} credits per variant${p.quality === '720p' ? ' at 720p' : ''}, ${p.perVariantCredits * _nV} credits for ${_nV}${p.perVariantMaxCredits ? ` (up to ${p.perVariantMaxCredits} per variant if a tight close-up goes through the video editor)` : ''}${_planPaid ? ` (the planning read just billed ${_planPaid} credits)` : ''}`;
|
|
25130
|
+
if (dryRun) return { content: [{ type: 'text', text: `Plan (nothing rendered). Source: ${Object.entries(p.source || {}).map(([k, v]) => k + ': ' + v).join('; ')}\n${lines.join('\n')}${changeLine}${_srcLine}\n${quote}. ${PRICE_ESTIMATE_LINE} Call again with plan set to this plan (and no dryRun) to render exactly these without paying to plan again.` }], structuredContent: { plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.perVariantCredits * _nV, planCreditsUsed: _planPaid, dryRun: true } };
|
|
24978
25131
|
const jobs = [];
|
|
24979
25132
|
for (const v of (p.variants || [])) {
|
|
24980
|
-
const job = await submitJob('multiply', { video:
|
|
25133
|
+
const job = await submitJob('multiply', { video: _jobSrc, instruction: v.instruction, label: v.label, ...((p.images || []).length ? { images: p.images } : {}), ...(p.quality ? { quality: p.quality } : {}) }, { label: `Multiply · ${v.label}` });
|
|
24981
25134
|
jobs.push({ id: job.id, label: v.label });
|
|
24982
25135
|
}
|
|
24983
|
-
return { content: [{ type: 'text', text: `Multiplying — ${jobs.length} variant(s) queued, original audio kept on all of them. ${quote}.${changeLine}\n${jobs.map((j, i) => `${i + 1}. ${j.label} → job ${j.id}`).join('\n')}\nEach variant is one keyframe edit plus a motion transfer of the whole source (2-6 minutes) and comes back the SAME length as the source. Call get_job with each id until it reports done; a variant has NO file until then.` }], structuredContent: { jobs, plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.perVariantCredits * _nV, planCreditsUsed: _planPaid } };
|
|
25136
|
+
return { content: [{ type: 'text', text: `Multiplying — ${jobs.length} variant(s) queued, original audio kept on all of them. ${quote}.${changeLine}${_srcLine}\n${jobs.map((j, i) => `${i + 1}. ${j.label} → job ${j.id}`).join('\n')}\nEach variant is one keyframe edit plus a motion transfer of the whole source (2-6 minutes) and comes back the SAME length as the source. Call get_job with each id until it reports done; a variant has NO file until then.` }], structuredContent: { jobs, plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.perVariantCredits * _nV, planCreditsUsed: _planPaid } };
|
|
24984
25137
|
}));
|
|
24985
25138
|
|
|
24986
25139
|
// HOOK MULTIPLIER (2026-09-18): ONE finished video ad → N complete versions with NEW opening hooks, the rest of the ad and
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.491",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI marketing agent and AI ad generator, run from your own AI agent.
|
|
5
|
+
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1108 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. 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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|