hermoso 0.1.374 → 0.1.378
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 +98 -41
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**897 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
@@ -231,7 +231,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
231
231
|
|
|
232
232
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
233
233
|
|
|
234
|
-
### What the
|
|
234
|
+
### What the 897 tools cover
|
|
235
235
|
|
|
236
236
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
237
237
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/tools.mjs
CHANGED
|
@@ -5687,73 +5687,107 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5687
5687
|
const d = await apiPost(`/api/schedule/${encodeURIComponent(id)}/duplicate`, rest);
|
|
5688
5688
|
return ok(d.note || `Duplicated ${id} as ${d.id}.`, d);
|
|
5689
5689
|
}));
|
|
5690
|
-
// ── THE POSTING REFILL (2026-08-03)
|
|
5691
|
-
// Keeping a calendar full is the part of "post three times a day" that nobody sustains by hand
|
|
5692
|
-
//
|
|
5693
|
-
//
|
|
5690
|
+
// ── THE POSTING REFILL / AUTOPOSTING (2026-08-03; fresh-only with two modes since 2026-10-05) ───────────────────
|
|
5691
|
+
// Keeping a calendar full is the part of "post three times a day" that nobody sustains by hand. These wrap the SAME
|
|
5692
|
+
// routes the app uses; there is no second queue — every post it schedules is an ordinary scheduled item that
|
|
5693
|
+
// list_scheduled shows and cancel_scheduled cancels. It ALWAYS makes new content within the user's render budget and
|
|
5694
|
+
// never re-posts the Library; in 'review' mode each batch waits as drafts until approved.
|
|
5694
5695
|
server.registerTool('get_post_refill', {
|
|
5695
|
-
title: '
|
|
5696
|
-
description: 'Show the
|
|
5696
|
+
title: 'Autoposting status',
|
|
5697
|
+
description: 'Show autoposting (the posting refill) for this profile: whether it is on, its mode (review = each batch of fresh posts waits as drafts for approval; auto = fresh posts are scheduled straight away), WHICH CHANNELS it posts to with each one’s posts per day and posting times, roughly what it costs a day, when it next runs, how many posts are queued, and the DRAFTS waiting for approval (id, time, channels, caption, the new image or video). It also names the channels that CANNOT be posted to and why. Read-only, free.',
|
|
5697
5698
|
inputSchema: {},
|
|
5698
5699
|
outputSchema: {
|
|
5699
|
-
enabled: z.boolean().optional(),
|
|
5700
|
+
enabled: z.boolean().optional(), mode: z.string().optional(), daysAhead: z.number().optional(),
|
|
5700
5701
|
postsPerDay: z.number().optional(), running: z.boolean().optional(), nextRunAt: z.number().optional(),
|
|
5701
|
-
queuedPosts: z.number().optional(),
|
|
5702
|
+
queuedPosts: z.number().optional(), drafts: z.array(z.any()).optional(), zeroBudget: z.boolean().optional(),
|
|
5703
|
+
postingSchedule: z.any().optional(), excluded: z.any().optional(),
|
|
5702
5704
|
},
|
|
5703
5705
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5704
5706
|
}, wrap(async () => {
|
|
5705
5707
|
const d = await apiGet('/api/schedule/refill', {});
|
|
5706
|
-
const
|
|
5707
|
-
const
|
|
5708
|
-
|
|
5708
|
+
const state = !d.enabled ? 'OFF' : (d.mode === 'auto' ? 'ON, AUTO-PUBLISH — each run makes fresh posts and schedules them' : 'ON, REVIEW — each run makes fresh posts and keeps them as drafts until you approve them');
|
|
5709
|
+
const drafts = Array.isArray(d.drafts) ? d.drafts : [];
|
|
5710
|
+
const draftLines = drafts.slice(0, 20).map((x, i) => ` ${i + 1}. ${x.id} · ${x.at} → ${(x.channels || []).join(', ')} · ${x.kind}\n ${String(x.message || '').replace(/\n+/g, ' / ').slice(0, 200)}\n ${x.media || '(no file)'}`);
|
|
5711
|
+
return ok([
|
|
5712
|
+
`Autoposting: ${state}.${d.note ? `\n${d.note}` : ''}`,
|
|
5713
|
+
`Posting times: ${(d.postingTimes || []).join(', ') || 'none set (add some on the Schedule tab)'} ${d.postingSchedule?.timezone || 'UTC'}. It fills ${d.daysAhead} day(s) ahead; ${d.queuedPosts} post(s) queued now.`,
|
|
5714
|
+
(d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under Workspace ▸ Connectors.'),
|
|
5715
|
+
`Makes ${d.postsPerDayMade || 0} new post(s) a day, every one a fresh render made for it${d.estCreditsPerDay != null && d.postsPerDayMade ? `, about ${d.estCreditsPerDay} credits a day at today's prices` : ''}.${d.zeroBudget ? ` ${d.budgetNote}` : ''} Used: ${d.spend?.today || 0} credits today, ${d.spend?.week || 0} this week.`,
|
|
5716
|
+
`Brief: goal ${d.brief?.goal || 'not set (required to switch it on)'}${d.brief?.goalNote ? ` (${d.brief.goalNote})` : ''} · format ${d.brief?.formats || 'mix'}${(d.brief?.pillars || []).length ? ` · pillars: ${d.brief.pillars.join('; ')}` : (d.suggestedPillars || []).length ? ` · no pillars yet; suggested from the brand: ${d.suggestedPillars.join('; ')}` : ''}${(d.brief?.avoid || []).length ? ` · avoid: ${d.brief.avoid.join('; ')}` : ''}`,
|
|
5717
|
+
d.suggestAuto ? `${d.approvedBatches} review batches approved so far: auto-publish (mode "auto") is available if the user wants posts to go out without a review step. Ask; never switch it yourself.` : '',
|
|
5718
|
+
drafts.length ? `Drafts waiting for approval (${drafts.length}):\n${draftLines.join('\n')}${drafts.length > 20 ? `\n …and ${drafts.length - 20} more` : ''}\nApprove, edit or discard them with run_post_refill (approve / edit / discard).` : 'No drafts waiting for approval.',
|
|
5719
|
+
`Cannot post to: ${Object.entries(d.excluded || {}).map(([c, why]) => `${c} — ${why}`).join('\n ') || 'nothing'}`,
|
|
5720
|
+
].filter(Boolean).join('\n'), d);
|
|
5709
5721
|
}));
|
|
5710
5722
|
server.registerTool('set_post_refill', {
|
|
5711
|
-
title: 'Configure
|
|
5712
|
-
description: 'Turn the
|
|
5723
|
+
title: 'Configure autoposting',
|
|
5724
|
+
description: 'Turn autoposting (the posting refill) on or off and set how it behaves. PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. WHERE: every connected channel by default (channels narrows it); HOW OFTEN: the brand’s posting times, and channelPostsPerDay sets fewer on a channel (e.g. {"x":1}). There are no credit limits to set — get_post_refill shows roughly what it costs a day. Switching it on is refused when no channel is connected or nothing a day would be made. SWITCHING IT ON ASKS THE BRIEF ONCE: a goal (followers / sales / launch / other) is required; content pillars (get_post_refill suggests some from the brand), formats (mix / video / carousel / image) and an avoid list are optional. Posts are written from the brief, the brand profile, what has worked on each channel (compared only within a channel), the brand’s skills and playbooks and the ads it saved. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled). Three posting times means up to three posts a day; to post MORE per day, add posting times on the Schedule tab.',
|
|
5713
5725
|
inputSchema: {
|
|
5714
|
-
enabled: z.boolean().optional().describe('on/off. false PAUSES it: the recurring job is deleted and nothing new is
|
|
5715
|
-
|
|
5726
|
+
enabled: z.boolean().optional().describe('on/off. true needs a connected channel and a goal (refused otherwise). false PAUSES it: the recurring job is deleted and nothing new is made. Already-queued posts are untouched.'),
|
|
5727
|
+
mode: z.enum(['review', 'auto']).optional().describe('"review" (default): each batch of fresh posts waits as drafts for approval. "auto": fresh posts are scheduled to publish automatically.'),
|
|
5716
5728
|
daysAhead: z.number().optional().describe('how far ahead to keep the queue full, 1–30 (default 7)'),
|
|
5717
5729
|
postsPerDay: z.number().optional().describe('cap the posts per day BELOW the number of posting times. 0 (default) = use every posting time, which is where "3 a day" comes from. To post MORE per day, add posting times instead.'),
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5730
|
+
channelPostsPerDay: z.record(z.number()).optional().describe('per channel, how many posts a day (the first N of the posting times), e.g. {"x":1,"instagram":3}. 0 turns a channel off; null resets it to every posting time. Merged into what is saved.'),
|
|
5731
|
+
goal: z.enum(['followers', 'sales', 'launch', 'other']).optional().describe('THE BRIEF: what the posts are for — grow followers, drive sales, promote a launch, or other (say what in goalNote). Required to switch it on.'),
|
|
5732
|
+
goalNote: z.string().optional().describe('THE BRIEF: a line about the goal (e.g. the launch date and what is launching).'),
|
|
5733
|
+
pillars: z.array(z.string()).optional().describe('THE BRIEF: content pillars / topics, up to 8 short lines. Every post belongs to one. get_post_refill suggests some from the brand profile.'),
|
|
5734
|
+
formats: z.enum(['mix', 'video', 'carousel', 'image']).optional().describe('THE BRIEF: what to make — mix (images with a short video now and then, the default), video (short videos only), carousel (3 new images per post), image.'),
|
|
5735
|
+
avoid: z.array(z.string()).optional().describe('THE BRIEF: never mention or show these — topics, claims, competitors, faces.'),
|
|
5722
5736
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('restrict it to these channels. Omit (or send an empty list) to use every connected channel that can carry each post.'),
|
|
5723
5737
|
boardId: z.string().optional().describe('PINTEREST — which board Pins go on (list_pinterest_boards). Without one, Pinterest is skipped: a Pin on the wrong board is a public mistake, so it is never guessed.'),
|
|
5724
5738
|
chatId: z.string().optional().describe('TELEGRAM — which chat, group or channel posts go to (@username or numeric id). Without one, telegram is skipped: there is no default chat and posting to the wrong one is a public mistake.'),
|
|
5725
5739
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this profile is ignored rather than failing the whole post.'),
|
|
5726
5740
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page.'),
|
|
5727
5741
|
},
|
|
5728
|
-
outputSchema: { enabled: z.boolean().optional(),
|
|
5742
|
+
outputSchema: { enabled: z.boolean().optional(), mode: z.string().optional(), running: z.boolean().optional(), daysAhead: z.number().optional() },
|
|
5729
5743
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5730
5744
|
}, wrap(async (a) => {
|
|
5731
|
-
const
|
|
5745
|
+
const { goal, goalNote, pillars, formats, avoid, ...rest } = a;
|
|
5746
|
+
const brief = Object.fromEntries(Object.entries({ goal, goalNote, pillars, formats, avoid }).filter(([, v]) => v !== undefined));
|
|
5747
|
+
const d = await apiPut('/api/schedule/refill', { ...rest, ...(Object.keys(brief).length ? { brief } : {}) });
|
|
5732
5748
|
return ok(d.enabled
|
|
5733
|
-
? `
|
|
5734
|
-
: '
|
|
5749
|
+
? `Autoposting is ON in ${d.mode === 'auto' ? 'AUTO-PUBLISH mode — each run makes fresh posts and schedules them to go out on their own.' : 'REVIEW mode — each run makes fresh posts and keeps them as drafts; nothing is scheduled until you approve them with run_post_refill.'} get_post_refill lists the channels it posts to and what it costs a day. Run run_post_refill (a free preview by default) to see a plan.`
|
|
5750
|
+
: 'Autoposting is OFF. The recurring job is gone and nothing new will be made. Posts already in the calendar are untouched — cancel those with cancel_scheduled if you want them gone.', d);
|
|
5735
5751
|
}));
|
|
5736
5752
|
server.registerTool('run_post_refill', {
|
|
5737
|
-
title: 'Run
|
|
5738
|
-
description: 'Run
|
|
5753
|
+
title: 'Run autoposting / review its drafts',
|
|
5754
|
+
description: 'Run autoposting NOW instead of waiting for its daily turn, or act on the drafts it made. PREVIEW BY DEFAULT: it returns the posts it WOULD make — each new picture’s scene, the caption, the channels and the price — rendering nothing and queueing nothing. dryRun:false actually renders a fresh image or video for each post within the budget (credits are spent) and then, by mode, schedules them ("auto") or keeps them as drafts ("review"). It never re-posts the Library. REVIEWING A BATCH: pass approve (draft ids, or ["all"]) to schedule drafts, discard to delete them, edit ([{id, message?, captions?, title?, at?, channels?}]) to change one first; get_post_refill lists them. A scheduled post can still be pulled before it goes out with cancel_scheduled. Every caption is screened against the voice rules and a failing one is dropped, so a plan can come back shorter than the cadence — the reason is in the notes.',
|
|
5739
5755
|
inputSchema: {
|
|
5740
|
-
dryRun: z.boolean().optional().describe('default TRUE (preview
|
|
5741
|
-
force: z.boolean().optional().describe('plan even while
|
|
5756
|
+
dryRun: z.boolean().optional().describe('default TRUE (a preview: nothing rendered, nothing queued). false renders fresh creative and schedules or drafts it per the mode.'),
|
|
5757
|
+
force: z.boolean().optional().describe('plan a preview even while autoposting is switched off — useful for showing someone what it would do before they turn it on.'),
|
|
5758
|
+
approve: z.array(z.string()).optional().describe('REVIEW: draft ids to schedule, or ["all"]. A draft whose time has passed moves to the next free posting slot.'),
|
|
5759
|
+
discard: z.array(z.string()).optional().describe('REVIEW: draft ids to delete, or ["all"]. Nothing is posted for them.'),
|
|
5760
|
+
edit: z.array(z.object({ id: z.string(), message: z.string().optional(), captions: z.record(z.string()).optional(), title: z.string().optional(), at: z.string().optional().describe('ISO time, at least 5 minutes ahead'), channels: z.array(z.string()).optional().describe('narrow to some of the draft’s own channels') })).optional().describe('REVIEW: change drafts before approving them. Edited copy is screened by the voice rules.'),
|
|
5742
5761
|
},
|
|
5743
5762
|
outputSchema: {
|
|
5744
|
-
enabled: z.boolean().optional(), dryRun: z.boolean().optional(), summary: z.string().optional(),
|
|
5745
|
-
posts: z.array(z.any()).optional(), queued: z.array(z.any()).optional(), skippedSlots: z.array(z.any()).optional(),
|
|
5763
|
+
enabled: z.boolean().optional(), mode: z.string().optional(), dryRun: z.boolean().optional(), summary: z.string().optional(),
|
|
5764
|
+
posts: z.array(z.any()).optional(), queued: z.array(z.any()).optional(), drafts: z.array(z.any()).optional(), skippedSlots: z.array(z.any()).optional(),
|
|
5746
5765
|
notes: z.array(z.string()).optional(), spend: z.any().optional(), perChannel: z.any().optional(),
|
|
5747
5766
|
},
|
|
5748
5767
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5749
5768
|
}, wrap(async (a) => {
|
|
5769
|
+
const reviewing = (a.approve && a.approve.length) || (a.discard && a.discard.length) || (a.edit && a.edit.length);
|
|
5770
|
+
if (reviewing) {
|
|
5771
|
+
const d = await apiPost('/api/schedule/refill/drafts', { approve: a.approve || [], discard: a.discard || [], edit: a.edit || [] });
|
|
5772
|
+
const okQ = (d.queued || []).filter(q => q && q.id), badQ = (d.queued || []).filter(q => q && q.error);
|
|
5773
|
+
return ok([
|
|
5774
|
+
okQ.length ? `Scheduled ${okQ.length} draft(s): ${okQ.map(q => `${q.draftId} → ${q.id} at ${q.at}${q.moved ? ' (moved to the next free slot)' : ''}`).join(' · ')}. Pull any of them before it goes out with cancel_scheduled.` : '',
|
|
5775
|
+
badQ.length ? `Could not schedule ${badQ.length}: ${badQ.map(q => `${q.draftId} — ${q.error}`).join(' · ')}` : '',
|
|
5776
|
+
d.discarded ? `Discarded ${d.discarded} draft(s).` : '',
|
|
5777
|
+
d.edited ? `Edited ${d.edited} draft(s).` : '',
|
|
5778
|
+
(d.editErrors || []).length ? `Edits refused: ${d.editErrors.map(x => `${x.draftId} — ${x.error}`).join(' · ')}` : '',
|
|
5779
|
+
(d.unknown || []).length ? `No such draft: ${d.unknown.join(', ')}` : '',
|
|
5780
|
+
`${d.remaining} draft(s) still waiting for approval.`,
|
|
5781
|
+
].filter(Boolean).join('\n'), d);
|
|
5782
|
+
}
|
|
5750
5783
|
const d = await apiPost('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force });
|
|
5751
|
-
const lines = (d.posts || []).map((p, i) => ` ${i + 1}. ${p.at} → ${(p.channels || []).map(c => `${c}[${(p.visibilityByChannel || {})[c] || 'public'}]`).join(', ')}\n ${String(p.message || '').replace(/\n+/g, ' / ').slice(0, 220)}\n
|
|
5784
|
+
const lines = (d.posts || []).map((p, i) => ` ${i + 1}. ${p.at} → ${(p.channels || []).map(c => `${c}[${(p.visibilityByChannel || {})[c] || 'public'}]`).join(', ')}\n ${String(p.message || '').replace(/\n+/g, ' / ').slice(0, 220)}\n new ${p.kind}: ${p.media || `(made when it runs live) ${String(p.asset?.scene || '').slice(0, 160)}`} · ${p.asset?.credits || 0} credits`);
|
|
5752
5785
|
const q = (d.queued || []).filter(x => x && x.id).length, qFail = (d.queued || []).filter(x => x && x.error);
|
|
5753
5786
|
return ok([
|
|
5754
5787
|
d.summary || '',
|
|
5755
5788
|
lines.length ? `\nThe posts:\n${lines.join('\n')}` : '',
|
|
5756
|
-
d.dryRun ? '\nNOTHING WAS QUEUED
|
|
5789
|
+
(d.noChannels || d.zeroBudget || (d.enabled === false && !(d.posts || []).length)) ? '' : d.dryRun ? '\nNOTHING WAS RENDERED, QUEUED OR SPENT — this was a preview. Show it to the user; call again with dryRun:false only if they say yes.'
|
|
5790
|
+
: (d.mode === 'auto' ? `\n${q} post(s) scheduled. Pull any of them before it goes out with cancel_scheduled.` : `\n${(d.drafts || []).length} draft(s) waiting for approval — approve, edit or discard them with run_post_refill.`),
|
|
5757
5791
|
qFail.length ? `\n${qFail.length} could not be queued: ${qFail.map(x => `${x.at} — ${x.error}`).join(' · ')}` : '',
|
|
5758
5792
|
(d.notes || []).length ? `\nNotes: ${d.notes.join(' · ')}` : '',
|
|
5759
5793
|
].filter(Boolean).join('\n'), d);
|
|
@@ -13258,8 +13292,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13258
13292
|
inputSchema: { limit: z.number().optional(), after: z.string().optional() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
13259
13293
|
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feeds', a))));
|
|
13260
13294
|
server.registerTool('create_openai_ads_feed', {
|
|
13261
|
-
title: 'Create a ChatGPT Ads product feed', description: 'Create an EMPTY product feed on ChatGPT Ads with a name and the countries it serves (ISO codes). Fill it with update_openai_ads_feed_products, or set up SFTP bulk delivery with set_openai_ads_feed_sftp.',
|
|
13262
|
-
inputSchema: { name: z.string(), countries: z.array(z.string()).optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
13295
|
+
title: 'Create a ChatGPT Ads product feed', description: 'Create an EMPTY product feed on ChatGPT Ads with a name and the countries it serves (ISO codes). Fill it with update_openai_ads_feed_products, or set up SFTP bulk delivery with set_openai_ads_feed_sftp. inventoryType:"hotel" creates a HOTEL property feed instead (schema hotel_property_v1, filled with a full property snapshot over SFTP; an OpenAI limited beta for approved pilot advertisers, so an account outside the pilot is refused by OpenAI).',
|
|
13296
|
+
inputSchema: { name: z.string(), countries: z.array(z.string()).optional(), inventoryType: z.enum(['product', 'hotel']).optional().describe('default product; hotel = a Hotel property feed (limited beta)') }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
13263
13297
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed', a))));
|
|
13264
13298
|
server.registerTool('list_openai_ads_feed_uploads', {
|
|
13265
13299
|
title: 'List ChatGPT Ads feed uploads', description: 'Recent product-feed UPLOADS across the account with per-upload status, rows accepted / rejected / ads-eligible and OpenAI’s DIAGNOSTICS (code, severity, field and rows affected, e.g. invalid_value on price) — the way to learn why products are not serving. Per-product review status (why ONE product was rejected) is shown only in ChatGPT Ads Manager > Products: OpenAI does not expose it through the API, so say that rather than guessing. Read-only, 0 credits.',
|
|
@@ -13303,6 +13337,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13303
13337
|
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), available: z.boolean().optional(), pending: z.boolean().optional(), snapshotId: z.string().nullable().optional(), status: z.string().optional(), sourceUrl: z.string().nullable().optional(), finalUrl: z.string().nullable().optional(), capturedAt: z.string().nullable().optional(), crawlProfile: z.string().nullable().optional(), failureCode: z.string().nullable().optional(), robotsTxtAllowed: z.boolean().nullable().optional(), robotsTxtReason: z.string().nullable().optional(), robotsTxtUrl: z.string().nullable().optional(), userAgent: z.string().nullable().optional(), screenshotUrl: z.string().nullable().optional(), note: z.string().optional() },
|
|
13304
13338
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
13305
13339
|
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/landing-page-crawl', a))));
|
|
13340
|
+
server.registerTool('openai_ads_hotel_report', {
|
|
13341
|
+
title: 'ChatGPT Ads Hotel Insights (per property)', description: 'Delivery per HOTEL PROPERTY for a ChatGPT Ads Hotel feed: impressions, clicks, spend, CTR, CPC and CPM per property (hotel id, name, brand, city, star rating), optionally per day or month and broken down by campaign, ad group or ad; timeseries:true returns daily totals across the selected properties instead. Filter by campaignIds / adGroupIds / adIds, exact properties [{feedId, hotelId}], or filters on hotel_name / hotel_brand / hotel_category / hotel_city / hotel_region / hotel_country_code (IN) and impressions / clicks / spend / ctr / cpc / cpm (GREATER_THAN / LESS_THAN). OpenAI’s rules, said in every reply: impressions and clicks mix settled and estimated data, spend and cost rates are settled only, and the coverage block decides whether an empty or short answer is real. No conversions and no attribution windows exist here. Hotel Insights is an OpenAI limited beta for accounts enrolled in Hotel reporting; others are refused by OpenAI. Read-only, 0 credits.',
|
|
13342
|
+
inputSchema: {
|
|
13343
|
+
since: z.string().describe('YYYY-MM-DD, account timezone'), until: z.string().describe('YYYY-MM-DD, account timezone, no later than today'),
|
|
13344
|
+
timeseries: z.boolean().optional().describe('true = daily totals across the selected properties'),
|
|
13345
|
+
granularity: z.enum(['none', 'daily', 'monthly']).optional(),
|
|
13346
|
+
breakdown: z.enum(['campaign', 'ad_group', 'ad']).optional().describe('expand each property into hierarchy rows; each row repeats its property total under `property`'),
|
|
13347
|
+
campaignIds: z.array(z.string()).optional(), adGroupIds: z.array(z.string()).optional(), adIds: z.array(z.string()).optional(),
|
|
13348
|
+
properties: z.array(z.object({ feedId: z.string(), hotelId: z.string() })).optional().describe('exact feed/property pairs'),
|
|
13349
|
+
filters: z.array(z.object({ field: z.string(), operator: z.enum(['IN', 'GREATER_THAN', 'LESS_THAN']), value: z.any() })).optional(),
|
|
13350
|
+
includeZeroActivity: z.boolean().optional().describe('also list active properties with no delivery'),
|
|
13351
|
+
sortField: z.enum(['impressions', 'clicks', 'spend', 'ctr', 'cpc', 'cpm']).optional(), sortDirection: z.enum(['asc', 'desc']).optional(),
|
|
13352
|
+
limit: z.number().optional().describe('property/time-bucket groups per page, up to 2000'), after: z.string().optional(), includeTotalCount: z.boolean().optional(),
|
|
13353
|
+
},
|
|
13354
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
13355
|
+
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/hotel-insights', a))));
|
|
13306
13356
|
|
|
13307
13357
|
server.registerTool('list_pinterest_ads_campaigns', {
|
|
13308
13358
|
title: 'List Pinterest ad accounts / campaigns',
|
|
@@ -13806,11 +13856,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13806
13856
|
}));
|
|
13807
13857
|
server.registerTool('create_reddit_ads_max_campaign', {
|
|
13808
13858
|
title: 'Build a Reddit Max campaign (paused)',
|
|
13809
|
-
description: 'Build a Reddit MAX campaign in one call: an automated campaign (campaign budget optimization, automatic bidding), its one automated ad group (seed targeting) and its one template ad, from which Reddit GENERATES the ads out of your creative-library assets. ALL CREATED PAUSED: nothing spends until the user approves and set_reddit_ads_status(confirm:true) runs on the campaign, the ad group and the template ad. Before calling, upload at least 3 HEADLINE and 2 IMAGE or VIDEO assets with upload_reddit_ads_assets (5 to 45 in total, all ACTIVE) and pass their ids, and take redditProfileId from list_reddit_ads_profiles. Every asset is read and counted by type before anything is created. Objectives: CLICKS, CONVERSIONS,
|
|
13859
|
+
description: 'Build a Reddit MAX campaign in one call: an automated campaign (campaign budget optimization, automatic bidding), its one automated ad group (seed targeting) and its one template ad, from which Reddit GENERATES the ads out of your creative-library assets. ALL CREATED PAUSED: nothing spends until the user approves and set_reddit_ads_status(confirm:true) runs on the campaign, the ad group and the template ad. Before calling, upload at least 3 HEADLINE and 2 IMAGE or VIDEO assets with upload_reddit_ads_assets (5 to 45 in total, all ACTIVE) and pass their ids, and take redditProfileId from list_reddit_ads_profiles. Every asset is read and counted by type before anything is created. Objectives: CLICKS, SALES, LEAD_GENERATION, APP_INSTALLS (and the legacy CONVERSIONS, Reddit’s old name for SALES). spendCap puts a lifetime ceiling on a DAILY_SPEND campaign. Bid strategies: BIDLESS (default), MAXIMIZE_VOLUME, TARGET_CPX (needs bidAmount). MAXIMIZE_VALUE and TARGET_ROAS cannot be set: Reddit exposes them only as read-only values. If a later step fails, what this call created is ARCHIVED and the reply says so. The reply says whether Reddit finished generating the ads (materialization); poll with get_reddit_ads_max_template.',
|
|
13810
13860
|
inputSchema: {
|
|
13811
13861
|
adAccountId: z.string().optional(),
|
|
13812
13862
|
name: z.string().describe('campaign name, 3 to 500 characters'),
|
|
13813
|
-
objective: z.enum(['CLICKS', '
|
|
13863
|
+
objective: z.enum(['CLICKS', 'SALES', 'LEAD_GENERATION', 'APP_INSTALLS', 'CONVERSIONS']).optional().describe('default CLICKS; CONVERSIONS is the legacy name of SALES'),
|
|
13814
13864
|
budget: z.number().describe('campaign budget in the ad account’s currency (a Max budget lives on the campaign)'),
|
|
13815
13865
|
goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional().describe('default DAILY_SPEND; LIFETIME_SPEND needs endTime'),
|
|
13816
13866
|
startTime: z.string().describe('ISO 8601, e.g. 2026-09-20T00:00:00Z'),
|
|
@@ -13818,7 +13868,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13818
13868
|
bidStrategy: z.enum(['BIDLESS', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional().describe('default BIDLESS'),
|
|
13819
13869
|
bidType: z.enum(['CPC', 'CPM', 'CPV6', 'CPV15']).optional().describe('default CPC for CLICKS; required for the other objectives'),
|
|
13820
13870
|
bidAmount: z.number().optional().describe('target cost per result in the ad account’s currency; required for TARGET_CPX'),
|
|
13821
|
-
optimizationGoal: z.string().optional().describe('default CLICKS for CLICKS; required
|
|
13871
|
+
optimizationGoal: z.string().optional().describe('default CLICKS for CLICKS; required otherwise: SALES / CONVERSIONS take e.g. PURCHASE, ADD_TO_CART, SIGN_UP, LEAD or CUSTOM_EVENT (a custom pixel event Reddit switches on per ad account), LEAD_GENERATION takes LEAD or SIGN_UP, APP_INSTALLS a MOBILE_CONVERSION_ goal; cannot change later'),
|
|
13872
|
+
spendCap: z.number().optional().describe('lifetime spend ceiling in the ad account’s currency; DAILY_SPEND only, at or above the daily budget'),
|
|
13873
|
+
invoiceLabel: z.string().optional().describe('up to 80 characters on the invoice; ignored on card-funded accounts'),
|
|
13822
13874
|
conversionPixelId: z.string().optional().describe('only needed when the ad account has more than one pixel'),
|
|
13823
13875
|
appId: z.string().optional().describe('App Store or Google Play id; required for APP_INSTALLS'),
|
|
13824
13876
|
specialAdCategories: z.array(z.enum(['HOUSING_EMPLOYMENT_CREDIT', 'NONE'])).optional().describe('cannot change after publishing'),
|
|
@@ -13998,16 +14050,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13998
14050
|
|
|
13999
14051
|
server.registerTool('list_reddit_ads_assets', {
|
|
14000
14052
|
title: 'List the Reddit asset library',
|
|
14001
|
-
description: 'The IMAGE and VIDEO assets stored on a Reddit profile, with status, dimensions and media urls; filter by type and name, paginate with pageToken. Reddit’s list is media-only: HEADLINE and CTA assets are stored but never listed here — read those by id with get_reddit_ads_asset (ids come from upload_reddit_ads_assets). Free.',
|
|
14053
|
+
description: 'The IMAGE and VIDEO assets stored on a Reddit profile, with status, dimensions and media urls; filter by type and name, paginate with pageToken. Reddit’s list is media-only: HEADLINE and CTA assets are stored but never listed here — read those by id with get_reddit_ads_asset (ids come from upload_reddit_ads_assets). OR pass postId (t3_…) to list the assets ONE POST uses, every type, each with its role (HEADLINE, IMAGE, VIDEO, CTA, THUMBNAIL, CAROUSEL_ITEM, HERO, SECONDARY_IMAGE) and image crop: those asset ids are what reddit_ads_report’s ASSET_ID breakdown reports on. Free.',
|
|
14002
14054
|
inputSchema: {
|
|
14003
14055
|
adAccountId: z.string().optional().describe('Reddit ad account id (a2_…) — omit when only one is shared'),
|
|
14004
|
-
redditProfileId: z.string().describe('the Reddit profile id (t2_…) from list_reddit_ads_profiles — assets hang off a profile, and it must be on the shared ad account'),
|
|
14056
|
+
redditProfileId: z.string().optional().describe('the Reddit profile id (t2_…) from list_reddit_ads_profiles — assets hang off a profile, and it must be on the shared ad account. Required unless postId is given'),
|
|
14057
|
+
postId: z.string().optional().describe('a post id (t3_…) from list_reddit_ads_posts: list the assets that post uses, with role and crop, instead of the profile library'),
|
|
14005
14058
|
types: z.array(z.enum(['IMAGE', 'VIDEO'])).optional().describe('Reddit lists media only'),
|
|
14006
14059
|
name: z.string().optional().describe('filter by asset name'),
|
|
14007
14060
|
limit: z.number().optional().describe('1–100, default 25'),
|
|
14008
14061
|
pageToken: z.string().optional(),
|
|
14009
14062
|
},
|
|
14010
|
-
outputSchema: { profileId: z.string().optional(), count: z.number().optional(), assets: z.array(z.any()).optional(), nextPageToken: z.string().nullable().optional(), note: z.string().optional() },
|
|
14063
|
+
outputSchema: { profileId: z.string().optional(), postId: z.string().optional(), count: z.number().optional(), assets: z.array(z.any()).optional(), nextPageToken: z.string().nullable().optional(), note: z.string().optional() },
|
|
14011
14064
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14012
14065
|
}, wrap(async (a) => { const d = await apiGet('/api/reddit-ads/assets', a); return ok(d.note, d); }));
|
|
14013
14066
|
|
|
@@ -17610,7 +17663,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17610
17663
|
}));
|
|
17611
17664
|
server.registerTool('update_doc', {
|
|
17612
17665
|
title: 'Edit a Google Doc in place',
|
|
17613
|
-
description: 'EDIT a Google Doc — the correction append_to_doc cannot make, which until now meant a doc could only ever grow and a wrong line stayed in it forever. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:"…"` replaces the ENTIRE body (rewrite:"" empties it), or `dropdowns:[{title, value}]` sets a dropdown chip (e.g. Status -> Approved — read_doc lists every chip with its options; pass dropdownId when two share a title; an unknown title or option is refused with the real list and nothing changes). Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.',
|
|
17666
|
+
description: 'EDIT a Google Doc — the correction append_to_doc cannot make, which until now meant a doc could only ever grow and a wrong line stayed in it forever. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:"…"` replaces the ENTIRE body (rewrite:"" empties it), or `dropdowns:[{title, value}]` sets a dropdown chip (e.g. Status -> Approved — read_doc lists every chip with its options; pass dropdownId when two share a title; an unknown title or option is refused with the real list and nothing changes); the same entry with options:[…] (the COMPLETE new list, 2–50) and/or newTitle changes the dropdown itself for every chip built from it, and an option you drop that a chip shows needs replace:{"Old":"New"}. Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.',
|
|
17614
17667
|
inputSchema: {
|
|
17615
17668
|
documentId: z.string().optional().describe('the document id (from create_doc, or list_drive_files for one the user picked)'),
|
|
17616
17669
|
docUrl: z.string().optional().describe('a Google Docs URL — the id is extracted from it'),
|
|
@@ -17618,11 +17671,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17618
17671
|
rewrite: z.string().optional().describe('replace the WHOLE body with this text ("" empties the doc)'),
|
|
17619
17672
|
confirm: z.boolean().optional(), confirmCells: z.number().optional().describe('echo back the character count the unconfirmed call reported (rewrite only)'),
|
|
17620
17673
|
dropdowns: z.array(z.object({ title: z.string().optional().describe('the dropdown title (from read_doc)'), dropdownId: z.string().optional().describe('when two dropdowns share a title'),
|
|
17621
|
-
tabId: z.string().optional(), value: z.string().describe('the option to select, by its display text')
|
|
17674
|
+
tabId: z.string().optional(), value: z.string().optional().describe('the option to select, by its display text'),
|
|
17675
|
+
options: z.array(z.string()).optional().describe('change the dropdown itself: the COMPLETE new option list (2–50) by display text; existing options keep their colour; shared by every chip built from it'),
|
|
17676
|
+
newTitle: z.string().optional().describe('rename the dropdown'),
|
|
17677
|
+
replace: z.record(z.string()).optional().describe('for an option you remove that a chip shows: {"Old": "New"}') })).optional().describe('dropdown chips to set, or dropdowns to change'),
|
|
17622
17678
|
},
|
|
17623
17679
|
outputSchema: { ok: z.boolean().optional(), documentId: z.string().optional(), title: z.string().optional(), url: z.string().optional(), occurrences: z.number().optional(), replacedChars: z.number().optional(), verified: z.boolean().nullable().optional(), text: z.string().nullable().optional(),
|
|
17624
17680
|
replacements: z.array(z.object({ find: z.string().optional(), replace: z.string().optional(), occurrences: z.number().optional() })).optional(),
|
|
17625
17681
|
dropdowns: z.array(z.object({ dropdownId: z.string().optional(), title: z.string().optional(), from: z.string().optional(), to: z.string().optional(), changed: z.boolean().optional(), verified: z.boolean().nullable().optional() })).optional(),
|
|
17682
|
+
definitions: z.array(z.object({ definitionId: z.string().optional(), title: z.string().optional(), previousTitle: z.string().optional(), options: z.array(z.string()).nullable().optional(), previousOptions: z.array(z.string()).optional(), chips: z.number().optional(), verified: z.boolean().nullable().optional() })).optional(),
|
|
17626
17683
|
note: z.string().optional() },
|
|
17627
17684
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
17628
17685
|
}, wrap(async (a) => {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.378",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "Marketing on autopilot, run from your own AI agent.
|
|
5
|
+
"description": "Marketing on autopilot, run from your own AI agent. 897 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 and Telegram. 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"
|