hermoso 0.1.228 → 0.1.230
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/roster-scope.mjs +13 -0
- package/mcp/tools.mjs +31 -20
- package/package.json +1 -1
package/mcp/roster-scope.mjs
CHANGED
|
@@ -131,10 +131,23 @@ export function toolProvider(name) {
|
|
|
131
131
|
// Returns TRUE only when we KNOW the read succeeded AND we can attribute the tool to a provider AND that provider
|
|
132
132
|
// is not connected. Every other answer is FALSE, i.e. keep it — which is properties 1 and 2 expressed as the
|
|
133
133
|
// default rather than as two branches somebody could forget to write.
|
|
134
|
+
// AN INSTAGRAM LOGIN CONNECTION SERVES PART OF THE META FAMILY (2026-09-12). Each tool below is filed under `meta`
|
|
135
|
+
// by name, but its server route resolves an Instagram Login token when the brand has one (igStandaloneFor in
|
|
136
|
+
// metaPublish, igGraphFor, igDmCtx, igAccountInsights, igEngage, the collab and audio readers). So a brand that
|
|
137
|
+
// connected only Instagram, with no Facebook Page and no Meta connection, must still be offered them: before this it
|
|
138
|
+
// could connect Instagram and then had no tool to post, reply, read its inbox or measure with. Only routes verified
|
|
139
|
+
// to take the Login token are listed; a Page-only tool (ads, hashtag search, comment_on_meta_post) stays gated on meta.
|
|
140
|
+
export const INSTAGRAM_LOGIN_TOOLS = new Set([
|
|
141
|
+
'post_to_meta', 'list_meta_posts', 'list_instagram_media', 'instagram_insights', 'meta_post_insights',
|
|
142
|
+
'list_meta_comments', 'reply_to_meta_comment', 'moderate_meta_comment', 'like_instagram',
|
|
143
|
+
'list_meta_conversations', 'read_meta_conversation', 'reply_to_meta_message',
|
|
144
|
+
'list_instagram_collab_invites', 'list_instagram_collab_media', 'respond_instagram_collab_invite', 'search_instagram_audio',
|
|
145
|
+
]);
|
|
134
146
|
export function toolHeldBackByConnectors(name, conn) {
|
|
135
147
|
if (!conn || !conn.readOk) return false; // property 1 — fail OPEN on an unreadable store
|
|
136
148
|
const p = toolProvider(name);
|
|
137
149
|
if (p === null) return false; // property 2 — unmapped is never held back
|
|
138
150
|
const on = conn.connected instanceof Set ? conn.connected : new Set(conn.connected || []);
|
|
151
|
+
if (p === 'meta' && on.has('instagram') && INSTAGRAM_LOGIN_TOOLS.has(name)) return false;
|
|
139
152
|
return !on.has(p);
|
|
140
153
|
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -3049,7 +3049,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3049
3049
|
}, wrap(async () => {
|
|
3050
3050
|
const d = await apiGet('/api/credits');
|
|
3051
3051
|
const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
|
|
3052
|
-
|
|
3052
|
+
// THE CHARGES THEMSELVES (2026-09-12): the same ledger list as Billing ▸ Usage, so "where did my credits go" has an
|
|
3053
|
+
// answer here too. Best effort: a reply about the balance never fails on the history read.
|
|
3054
|
+
let recent = ''; try { const u = await apiGet('/api/billing/usage', { limit: 10 }); if (u?.items?.length) recent = `\nRecent charges (newest first):\n${u.items.map((r) => `• ${String(r.at).slice(0, 16).replace('T', ' ')} UTC · ${r.label} · ${r.credits} credits`).join('\n')}`; } catch {}
|
|
3055
|
+
return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}. ${d.costModel || 'Credits are spent ONLY on AI models and Ad Spy research; posting, scheduling, campaign management, analytics and connectors are free (X is the one per-call exception).'}${recent}`, d);
|
|
3053
3056
|
}));
|
|
3054
3057
|
|
|
3055
3058
|
// AGENT BILLING: out of credits → top up. With a saved card + billing-admin rights this is the SAME one-click
|
|
@@ -4180,7 +4183,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4180
4183
|
// no need for one edge case just for Facebook."
|
|
4181
4184
|
server.registerTool('schedule_post', {
|
|
4182
4185
|
title: 'Schedule a post for later',
|
|
4183
|
-
description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word
|
|
4186
|
+
description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
|
|
4184
4187
|
inputSchema: {
|
|
4185
4188
|
...HOOK_ATTR,
|
|
4186
4189
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
|
|
@@ -4244,16 +4247,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4244
4247
|
actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE.'),
|
|
4245
4248
|
event: z.object({ title: z.string().optional(), startDate: z.string().optional(), startTime: z.string().optional(), endDate: z.string().optional(), endTime: z.string().optional() }).optional().describe('GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:"YYYY-MM-DD", endDate, startTime:"HH:MM", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them.'),
|
|
4246
4249
|
offer: z.object({ couponCode: z.string().optional(), redeemOnlineUrl: z.string().optional(), termsConditions: z.string().optional() }).optional().describe('GOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer.'),
|
|
4247
|
-
thread: z.array(z.string()).optional().describe('X — publish a THREAD, one entry per post, each replying to the one before (at most 25). 280 characters per part without X Premium, up to 25,000 with it
|
|
4250
|
+
thread: z.array(z.string()).optional().describe('X — publish a THREAD, one entry per post, each replying to the one before (at most 25). 280 characters per part without X Premium, up to 25,000 with it; nothing is truncated. It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll.'),
|
|
4248
4251
|
poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — attach a poll: {options:["…","…"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post.'),
|
|
4249
4252
|
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply. Omit for everyone, which is the right default for a brand post.'),
|
|
4250
4253
|
madeWithAi: z.boolean().optional().describe('X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf.'),
|
|
4251
4254
|
// THE THREE X FIELDS `xPost` HAS ACCEPTED SINCE THE DAY THEY LANDED AND NOTHING COULD SCHEDULE (2026-08-26).
|
|
4252
4255
|
// Same publisher-can/scheduler-cannot shape as SCHED_ID_FIELDS and the five Threads options one channel over:
|
|
4253
4256
|
// reachable when you publish NOW, unreachable when you schedule, and invisible until the parity sweep named it.
|
|
4254
|
-
xQuotePostId: z.string().optional().describe('X — the numeric id of an X post this one QUOTES: the last part of its URL.
|
|
4255
|
-
communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it
|
|
4256
|
-
paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP
|
|
4257
|
+
xQuotePostId: z.string().optional().describe('X — the numeric id of an X post this one QUOTES: the last part of its URL. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS. Billed at X’s higher LINK rate.'),
|
|
4258
|
+
communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it.'),
|
|
4259
|
+
paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.'),
|
|
4260
|
+
// AN X ARTICLE, SCHEDULED (2026-09-12). A mode of the x channel, not a channel: the X text is the markdown body
|
|
4261
|
+
// and the image is the cover, so the only new fields are the two the Article itself adds.
|
|
4262
|
+
xArticle: z.object({ title: z.string(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried.'),
|
|
4257
4263
|
collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
|
|
4258
4264
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
|
|
4259
4265
|
aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
|
|
@@ -4266,17 +4272,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4266
4272
|
locationId: z.string().optional().describe("GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user."),
|
|
4267
4273
|
visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional().describe("how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason."),
|
|
4268
4274
|
visibilityByChannel: z.record(z.string()).optional().describe('override visibility for one channel, e.g. { "tiktok": "draft" } to go live everywhere but stage TikTok for review'),
|
|
4269
|
-
optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written
|
|
4275
|
+
optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.'),
|
|
4270
4276
|
},
|
|
4271
4277
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), label: z.string().optional() },
|
|
4272
4278
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
4273
4279
|
}, wrap(async (a) => {
|
|
4274
4280
|
const d = await apiPost('/api/schedule', a);
|
|
4275
|
-
return ok(`Scheduled for ${d.at} → ${(d.channels || []).join(', ')}. It is NOT posted yet; Hermoso publishes it at that time (id ${d.id}).${a.visibility && a.visibility !== 'public' ? ` Visibility: ${a.visibility} (as asked) — it will NOT be publicly live.` : ' It will go LIVE publicly.'}`, d);
|
|
4281
|
+
return ok(`Scheduled for ${d.at} → ${(d.channels || []).join(', ')}. It is NOT posted yet; Hermoso publishes it at that time (id ${d.id}).${a.visibility && a.visibility !== 'public' ? ` Visibility: ${a.visibility} (as asked) — it will NOT be publicly live.` : ' It will go LIVE publicly.'}${d.note ? ` ${d.note}` : ''}`, d);
|
|
4276
4282
|
}));
|
|
4277
4283
|
server.registerTool('list_scheduled', {
|
|
4278
4284
|
title: 'List scheduled and past posts',
|
|
4279
|
-
description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Read-only, 0 credits.',
|
|
4285
|
+
description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). Read-only, 0 credits.',
|
|
4280
4286
|
inputSchema: {},
|
|
4281
4287
|
outputSchema: {
|
|
4282
4288
|
scheduled: z.array(z.object({ id: z.string().optional(), at: z.string().nullable().optional(), channels: z.array(z.string()).optional(), message: z.string().optional(), status: z.string().optional() })).optional(),
|
|
@@ -4291,7 +4297,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4291
4297
|
// full of failures as a calendar that ran.
|
|
4292
4298
|
const bad = (d.history || []).filter(r => r.status === 'error' || (Array.isArray(r.results) && r.results.some(x => x && x.ok === false)));
|
|
4293
4299
|
const badLine = bad.length ? ` ${bad.length} of those did NOT fully publish: ${bad.slice(0, 5).map(r => `${r.id} (${(Array.isArray(r.results) ? r.results.filter(x => x && x.ok === false).map(x => `${x.channel}: ${x.error || 'failed'}`).join('; ') : '') || r.error || 'failed'})`).join(' · ')}.` : '';
|
|
4294
|
-
|
|
4300
|
+
// A QUEUED X ARTICLE IS NAMED IN THE SENTENCE: in a channel list it reads exactly like an ordinary X post, and
|
|
4301
|
+
// its title is what tells an agent which queued item is the long-form one.
|
|
4302
|
+
const arts = (d.scheduled || []).filter(r => r && r.xArticle && r.xArticle.title);
|
|
4303
|
+
return ok(`${q} post${q === 1 ? '' : 's'} queued, ${h} already fired.${badLine}${arts.length ? ` X Article${arts.length === 1 ? '' : 's'} queued: ${arts.slice(0, 5).map(r => `${r.id} "${r.xArticle.title}" at ${r.at}`).join('; ')}.` : ''}`, d);
|
|
4295
4304
|
}));
|
|
4296
4305
|
// RESCHEDULE. `PATCH /api/schedule/:id` has existed since drag-to-reschedule shipped and NO agent surface wrapped
|
|
4297
4306
|
// it, so the only move an agent had was cancel + retype — which is exactly how a calendar loses a caption, and the
|
|
@@ -4306,7 +4315,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4306
4315
|
at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
|
|
4307
4316
|
message: z.string().optional().describe('replace the caption used for every channel that has no override'),
|
|
4308
4317
|
captions: z.record(z.string()).optional().describe('replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one'),
|
|
4309
|
-
optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written
|
|
4318
|
+
optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written; a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
|
|
4310
4319
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('replaces the channel list'),
|
|
4311
4320
|
imageUrl: z.string().optional().describe('swap the image; "" removes it'),
|
|
4312
4321
|
videoUrl: z.string().optional().describe('swap the video; "" removes it'),
|
|
@@ -4350,6 +4359,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4350
4359
|
xQuotePostId: z.string().optional().describe('X — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule.'),
|
|
4351
4360
|
communityId: z.string().optional().describe('X — the community to publish into; an empty string goes back to the main timeline.'),
|
|
4352
4361
|
paidPartnership: z.boolean().optional().describe('X — the paid-partnership label; false turns it off.'),
|
|
4362
|
+
xArticle: z.object({ title: z.string().optional(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: replaces the X Article (title, headings); {} makes it an ordinary X post again.'),
|
|
4353
4363
|
collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet \u2014 an invite already sent cannot be withdrawn from here.'),
|
|
4354
4364
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE', '']).optional().describe('INSTAGRAM \u2014 replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued \u2014 a Reel already published cannot be converted into a trial.'),
|
|
4355
4365
|
aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
|
|
@@ -4562,7 +4572,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4562
4572
|
title: z.string().describe('the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card.'),
|
|
4563
4573
|
body: z.string().describe('the article body, as markdown or plain prose. Markdown headings, lists, quotes, links, emphasis, ``` code fences and | pipe | tables | are all converted to X’s own Article structure.'),
|
|
4564
4574
|
coverImageUrl: z.string().optional().describe('optional cover picture for the Article — a Hermoso render URL or an upload_file url. Must be a STILL image; X Article covers are not videos.'),
|
|
4565
|
-
headings: z.enum(['blocks', 'text']).optional().describe('how headings are rendered. “blocks” (default) uses X’s own heading block types,
|
|
4575
|
+
headings: z.enum(['blocks', 'text']).optional().describe('how headings are rendered. “blocks” (default) uses X’s own heading block types for # and ## headings; ### and deeper become bold lines, because X Articles refuse a third-level heading (the reply says so). “text” renders every heading as a BOLD standalone paragraph instead: the words survive and the heading structure does not, and the reply says so.'),
|
|
4566
4576
|
allowLossy: z.boolean().optional().describe('publish even though part of the source cannot be represented on X, rendering those parts as plain text. OFF by default and it should usually stay off — silently publishing a user’s copy with formatting missing is worse than refusing and telling them.'),
|
|
4567
4577
|
publish: z.boolean().optional().describe('default true. Pass false to save it as a DRAFT in the account’s X Articles composer instead — nothing becomes public, the user can review and publish it from X, and it does not spend one of the five daily publishes.'),
|
|
4568
4578
|
},
|
|
@@ -16076,7 +16086,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16076
16086
|
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
16077
16087
|
server.registerTool('clip_video', {
|
|
16078
16088
|
title: 'Clip a long video',
|
|
16079
|
-
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip
|
|
16089
|
+
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip. It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
|
|
16080
16090
|
inputSchema: {
|
|
16081
16091
|
video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
|
|
16082
16092
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
@@ -16206,13 +16216,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16206
16216
|
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them and not by omitting the field. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: your prompt is NOT dispatched verbatim by default — a few small guards are appended (packaging/label safety when no reference image rides, a negative prompt on the models that take one, reference-binding lines when references ride) and hex colour codes are rewritten to colour names. Pass raw:true for none of that. ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
|
|
16207
16217
|
inputSchema: {
|
|
16208
16218
|
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
16209
|
-
raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two
|
|
16219
|
+
raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence. Billing, durable delivery and per-model validation are unchanged.'),
|
|
16210
16220
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
16211
16221
|
refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the omni engine accepts a raw clip and transforms it per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit to generate a fresh clip."),
|
|
16212
16222
|
durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the CHOSEN model’s listed durations from hermoso_capabilities (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 40s = 15+15+10).'),
|
|
16213
16223
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
16214
|
-
model: z.string().optional().describe('video model id from hermoso_capabilities
|
|
16215
|
-
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render
|
|
16224
|
+
model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick'),
|
|
16225
|
+
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render. Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
|
|
16226
|
+
cameraMove: z.enum(['orbit', 'orbit_half', 'orbit_full', 'rise', 'push_in', 'pull_back']).optional().describe('H3 Max Multi Angle only: camera move (default orbit)'),
|
|
16216
16227
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
16217
16228
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
16218
16229
|
musicMood: z.string().optional().describe('WHICH mood the music bed is composed in (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic). It does NOT decide WHETHER there is one: a clip that comes back with no audio track — every model hermoso_capabilities lists as "silent", plus any audio model that returned mute — gets a bed composed and CHARGED automatically, at the flat per-track fee hermoso_capabilities reports as explainerMusicCredits, and omitting this field only means the mood defaults to "warm". Pass audio:false for a genuinely silent clip with no bed and no bed charge.'),
|
|
@@ -16220,9 +16231,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16220
16231
|
},
|
|
16221
16232
|
outputSchema: { ...JOB_OUT,
|
|
16222
16233
|
refused: z.string().optional().describe("set when NOTHING was rendered and nothing charged — 'duration_exceeds_single_clip' or 'aspect_unsupported'"),
|
|
16223
|
-
maxSingleClipSeconds: z.number().optional().describe('the longest single clip any connected video model can render
|
|
16224
|
-
askedSeconds: z.number().optional().describe('the durationSeconds that
|
|
16225
|
-
askedAspectRatio: z.string().optional().describe('the aspectRatio that
|
|
16234
|
+
maxSingleClipSeconds: z.number().optional().describe('the longest single clip any connected video model can render'),
|
|
16235
|
+
askedSeconds: z.number().optional().describe('the durationSeconds that could not be honored'),
|
|
16236
|
+
askedAspectRatio: z.string().optional().describe('the aspectRatio that could not be honored'),
|
|
16226
16237
|
supportedAspectRatios: z.array(z.string()).optional().describe('the frames the chosen model DOES render'),
|
|
16227
16238
|
aspectRatioModels: z.array(z.string()).optional().describe('model ids that DO render the asked frame — name one in `model` and re-run'),
|
|
16228
16239
|
},
|
|
@@ -16261,7 +16272,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16261
16272
|
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
16262
16273
|
// an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
|
|
16263
16274
|
// treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
|
|
16264
|
-
const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model }, 'MCP video');
|
|
16275
|
+
const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model, ...(a.cameraMove ? { cameraTrajectory: a.cameraMove } : {}) }, 'MCP video');
|
|
16265
16276
|
return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${switchNote(r)}`, r);
|
|
16266
16277
|
}));
|
|
16267
16278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.230",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI ad studio and marketing MCP server with 825 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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",
|