hermoso 0.1.275 → 0.1.279
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/client.mjs +5 -1
- package/mcp/tools.mjs +27 -19
- package/package.json +1 -1
package/mcp/client.mjs
CHANGED
|
@@ -92,7 +92,11 @@ function headers(extra = {}) {
|
|
|
92
92
|
// their own activity, which is why this decides bookkeeping and nothing else.
|
|
93
93
|
if (ctx) h['x-hermoso-inproc'] = '1';
|
|
94
94
|
if (process.env.EDGE_SECRET) h['x-edge-auth'] = process.env.EDGE_SECRET; // belt: in-process self-calls satisfy the edge shield even if the loopback exemption ever changes
|
|
95
|
-
|
|
95
|
+
// THE SAME RULE FOR THE BEARER (2026-09-23). This read `ctx?.token || TOKEN`, so a hosted request whose ctx carried
|
|
96
|
+
// no token would have been sent with the SERVER process's own HERMOSO_TOKEN — an operator credential standing in for
|
|
97
|
+
// a customer's missing one, the exact class lib/operator-credentials.mjs exists to end. A remote ctx carries its own
|
|
98
|
+
// bearer or none; only stdio / the CLI, where the process IS the caller, reads the environment.
|
|
99
|
+
const tok = ctx ? (ctx.token || '') : TOKEN;
|
|
96
100
|
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
97
101
|
return h;
|
|
98
102
|
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -146,7 +146,7 @@ export const INDEPENDENCE = 'INDEPENDENT AREAS, NOT A PIPELINE — research, cre
|
|
|
146
146
|
// from any text that returns). Apple Ads' required set depends on which key path is used, so its handler checks it.
|
|
147
147
|
export const KEY_CONNECTORS = {
|
|
148
148
|
stripe: { label: 'Stripe', route: 'stripe', fields: { apiKey: 'rs' }, how: 'a secret (sk_) or restricted (rk_) key from Stripe ▸ Developers ▸ API keys' },
|
|
149
|
-
openai_ads: { label: 'ChatGPT Ads', route: 'openai-ads', fields: { apiKey: 'rs' }, how: 'an Advertiser API key from ChatGPT Ads Manager ▸ Settings ▸ API
|
|
149
|
+
openai_ads: { label: 'ChatGPT Ads', route: 'openai-ads', fields: { apiKey: 'rs' }, how: 'an Advertiser API key from ChatGPT Ads Manager ▸ Settings ▸ General ▸ API Keys ▸ Create New Key' },
|
|
150
150
|
apple_ads: { label: 'Apple Ads', route: 'apple-ads', fields: { clientId: '', teamId: '', keyId: '', privateKey: 's', setupToken: 's', orgId: '' }, how: 'clientId, teamId and keyId (Apple Ads ▸ Account Settings ▸ API shows all three once a public key is saved there), plus privateKey for a key already registered with Apple, or setupToken from a first call with no fields, which generates the key pair' },
|
|
151
151
|
bluesky: { label: 'Bluesky', route: 'bluesky', fields: { identifier: 'r', appPassword: 'rs', pds: '' }, how: 'the handle and an APP password from Bluesky ▸ Settings ▸ Privacy and Security ▸ App Passwords (pds only for a self-hosted server)' },
|
|
152
152
|
telegram: { label: 'Telegram', route: 'telegram', fields: { token: 'rs' }, how: 'the bot token from @BotFather ▸ /mybots ▸ your bot ▸ API Token' },
|
|
@@ -450,7 +450,7 @@ const wrap = (fn) => {
|
|
|
450
450
|
// Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
|
|
451
451
|
// META'S SECURITY HOLD (code 31/3858385): the server's sentence already carries the steps; this names the move
|
|
452
452
|
// so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
|
|
453
|
-
if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security
|
|
453
|
+
if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, open Ads Manager within 24 hours and follow the red banner, or if there is none create or slightly edit an ad in Ads Manager, wait for Verifying your edits, then follow the Fix errors prompt to the email verification; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
|
|
454
454
|
if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
|
|
455
455
|
}
|
|
456
456
|
// ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
|
|
@@ -17209,7 +17209,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17209
17209
|
|
|
17210
17210
|
server.registerTool('make_template_ad', {
|
|
17211
17211
|
title: 'Make template ad',
|
|
17212
|
-
description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML
|
|
17212
|
+
description: "Render a NATIVE-STYLE TEMPLATE ad or organic post from pure HTML: no AI model, about 30 seconds, a couple of credits. YOU author the copy: short, casual, believable, never marketing-speak, every line a finished phrase within its budget. Pass config.template plus its fields. 'slideshow' (IMAGES: a native photo slideshow for TikTok photo mode / Reels at 1080x1920, or feed carousels with size:'4:5' at 1080x1350; no branding, no end card): { slides:[{text, sub?, image?, blur?, background?:'#hex', position?}] (2-35; slide 1 is the hook, then one point per slide; the words are never rewritten), style?:'tiktok-classic'|'clean-minimal'|'note-style', textStyle? (add_subtitles' vocabulary), video?:true (also an MP4, about 2.5s a slide, for Shorts / X) }; returns images[] (+video) that post_to_tiktok imageUrls and post_to_meta carousels take as-is; 2 credits, +1 per slide past 5, +2 for the MP4. 'imessage-chat' (VIDEO ~15s): { thread:{contactName, messages:[{from:'them'|'me', text?, product?:{image,title,domain}}]}, theme?, endCard }, 4-6 short lowercase bubbles, the product card from 'me' mid-thread. 'chatgpt-chat' (VIDEO): { question, answer (may **bold** the brand), productImage?, endCard }. 'apple-notes' (VIDEO): { title, lines[], theme?, endCard }. 'value-prop' (VIDEO ~17s): { hook (≤40ch), claims[3-5 finished phrases ≤34ch], productImages[2-3 distinct], palette[], endCard }. 'static-mockup' (IMAGE): { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }. 'airdrop-carousel' (VIDEO ~10s): { brandName, products:[{image, title?}] (3-16 real photos), contactLine?, endCard }. 'app-ui-tour' (VIDEO, app brands): { hook?, appName, iconImage?, beats:[{screenImage, caption}] (2-6), endCard }. 'imessage-cascade' (VIDEO ~12s): { notifications:[{sender, text}] (4-8), backgroundImage?, endCard }. 'photo-grid' (VIDEO ~8s): { title?, photos:[{image, label?}] (4-9), endCard }. 'vignette' (VIDEO ~12s): { hook, lines[2-4 ≤40ch], heroImage, endCard }. 'kinetic-type' (VIDEO 9-15s, no voiceover, its own SFX): { phrases[3-6 ≤34ch, one idea each], productImages?[≤4], endCard }, pure typography when there are no photos. 'myth-vs-fact' (VIDEO 15-26s with a real VOICEOVER and word karaoke): { pairs:[{myth ≤50ch, fact ≤60ch, [brackets] accent the payoff}] (2-4), endCard }, real product truths only, never invented stats, plus a small voiceover charge. 'carousel' (IMAGES: 5-10 branded 1080x1080 PNGs): { cover:{hook?, title}, slides:[{headline ≤8 words, support? ≤16 words, stat?:{value, label}}] (3-8; a stat is a real user number), cta:{headline, cta?, domain?}, productImage?, logo? }. endCard = { headline, cta, domain?, logo?, color? }; palette and fontStack are optional everywhere. Every VIDEO format except myth-vs-fact gets a mood-matched bed from the curated library when one is on file (free, never a generated track; config.music:'off' or a mood name). Image URLs may be any public URL.",
|
|
17213
17213
|
inputSchema: {
|
|
17214
17214
|
config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
|
|
17215
17215
|
},
|
|
@@ -17221,7 +17221,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17221
17221
|
if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
|
|
17222
17222
|
const urls = r.raw.images.map((u) => abs(u));
|
|
17223
17223
|
const first = await imageBlock(urls[0]).catch(() => null);
|
|
17224
|
-
|
|
17224
|
+
const vid = r.raw.video ? `\nVideo version (${Math.round(r.raw.durationSeconds || 0)}s): ${abs(r.raw.video)}` : '';
|
|
17225
|
+
const notes = Array.isArray(r.raw.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : '';
|
|
17226
|
+
return { content: [{ type: 'text', text: `${String(a.config?.template || '') === 'slideshow' ? 'Slideshow' : 'Carousel'} ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')}${vid}${notes} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? {} };
|
|
17225
17227
|
}
|
|
17226
17228
|
if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]` }, ...(img ? [img] : [])], structuredContent: r ?? {} }; }
|
|
17227
17229
|
return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
@@ -17249,19 +17251,24 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17249
17251
|
|
|
17250
17252
|
server.registerTool('post_edit', {
|
|
17251
17253
|
title: 'Post-production edit',
|
|
17252
|
-
description: "MECHANICAL post-production on an EXISTING
|
|
17254
|
+
description: "MECHANICAL post-production on an EXISTING video (its URL): an ordered plan of whitelisted primitives run by ffmpeg (+ Chrome for type) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). Ops: a branded end card (ADDS its seconds, never re-renders), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, watermark (corner logo), grain (anti-AI), text (timed words over the clip in a native look, no branding: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle, position, start/end), join (this video FOLLOWED BY clips[], each a Library URL, a direct file or a public TikTok / Reel / Facebook / X / YouTube post link, as one 1080x1920 video with matched loudness; transition 'cut' or 'crossfade'). Up to 6 ops, in order. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}]}]. Brand assets load from the workspace brand. NEVER use generate_video/render_ad for these.",
|
|
17253
17255
|
inputSchema: {
|
|
17254
|
-
videoUrl: z.string().describe('the
|
|
17256
|
+
videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
|
|
17255
17257
|
ops: z.array(z.object({
|
|
17256
|
-
op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain']),
|
|
17257
|
-
start: z.number().optional().describe('trim/mute window start (s)'),
|
|
17258
|
-
end: z.number().optional().describe('trim/mute window end (s)'),
|
|
17258
|
+
op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain', 'text', 'join']),
|
|
17259
|
+
start: z.number().optional().describe('trim/mute/text window start (s)'),
|
|
17260
|
+
end: z.number().optional().describe('trim/mute/text window end (s)'),
|
|
17261
|
+
text: z.string().optional().describe('text: the words, verbatim'),
|
|
17262
|
+
position: z.enum(['top', 'center', 'lower', 'bottom']).optional().describe('text: where'),
|
|
17263
|
+
style: z.union([z.string(), z.object({}).passthrough()]).optional().describe('text: a look name or a textStyle'),
|
|
17264
|
+
clips: z.array(z.object({ url: z.string(), start: z.number().optional(), end: z.number().optional() })).optional().describe('join: the clips after this video'),
|
|
17265
|
+
transition: z.enum(['cut', 'crossfade']).optional().describe('join'),
|
|
17259
17266
|
factor: z.number().optional().describe('speed 0.5-2'),
|
|
17260
17267
|
db: z.number().optional().describe('audio_gain -20..+6 dB'),
|
|
17261
|
-
seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s'),
|
|
17268
|
+
seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s / crossfade 0.2-1.5s'),
|
|
17262
17269
|
headline: z.string().optional().describe('append_card: big line (defaults to the brand name)'),
|
|
17263
17270
|
tagline: z.string().optional().describe('append_card: smaller line under the headline'),
|
|
17264
|
-
sub: z.string().optional().describe('append_card: the pill line (defaults to the
|
|
17271
|
+
sub: z.string().optional().describe('append_card: the pill line (defaults to the website) / text: a smaller second line'),
|
|
17265
17272
|
background: z.string().optional().describe("append_card: card background — hex or a color name ('red', 'navy'…); the user's stated color always wins over the brand palette"),
|
|
17266
17273
|
card_html: z.string().optional().describe('append_card: your OWN full-frame card design as inline-styled HTML ({{logo}} inserts the real brand logo) — use when the standard layout cannot honor the request'),
|
|
17267
17274
|
corner: z.enum(['tl', 'tr', 'bl', 'br']).optional().describe('watermark corner (default br)'),
|
|
@@ -17277,7 +17284,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17277
17284
|
let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // via /api/store/bootstrap — there is no GET /api/store/:key route
|
|
17278
17285
|
const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
|
|
17279
17286
|
const r = await renderJob('postedit', { videoUrl: a.videoUrl, ops: (a.ops || []).slice(0, 6), brandName: a.brandName || b.name || '', domain: a.domain || b.domain || '', logo: b.logo || '', accent: a.accent || pal[0] || '' }, 'MCP post edit');
|
|
17280
|
-
return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''} [job ${r.jobId}]`, r);
|
|
17287
|
+
return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : ''} [job ${r.jobId}]`, r);
|
|
17281
17288
|
}));
|
|
17282
17289
|
|
|
17283
17290
|
server.registerTool('fix_beat', {
|
|
@@ -19975,24 +19982,25 @@ function memoryNoteVerdict(text) {
|
|
|
19975
19982
|
|
|
19976
19983
|
server.registerTool('list_published_posts', {
|
|
19977
19984
|
title: 'List what this brand has published',
|
|
19978
|
-
description: "List every post Hermoso has recorded publishing for this brand — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement.
|
|
19985
|
+
description: "List every post Hermoso has recorded publishing for this brand, newest first, across all channels — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. The WHOLE history, no cap: pass the reply's nextCursor as `cursor` for older posts. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author intended) or 'backfilled' (reconstructed from the platform afterwards). A dash for engagement means the platform reported no number — NOT zero. Read-only, 0 credits.",
|
|
19979
19986
|
inputSchema: {
|
|
19980
19987
|
...MANAGE_BRAND,
|
|
19981
19988
|
channel: z.string().optional().describe('filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest, google_business'),
|
|
19982
19989
|
limit: z.number().optional().describe('max posts (default 50, max 200), newest first'),
|
|
19990
|
+
cursor: z.string().optional().describe('nextCursor from a previous reply: the next, older page'),
|
|
19983
19991
|
},
|
|
19984
|
-
outputSchema: { posts: z.array(z.any()).optional(), total: z.number().optional(), windows: z.array(z.string()).optional() },
|
|
19992
|
+
outputSchema: { posts: z.array(z.any()).optional(), total: z.number().optional(), nextCursor: z.string().nullable().optional(), windows: z.array(z.string()).optional() },
|
|
19985
19993
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
19986
19994
|
}, wrap(async (a) => {
|
|
19987
|
-
const d = await apiGet('/api/posts', queryBrand(a, { ...(a.channel ? { channel: a.channel } : {}), ...(a.limit ? { limit: a.limit } : {}) }));
|
|
19995
|
+
const d = await apiGet('/api/posts', queryBrand(a, { ...(a.channel ? { channel: a.channel } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) }));
|
|
19988
19996
|
const posts = d.posts || [];
|
|
19989
|
-
if (!posts.length) return ok('No published posts recorded for this brand yet. Everything published from now on is recorded automatically; to import history, call backfill_posts for a channel.', d);
|
|
19997
|
+
if (!posts.length) return ok(a.cursor ? 'No older posts: that was the start of this brand\'s history.' : 'No published posts recorded for this brand yet. Everything published from now on is recorded automatically; to import history, call backfill_posts for a channel.', d);
|
|
19990
19998
|
const rows = posts.map(p => {
|
|
19991
19999
|
const er = p.engagement || {};
|
|
19992
20000
|
const eng = er.present ? `${(er.rate * 100).toFixed(2)}%` : `— (${er.reason || 'not measured'})`;
|
|
19993
20001
|
return `• ${p.channel} · ${String(p.publishedAt ? new Date(p.publishedAt).toISOString().slice(0, 10) : '?')} · ${p.media} — ${String(p.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 60)}\n hook: ${p.hook || `(none — ${p.attribution})`} · engagement ${eng}${p.url ? ` · ${p.url}` : ''}`;
|
|
19994
20002
|
});
|
|
19995
|
-
return ok(`${posts.length} of ${d.total} recorded post(s):\n${rows.join('\n')}\n\nA dash is "the platform reported no number", never zero engagement
|
|
20003
|
+
return ok(`${posts.length} of ${d.total} recorded post(s):\n${rows.join('\n')}\n\nA dash is "the platform reported no number", never zero engagement.${d.nextCursor ? `\nOlder posts follow: pass cursor:"${d.nextCursor}".` : ''}`, d);
|
|
19996
20004
|
}));
|
|
19997
20005
|
|
|
19998
20006
|
server.registerTool('list_hooks', {
|
|
@@ -20037,7 +20045,7 @@ function memoryNoteVerdict(text) {
|
|
|
20037
20045
|
...MANAGE_BRAND,
|
|
20038
20046
|
axis: z.enum(['hook', 'subject', 'recipe', 'channel', 'media', 'hour']).optional().describe('what to group by — default hook; recipe = the format of the creative'),
|
|
20039
20047
|
channel: z.string().optional().describe('restrict to one channel'),
|
|
20040
|
-
days: z.number().optional().describe('look back N days (1-730)
|
|
20048
|
+
days: z.number().optional().describe('look back N days (1-730) over the whole history; omit for the recent posts only'),
|
|
20041
20049
|
},
|
|
20042
20050
|
outputSchema: { axis: z.string().optional(), groups: z.array(z.any()).optional(), finding: z.any().optional(), excludedUnattributed: z.number().optional(), minN: z.number().optional(), totalPosts: z.number().optional(), trend: z.any().optional(), leaderboard: z.any().optional(), health: z.any().optional(), followers: z.any().optional(), archive: z.any().optional(), days: z.number().optional() },
|
|
20043
20051
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
@@ -20060,7 +20068,7 @@ function memoryNoteVerdict(text) {
|
|
|
20060
20068
|
// FOLLOWERS OVER TIME (2026-09-23): one count per account per day from the nightly snapshot; a count that could not be
|
|
20061
20069
|
// read is printed as unknown WITH its reason — never as 0.
|
|
20062
20070
|
const folTxt = Array.isArray(d.followers) ? d.followers.slice(0, 12).map(f => `• ${f.channel}${f.label ? ` ${f.label}` : ''}: ${f.last ? `${fmtN(f.last.followers)} on ${f.last.day}${f.change != null && f.first && f.first.day !== f.last.day ? ` (${f.change >= 0 ? '+' : ''}${fmtN(f.change)} since ${f.first.day})` : ''}` : 'unknown'}${f.lastWhy ? ` — latest read unknown: ${f.lastWhy.why}` : ''}`) : [];
|
|
20063
|
-
const archTxt = d.archive?.used ? `\n\nIncludes ${d.archive.rows} older post(s) from the brand's archive
|
|
20071
|
+
const archTxt = d.archive?.used ? `\n\nIncludes ${d.archive.rows} older post(s) from the brand's archive.` : (d.archive?.unreadable ? `\n\n⚠ ${d.archive.why}` : '');
|
|
20064
20072
|
const overTime = `${boardTxt.length ? `\n\nBEST AND WORST POSTS (last ${d.days || 30} days):\n${boardTxt.join('\n')}` : ''}${trendTxt.length ? `\n\nOVER TIME (last ${d.trend.weeks.length} weeks, 7-day readings where they exist):\n${trendTxt.join('\n')}` : ''}${healthTxt.length ? `\n\nMEASUREMENT GAPS:\n${healthTxt.join('\n')}` : ''}${folTxt.length ? `\n\nFOLLOWERS (daily snapshot):\n${folTxt.join('\n')}` : (d.followers?.unreadable ? `\n\nFOLLOWERS: ${d.followers.why}` : '')}${archTxt}`;
|
|
20065
20073
|
if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim() + overTime, d);
|
|
20066
20074
|
const rows = gs.map(g => `• "${g.key}" · ${g.channel} — ${g.meanRate == null ? (g.meanEngagement == null ? 'no measurable engagement' : `${g.meanEngagement.toFixed(1)} engagements (no reach denominator on this channel, so no rate)`) : `${(g.meanRate * 100).toFixed(2)}% engagement`} · ${g.n} post(s), ${g.nRated} measured${g.verdict === 'ready' ? '' : ` — ${g.suppressed}`}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.279",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 856 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 and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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",
|