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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/mcp/tools.mjs +98 -41
  3. 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
- **896 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
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 896 tools cover
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, and it was
5692
- // browser-only for about an hour. These three wrap the SAME routes the app uses; there is no second queue —
5693
- // every post the refill makes is an ordinary scheduled item that list_scheduled shows and cancel_scheduled cancels.
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: 'Posting refill status',
5696
- description: 'Show the automatic posting refill for this profile: whether it is on, whether it is in dry-run (preview) mode, how many days ahead it fills, its render budget, when it next runs, and how many posts are queued right now. It also names the channels that CANNOT be posted to and why. Read-only, free.',
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(), dryRun: z.boolean().optional(), daysAhead: z.number().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(), postingSchedule: z.any().optional(), excluded: z.any().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 cadence = d.postsPerDay > 0 ? d.postsPerDay : (d.postingSchedule?.slots || []).length;
5707
- const state = !d.enabled ? 'OFF' : (d.dryRun ? 'ON, but in DRY RUN — it plans and previews, it queues nothing' : 'ON and LIVE — it queues real posts');
5708
- return ok(`Posting refill: ${state}.\n${cadence} post(s) a day at ${(d.postingSchedule?.slots || []).join(', ') || 'no posting times set'} ${d.postingSchedule?.timezone || 'UTC'}, filling ${d.daysAhead} day(s) ahead. ${d.queuedPosts} post(s) queued now.\nRender budget: ${d.maxImagesPerDay || 0} image(s) + ${d.maxVideosPerDay || 0} video(s) a day, ${d.maxCreditsPerDay || 0} credit ceiling — at 0 it reuses the Library and spends nothing on creative.\nCannot post to: ${Object.entries(d.excluded || {}).map(([c, why]) => `${c} — ${why}`).join('\n ') || 'nothing'}`, d);
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 the posting refill',
5712
- description: 'Turn the automatic posting refill on or off and set how it behaves. PASS ONLY WHAT CHANGES. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled if you want them gone). It starts in dryRun, which plans and previews without queueing; set dryRun:false only once a human has read a preview from run_post_refill. THE CADENCE IS THE BRAND’S POSTING TIMES, not a number here: three posting times means three posts a day. Raising maxImagesPerDay / maxVideosPerDay / maxCreditsPerDay above 0 lets it SPEND on new creative — at 0 (the default) it only reuses renders already in the Library and costs nothing.',
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 queued. Already-queued posts are untouched.'),
5715
- dryRun: z.boolean().optional().describe('true (the default) = plan and preview only, queue nothing. Set false ONLY after a human has read a preview.'),
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
- assetCooldownDays: z.number().optional().describe('how long before a Library render may be posted again (default 30). It never repeats one inside this window — it queues fewer posts and says so.'),
5719
- maxImagesPerDay: z.number().optional().describe('how many NEW images a day it may render when the Library runs dry. 0 (default) = none, spend nothing.'),
5720
- maxVideosPerDay: z.number().optional().describe('how many NEW videos a day it may render. 0 (default) = none. Video is the expensive one — hundreds of credits each.'),
5721
- maxCreditsPerDay: z.number().optional().describe('a hard credit ceiling per day, checked BEFORE any render starts. It binds independently of the counts above.'),
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(), dryRun: z.boolean().optional(), running: z.boolean().optional(), daysAhead: z.number().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 d = await apiPut('/api/schedule/refill', a);
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
- ? `Posting refill is ON${d.dryRun ? ' in DRY RUN — it will preview a plan each day and queue nothing. Run run_post_refill to see the plan, and only set dryRun:false once a human has read one.' : ' and LIVE — it will queue real posts every day.'}`
5734
- : 'Posting refill is OFF. The recurring job is gone and nothing new will be queued. Posts already in the calendar are untouched — cancel those with cancel_scheduled if you want them gone.', d);
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 the posting refill',
5738
- description: 'Run the refill NOW instead of waiting for its daily turn. DRY BY DEFAULT: it returns the exact posts it WOULD queue — the caption, the creative, the channels and the per-channel visibility — without queueing anything or spending anything on creative. Pass dryRun:false to actually queue them. SHOW THE PREVIEW TO THE USER BEFORE EVER PASSING dryRun:false; these go onto real public accounts. Every caption is screened against the brand’s own voice rules and a failing one is dropped, so a plan can legitimately come back shorter than the cadence — the reason is in the notes.',
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 only). false actually queues the posts.'),
5741
- force: z.boolean().optional().describe('plan even while the refill is switched off — useful for showing someone what it would do before they turn it on. Combined with dryRun:false it still respects a stored dryRun.'),
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 creative: ${p.kind} · ${p.asset?.model || 'Library render'} (${p.asset?.credits || 0} credits)`);
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 AND NOTHING WAS SPENT — this was a preview. Show it to the user; call again with dryRun:false only if they say yes.' : `\n${q} post(s) 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, APP_INSTALLS. 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.',
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', 'CONVERSIONS', 'APP_INSTALLS']).optional().describe('default 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 for CONVERSIONS (e.g. PURCHASE, SIGN_UP, LEAD, ADD_TO_CART, or CUSTOM_EVENT for a custom pixel event, which Reddit switches on per ad account) and APP_INSTALLS; cannot change later'),
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') })).optional().describe('dropdown chips to set'),
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.374",
3
+ "version": "0.1.378",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "Marketing on autopilot, run from your own AI agent. 896 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.",
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"