hermoso 0.1.267 → 0.1.269
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/tools.mjs +97 -39
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -621,6 +621,24 @@ const HOOK_ATTR = {
|
|
|
621
621
|
hook: z.string().optional().describe('WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.'),
|
|
622
622
|
subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.'),
|
|
623
623
|
};
|
|
624
|
+
// A POST YOU CAN CREATE IN A BRAND MUST BE MANAGEABLE THERE (2026-09-21, measured on the hosted MCP). With the
|
|
625
|
+
// connection pinned to a brand that has no X, `post_to_x {brand:'Hermoso'}` posted on Hermoso's X — and then
|
|
626
|
+
// `delete_x_post {id, brand:'Hermoso'}` was refused "needs the x connection", because delete_x_post had no `brand` and
|
|
627
|
+
// was gated and routed on the pin. So every tool that edits, deletes, lists or measures what a publish tool made
|
|
628
|
+
// carries the SAME per-call brand, over the SAME wire the publish tools use: a string `brand` in a POST body, or
|
|
629
|
+
// `?brandId=` on a GET/DELETE (the server belt `brandRefOf` consumes both; `?brand=` is /api/product/find's).
|
|
630
|
+
// tools/brand-per-call-roster-check.mjs derives the tool set by running registerTools and fails on a new one without it.
|
|
631
|
+
const MANAGE_BRAND = {
|
|
632
|
+
brand: z.string().optional().describe('WHICH BRAND the post lives in — the id or exact name from list_brands (a workspace shared with you: its profile id). Needed when it was published in a brand this connection is not pinned to: a post you can CREATE in a brand is manageable there too, for THIS CALL ONLY, without switching the connection. A name that matches no brand, or two, is REFUSED and nothing is done.'),
|
|
633
|
+
};
|
|
634
|
+
const namedBrand = (a) => (a && typeof a.brand === 'string' && a.brand.trim() ? a.brand.trim() : '');
|
|
635
|
+
// POST body: the belt reads a string `brand` and deletes it before the route sees the body.
|
|
636
|
+
const bodyBrand = (a, body = {}) => (namedBrand(a) ? { ...body, brand: namedBrand(a) } : body);
|
|
637
|
+
// GET params: the query spelling is `brandId` only.
|
|
638
|
+
const queryBrand = (a, q = {}) => (namedBrand(a) ? { ...q, brandId: namedBrand(a) } : q);
|
|
639
|
+
// A DELETE carries its args in the path, so the brand rides as a query SUFFIX — appended inside the path literal so
|
|
640
|
+
// the route stays the first thing in the call (tools/nonads-matrix-check.mjs derives routes from `apiX('/api/…`).
|
|
641
|
+
const brandQs = (a, sep = '?') => (namedBrand(a) ? `${sep}brandId=${encodeURIComponent(namedBrand(a))}` : '');
|
|
624
642
|
// The "Duration to boards" table in one line — fill every act to the model max, remainder LAST, and pull the
|
|
625
643
|
// deficit off the previous act when the remainder would fall under the provider floor (their own 18 -> 14+4). Mirrors
|
|
626
644
|
// hfClipDurations in acts-packing.mjs, which is what actually packs the render; here it only makes the refusal concrete.
|
|
@@ -2318,9 +2336,14 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2318
2336
|
// ticked accounts on the same brand). The gate is a snapshot; the refusal must not be. Before saying "not connected",
|
|
2319
2337
|
// re-read the connection set ONCE and re-gate (the same regateForWorkspace use_brand runs); a failed read fails open
|
|
2320
2338
|
// as everywhere else, so the worst case is the old answer, never a wrong refusal of a connector that exists.
|
|
2321
|
-
async function holdReasonRechecked(name, ctx) {
|
|
2339
|
+
async function holdReasonRechecked(name, ctx, args = null) {
|
|
2322
2340
|
let why = holdReasonFor(name, ctx);
|
|
2323
2341
|
if (why !== 'not_connected') return why;
|
|
2342
|
+
// A PER-CALL BRAND IS NOT THE PIN (2026-09-21). The connection snapshot above is the PINNED brand's; a tool that
|
|
2343
|
+
// takes `brand` runs against the brand the caller NAMED, which may have the connector the pin lacks. Refusing it
|
|
2344
|
+
// here answered "not connected" for an account that was connected. Run it: the route gates the named brand itself
|
|
2345
|
+
// and answers the one "not connected" shape (401 + connector) when it really is missing.
|
|
2346
|
+
if (callNamesOwnBrand(name, ctx, args)) return null;
|
|
2324
2347
|
// The re-read must be a GOOD read before it replaces the snapshot: regateForWorkspace fails OPEN on a failed read
|
|
2325
2348
|
// (right for use_brand, wrong here — it would turn "not connected" into "run it and 401"), so a read that did not
|
|
2326
2349
|
// succeed keeps the snapshot's answer and the refusal it already earned.
|
|
@@ -2330,6 +2353,12 @@ async function holdReasonRechecked(name, ctx) {
|
|
|
2330
2353
|
try { await regateForWorkspace(ctx, fresh); } catch { return why; }
|
|
2331
2354
|
return holdReasonFor(name, ctx);
|
|
2332
2355
|
}
|
|
2356
|
+
function callNamesOwnBrand(name, ctx, args) {
|
|
2357
|
+
const b = args && typeof args === 'object' ? args.brand : null;
|
|
2358
|
+
if (typeof b !== 'string' || !b.trim()) return false;
|
|
2359
|
+
const h = ctx && ctx.handleOf && ctx.handleOf[name];
|
|
2360
|
+
try { return !!(h && h.inputSchema && h.inputSchema.shape && Object.prototype.hasOwnProperty.call(h.inputSchema.shape, 'brand')); } catch { return false; }
|
|
2361
|
+
}
|
|
2333
2362
|
// MODULE SCOPE, NOT INSIDE buildTools — and the reason is worth keeping. tools/generation-matrix-check.mjs
|
|
2334
2363
|
// derives the generation surface by slicing the source between registerTool() calls and asking which
|
|
2335
2364
|
// /api/generate/* routes each slice touches. Written just above find_tools, this helper's apiGet fell
|
|
@@ -2376,7 +2405,7 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2376
2405
|
const h = name && ctx.handleOf[name];
|
|
2377
2406
|
if (!h && LEGACY_TOOL_NAMES[name]) return legacyToolAnswer(name, request, extra, ctx); // a name only an old snapshot still holds
|
|
2378
2407
|
if (h && h.enabled === false) {
|
|
2379
|
-
const why = await holdReasonRechecked(name, ctx);
|
|
2408
|
+
const why = await holdReasonRechecked(name, ctx, request?.params?.arguments);
|
|
2380
2409
|
if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return withHints({ content: [{ type: 'text', text: t }], isError: true }, holdHints(name, why, ctx)); }
|
|
2381
2410
|
// The call itself is the evidence: this host's tool list still names a tool the session holds out on size,
|
|
2382
2411
|
// i.e. the host is serving a stale roster. Run it (that is the point) and record that it happened.
|
|
@@ -3103,7 +3132,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3103
3132
|
return { content: [{ type: 'text', text: `No tool named "${n}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} find_tools({query}) searches every tool by name or task.` }], isError: true };
|
|
3104
3133
|
}
|
|
3105
3134
|
if (n === 'call_tool' || n === 'find_tools' || n === 'enable_tools') return { content: [{ type: 'text', text: `${n} is a roster tool; call it directly.` }], isError: true };
|
|
3106
|
-
const why = await holdReasonRechecked(n, ctx);
|
|
3135
|
+
const why = await holdReasonRechecked(n, ctx, args);
|
|
3107
3136
|
// ONE sentence per hold, from holdReasonText. call_tool used to spell its own copies, so the connect link added there
|
|
3108
3137
|
// never reached claude.ai or ChatGPT, the two hosts that run held tools through here.
|
|
3109
3138
|
if (why) {
|
|
@@ -3162,6 +3191,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3162
3191
|
description: "Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky, or here with connect_connector, with a handle and an APP PASSWORD.",
|
|
3163
3192
|
inputSchema: {
|
|
3164
3193
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
3194
|
+
brand: HOOK_ATTR.brand,
|
|
3165
3195
|
text: z.string().describe('The post, up to 300 characters / 3000 UTF-8 bytes.'),
|
|
3166
3196
|
imageUrls: z.array(z.string()).optional().describe('Up to 4 public image URLs to attach. Cannot be combined with videoUrl.'),
|
|
3167
3197
|
altText: z.union([z.string(), z.array(z.string())]).optional().describe('Alt text \u2014 an ARRAY, one per image in the same order, or a single STRING to describe every image with it. WRITE ONE: Bluesky\u2019s own lexicon makes `alt` a REQUIRED property of every image, so a post without it is undescribed by design rather than by omission, and Bluesky users expect it. No maximum length is published, so nothing is truncated.'),
|
|
@@ -3188,6 +3218,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3188
3218
|
title: 'Delete a post from the connected Bluesky account',
|
|
3189
3219
|
description: "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings > Connectors > Bluesky, or connect_connector).",
|
|
3190
3220
|
inputSchema: {
|
|
3221
|
+
...MANAGE_BRAND,
|
|
3191
3222
|
uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x)"),
|
|
3192
3223
|
confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent and cannot be undone'),
|
|
3193
3224
|
confirmText: z.string().optional().describe("the post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead."),
|
|
@@ -3197,7 +3228,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3197
3228
|
// "Delete a repository record, or ensure it doesn't exist" — re-deleting a gone post is a no-op, not an error.
|
|
3198
3229
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
3199
3230
|
}, wrap(async (a) => {
|
|
3200
|
-
const d = await apiPost('/api/bluesky/delete-post', { uri: a.uri, confirm: a.confirm === true, ...(a.confirmText != null ? { confirmText: a.confirmText } : {}) });
|
|
3231
|
+
const d = await apiPost('/api/bluesky/delete-post', bodyBrand(a, { uri: a.uri, confirm: a.confirm === true, ...(a.confirmText != null ? { confirmText: a.confirmText } : {}) }));
|
|
3201
3232
|
// REPORT THE READ-BACK, never the 2xx — and on this route that is not a nicety: deleteRecord answers 200
|
|
3202
3233
|
// identically for a record it removed and one that was never there, so `note` is built from re-reading the
|
|
3203
3234
|
// record afterwards and an accepted delete that did not take says so instead of being narrated as gone.
|
|
@@ -3220,6 +3251,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3220
3251
|
description: "Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram, or here with connect_connector, by pasting a bot token from @BotFather.",
|
|
3221
3252
|
inputSchema: {
|
|
3222
3253
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
3254
|
+
brand: HOOK_ATTR.brand,
|
|
3223
3255
|
chatId: z.string().describe("REQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats."),
|
|
3224
3256
|
text: z.string().optional().describe('the message. ≤4096 characters on its own; ≤1024 once any image or video is attached.'),
|
|
3225
3257
|
imageUrl: z.string().optional().describe('one image (≤10MB after upload)'),
|
|
@@ -3284,6 +3316,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3284
3316
|
title: 'Delete a Telegram message',
|
|
3285
3317
|
description: "PERMANENTLY delete one message the bot posted to a Telegram chat. Call it WITHOUT confirm first: nothing is deleted and you get a sentence to show the user. There is deliberately NO preview of the message — the Bot API has no method that reads one message back, so anything shown would be invented, and for the same reason the result after deleting is Telegram’s own success answer rather than a verified read-back. TWO VENDOR LIMITS, both Telegram’s and neither ours: \"A message can only be deleted if it was sent less than 48 hours ago\", and in a CHANNEL the bot needs the Post Messages right to remove even its own posts. Takes the same chatId as post_to_telegram plus the messageId post_to_telegram returned. 0 credits. Needs Telegram connected (Settings > Connectors > Telegram, or connect_connector).",
|
|
3286
3318
|
inputSchema: {
|
|
3319
|
+
...MANAGE_BRAND,
|
|
3287
3320
|
chatId: z.string().describe("the chat the message is in — the same @username or numeric id it was posted with"),
|
|
3288
3321
|
messageId: z.number().describe('the message id post_to_telegram returned (also the number at the end of a t.me link)'),
|
|
3289
3322
|
confirm: z.boolean().optional().describe('REQUIRED true — Telegram has no trash and no undelete'),
|
|
@@ -3291,7 +3324,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3291
3324
|
outputSchema: { ok: z.boolean().optional(), deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), chatId: z.string().optional(), messageId: z.number().optional(), note: z.string().optional() },
|
|
3292
3325
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
3293
3326
|
}, wrap(async (a) => {
|
|
3294
|
-
const d = await apiPost('/api/telegram/delete-message', { chatId: a.chatId, messageId: a.messageId, confirm: a.confirm === true });
|
|
3327
|
+
const d = await apiPost('/api/telegram/delete-message', bodyBrand(a, { chatId: a.chatId, messageId: a.messageId, confirm: a.confirm === true }));
|
|
3295
3328
|
return ok(d.note, d);
|
|
3296
3329
|
}));
|
|
3297
3330
|
|
|
@@ -3899,6 +3932,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3899
3932
|
title: 'Insights for one Facebook/Instagram post',
|
|
3900
3933
|
description: 'Performance for a single organic post — on Facebook views/reach (post_media_view, post_total_media_view_unique — Meta’s own replacements for the retired impressions family), clicks, reactions and video watch time; on Instagram views, reach, likes, comments, saves, shares, total interactions and (where the media type has them) follows, profile visits, story navigation and reel watch time. Use it to find which organic posts earned their reach before turning one into a paid ad. A metric Meta returns no value for is reported by name as MISSING — never read it as zero.',
|
|
3901
3934
|
inputSchema: {
|
|
3935
|
+
...MANAGE_BRAND,
|
|
3902
3936
|
postId: z.string().describe('post/media id returned by post_to_meta'),
|
|
3903
3937
|
target: z.enum(['facebook', 'instagram']).optional().describe('which metric set to ask for (default facebook)'),
|
|
3904
3938
|
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
|
|
@@ -3907,7 +3941,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3907
3941
|
outputSchema: { postId: z.string().optional(), metrics: z.array(z.any()).optional(), note: z.string().optional() },
|
|
3908
3942
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
3909
3943
|
}, wrap(async (a) => {
|
|
3910
|
-
const d = await apiGet('/api/meta/post-insights', { postId: a.postId, target: a.target, account: a.account, pageId: a.pageId });
|
|
3944
|
+
const d = await apiGet('/api/meta/post-insights', queryBrand(a, { postId: a.postId, target: a.target, account: a.account, pageId: a.pageId }));
|
|
3911
3945
|
return ok(`${d.target} post ${d.postId}:\n${(d.metrics || []).map(m => `• ${m.name}: ${m.value ?? '— (no value returned — MISSING, not zero)'}`).join('\n') || '(no metrics)'}${d.note ? `\n${d.note}` : ''}`, d);
|
|
3912
3946
|
}));
|
|
3913
3947
|
|
|
@@ -3939,6 +3973,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3939
3973
|
title: 'List the brand’s Instagram posts',
|
|
3940
3974
|
description: 'The connected Instagram Business account’s own recent media — id, caption, media type (feed / reel / story-era), permalink, timestamp, like and comment counts. This is where the media id every other Instagram tool needs comes from: resolve “my latest reel” yourself instead of asking the user for a link, then pass the id to meta_post_insights. Read-only, 0 credits.',
|
|
3941
3975
|
inputSchema: {
|
|
3976
|
+
...MANAGE_BRAND,
|
|
3942
3977
|
limit: z.number().optional().describe('how many (1–50, default 15)'),
|
|
3943
3978
|
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
|
|
3944
3979
|
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
@@ -3946,7 +3981,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3946
3981
|
outputSchema: { instagramId: z.string().optional(), count: z.number().optional(), media: z.array(z.any()).optional() },
|
|
3947
3982
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
3948
3983
|
}, wrap(async (a) => {
|
|
3949
|
-
const d = await apiGet('/api/instagram/media', { limit: a.limit, account: a.account, pageId: a.pageId });
|
|
3984
|
+
const d = await apiGet('/api/instagram/media', queryBrand(a, { limit: a.limit, account: a.account, pageId: a.pageId }));
|
|
3950
3985
|
return ok(`${d.count} Instagram post(s):\n${(d.media || []).map(m => `• [${m.media_product_type || m.media_type}] ${String(m.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 90)} — ${m.like_count ?? '—'} likes, ${m.comments_count ?? '—'} comments · ${m.timestamp || ''}\n id ${m.id}${m.permalink ? ` · ${m.permalink}` : ''}`).join('\n') || ' (none)'}`, d);
|
|
3951
3986
|
}));
|
|
3952
3987
|
|
|
@@ -4028,6 +4063,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4028
4063
|
title: 'Posts this account co-authors',
|
|
4029
4064
|
description: 'Every collaborative post this Instagram account is a co-author on — who posted it, and the COMBINED engagement across all co-authors (total likes and comments, plus saves, shares and reposts where Instagram provides them). Read-only, free.',
|
|
4030
4065
|
inputSchema: {
|
|
4066
|
+
...MANAGE_BRAND,
|
|
4031
4067
|
limit: z.number().optional().describe('1–100, default 25'),
|
|
4032
4068
|
after: z.string().optional().describe('cursor from a previous page'),
|
|
4033
4069
|
account: z.string().optional().describe('which Instagram account — @handle or id; omit for the one Page-linked account'),
|
|
@@ -4036,7 +4072,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4036
4072
|
outputSchema: { instagramId: z.string().optional(), count: z.number().optional(), media: z.array(z.any()).optional(), cursor: z.string().nullable().optional() },
|
|
4037
4073
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
4038
4074
|
}, wrap(async (a) => {
|
|
4039
|
-
const d = await apiGet('/api/instagram/collab-media', { limit: a.limit, after: a.after, account: a.account, pageId: a.pageId });
|
|
4075
|
+
const d = await apiGet('/api/instagram/collab-media', queryBrand(a, { limit: a.limit, after: a.after, account: a.account, pageId: a.pageId }));
|
|
4040
4076
|
if (!d.count) return ok('This Instagram account is not a co-author on any collaborative post.', d);
|
|
4041
4077
|
return ok(`${d.count} collaborative post(s):\n${d.media.map(m => `• [${m.mediaKind}] with @${m.by || '?'} — ${String(m.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 70)} — ${m.totalLikes ?? m.likes ?? '—'} likes, ${m.totalComments ?? m.comments ?? '—'} comments${m.saves != null ? `, ${m.saves} saves` : ''}${m.shares != null ? `, ${m.shares} shares` : ''} · ${m.url || m.id}`).join('\n')}`, d);
|
|
4042
4078
|
}));
|
|
@@ -4197,11 +4233,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4197
4233
|
server.registerTool('list_threads_posts', {
|
|
4198
4234
|
title: 'List your Threads posts',
|
|
4199
4235
|
description: 'List recent posts on the brand’s connected Threads account (id, text, media, permalink, timestamp). Use it to find a post id for threads_insights, list_threads_replies, reply_to_thread or delete_thread.',
|
|
4200
|
-
inputSchema: { limit: z.number().optional().describe('how many posts (1–50, default 15)') },
|
|
4236
|
+
inputSchema: { ...MANAGE_BRAND, limit: z.number().optional().describe('how many posts (1–50, default 15)') },
|
|
4201
4237
|
outputSchema: { username: z.string().optional(), count: z.number().optional(), posts: z.array(z.any()).optional() },
|
|
4202
4238
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
4203
4239
|
}, wrap(async (a) => {
|
|
4204
|
-
const d = await apiGet('/api/threads/posts', { limit: a.limit });
|
|
4240
|
+
const d = await apiGet('/api/threads/posts', queryBrand(a, { limit: a.limit }));
|
|
4205
4241
|
const lines = (d.posts || []).map(p => `• ${String(p.text || '(no text)').replace(/\s+/g, ' ').slice(0, 80)} — ${p.id} · ${String(p.timestamp || '').slice(0, 10)} · ${p.permalink || ''}`);
|
|
4206
4242
|
return ok(`@${d.username} — ${d.count} post(s):\n${lines.join('\n') || '(none)'}`, d);
|
|
4207
4243
|
}));
|
|
@@ -4276,6 +4312,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4276
4312
|
title: 'Delete a Threads post',
|
|
4277
4313
|
description: 'PERMANENTLY delete one of the brand’s Threads posts. IRREVERSIBLE — Threads has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real text plus its views, likes, replies and reposts read back from Threads. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (the post’s exact text as it was reported) once anyone has engaged with it. confirmName exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. Threads allows only 100 deletions per account per rolling 24 hours; threads_publishing_limit says how many are left, and a quota refusal otherwise reads like a broken connection. Note Meta documents nothing about what a delete does to the replies underneath a post, so do not promise the conversation survives. 0 credits.',
|
|
4278
4314
|
inputSchema: {
|
|
4315
|
+
...MANAGE_BRAND,
|
|
4279
4316
|
postId: z.string().describe('post id from list_threads_posts'),
|
|
4280
4317
|
confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent; only set it after the user has explicitly agreed'),
|
|
4281
4318
|
confirmName: z.string().optional().describe('the post’s exact text as the unconfirmed call reported it — required once it has any likes, replies or reposts'),
|
|
@@ -4283,7 +4320,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4283
4320
|
outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), permalink: z.string().nullable().optional(), note: z.string().optional() },
|
|
4284
4321
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
4285
4322
|
}, wrap(async (a) => {
|
|
4286
|
-
const d = await apiPost('/api/threads/delete', { postId: a.postId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) });
|
|
4323
|
+
const d = await apiPost('/api/threads/delete', bodyBrand(a, { postId: a.postId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) }));
|
|
4287
4324
|
// REPORT THE READ-BACK, never the `{"success":true}` — `note` is built from re-reading the id on the server.
|
|
4288
4325
|
return ok(d.note, d);
|
|
4289
4326
|
}));
|
|
@@ -4485,13 +4522,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4485
4522
|
title: 'Read a public Threads account\u2019s posts',
|
|
4486
4523
|
description: 'The recent PUBLIC posts of any Threads account \u2014 the raw material for a teardown, for mine_angles, and for ad copy in the language the market actually uses. Use it after threads_profile, or on its own when you already know the handle, and then USE what it returns: draft the brand\u2019s next post or plan_ad off the angles you found. AN EMPTY LIST IS NEVER PROOF THEY HAVE NOT POSTED \u2014 Meta returns nothing for a private account, for any account under 100 followers, and when the Threads connection predates the `threads_profile_discovery` permission (approved for the app 2026-09-05; reconnect Threads to grant it); the note says which of those applies. Read-only, 0 credits. Needs Threads connected.',
|
|
4487
4524
|
inputSchema: {
|
|
4525
|
+
...MANAGE_BRAND,
|
|
4488
4526
|
username: z.string().describe('the Threads handle \u2014 "nike", "@nike", or a threads.net profile link'),
|
|
4489
4527
|
limit: z.number().optional().describe('how many posts (1\u201350, default 25)'),
|
|
4490
4528
|
},
|
|
4491
4529
|
outputSchema: { username: z.string().optional(), count: z.number().optional(), posts: z.array(z.any()).optional(), followers: z.number().nullable().optional(), note: z.string().optional(), rateNote: z.string().optional() },
|
|
4492
4530
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
4493
4531
|
}, wrap(async (a) => {
|
|
4494
|
-
const d = await apiGet('/api/threads/profile-posts', { username: a.username, limit: a.limit });
|
|
4532
|
+
const d = await apiGet('/api/threads/profile-posts', queryBrand(a, { username: a.username, limit: a.limit }));
|
|
4495
4533
|
const lines = (d.posts || []).map(p => `\u2022 ${String(p.text || '(no text)').replace(/\s+/g, ' ').slice(0, 120)} \u2014 ${p.permalink || p.id} \u00b7 ${String(p.at || '').slice(0, 10)}`);
|
|
4496
4534
|
if (!lines.length) return ok(`\u26a0 ${d.note || `No public posts came back for @${d.username}.`}`, d);
|
|
4497
4535
|
return ok(`${d.note ? `\u26a0 ${d.note}\n\n` : ''}${d.count} public post(s) from @${d.username}:\n${lines.join('\n')}`, d);
|
|
@@ -5243,6 +5281,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5243
5281
|
title: 'Edit a post on X',
|
|
5244
5282
|
description: 'EDIT the text of one of the connected account’s own posts on X. Three things about X’s edit model change how you must use this and none is guessable: (1) X REPLACES THE WHOLE TEXT — there is no partial patch, so pass the complete new post; (2) an edit MINTS A NEW POST ID, and the old id keeps resolving and keeps showing the OLD text, so always hand the user the NEW url afterwards or they will circulate a link to the version they just corrected; (3) X’s window is ONE HOUR from the ORIGINAL post and DOES NOT RESTART when a post is edited, and each post has a limited number of edits. Pass whichever id the user has — Hermoso reads X’s edit chain and aims at the newest id, which is the only one X accepts (an edit aimed at the id the user was originally given is refused by X once the post has been edited once). A published ARTICLE can never be edited whatever the subscription, and this says so rather than trying. Editing needs X Premium on the POSTING account; Hermoso attempts it and reports X’s own refusal rather than pre-refusing on a guess about the plan. Everything knowable for free — window closed, edits used up, post ineligible — is refused before anything is billed. Costs credits. Needs X connected.',
|
|
5245
5283
|
inputSchema: {
|
|
5284
|
+
...MANAGE_BRAND,
|
|
5246
5285
|
postId: z.string().describe('the numeric X post id — the last part of the post URL. Any id in the post’s edit chain works; Hermoso resolves the newest one.'),
|
|
5247
5286
|
text: z.string().describe('the FULL new text of the post. It replaces the old text entirely. Same length rule as a new post: 280 characters, or up to 25,000 on an X Premium account.'),
|
|
5248
5287
|
},
|
|
@@ -5255,7 +5294,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5255
5294
|
server.registerTool('delete_x_post', {
|
|
5256
5295
|
title: 'Delete a post on X',
|
|
5257
5296
|
description: 'Permanently delete one of the connected account’s posts on X. This CANNOT be undone — confirm the exact post with the user first. Costs credits (X bills per API call). Needs X connected.',
|
|
5258
|
-
inputSchema: { id: z.string().describe('the numeric X post id — the last part of the post URL') },
|
|
5297
|
+
inputSchema: { ...MANAGE_BRAND, id: z.string().describe('the numeric X post id — the last part of the post URL') },
|
|
5259
5298
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), deleted: z.boolean().optional(), costCredits: z.number().optional() },
|
|
5260
5299
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5261
5300
|
}, wrap(async (a) => {
|
|
@@ -5266,13 +5305,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5266
5305
|
title: 'Read performance of a post on X',
|
|
5267
5306
|
description: "THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround. Prefer this over x_post_insights, whose endpoint family X has retired. If a post is deleted, protected or suspended, X answers with no data at all and this says so — that is MISSING DATA, never zero engagement, and must never be reported as a measured zero. Costs a small number of credits (X bills per API read). Needs X connected.",
|
|
5268
5307
|
inputSchema: {
|
|
5308
|
+
...MANAGE_BRAND,
|
|
5269
5309
|
id: z.string().describe('the numeric X post id — the last part of the post URL'),
|
|
5270
5310
|
publishedAt: z.number().optional().describe('epoch ms the post went out, if known — lets the private owned-post metrics be requested only inside X\'s 30-day window instead of costing a refused call'),
|
|
5271
5311
|
},
|
|
5272
5312
|
outputSchema: { id: z.string().optional(), found: z.boolean().optional(), note: z.string().optional(), text: z.string().optional(), postedAt: z.string().nullable().optional(), url: z.string().optional(), impressions: z.number().nullable().optional(), likes: z.number().nullable().optional(), reposts: z.number().nullable().optional(), replies: z.number().nullable().optional(), quotes: z.number().nullable().optional(), bookmarks: z.number().nullable().optional(), urlClicks: z.number().nullable().optional(), profileClicks: z.number().nullable().optional(), engagements: z.number().nullable().optional(), organicImpressions: z.number().nullable().optional(), organicLikes: z.number().nullable().optional(), privateMetrics: z.boolean().optional(), costCredits: z.number().optional() },
|
|
5273
5313
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5274
5314
|
}, wrap(async (a) => {
|
|
5275
|
-
const d = await apiGet('/api/x/metrics', { id: a.id, publishedAt: a.publishedAt });
|
|
5315
|
+
const d = await apiGet('/api/x/metrics', queryBrand(a, { id: a.id, publishedAt: a.publishedAt }));
|
|
5276
5316
|
// The private half is reported only when X actually served it — an absent link-click count means "outside the
|
|
5277
5317
|
// 30-day window or not your post", which is not zero clicks and must not be printed as a number.
|
|
5278
5318
|
const priv = d.privateMetrics
|
|
@@ -5284,13 +5324,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5284
5324
|
title: 'Advertiser analytics for your own posts on X',
|
|
5285
5325
|
description: 'Advertiser-grade analytics for the connected account’s OWN posts on X — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles. This is the read that answers “did the creative work”, which x_post_metrics cannot: public metrics show likes and reposts, never clicks or video retention. Takes up to 25 post ids in one call. COSTS CREDITS PER POST READ, so ask about the posts that matter rather than everything. If X returns no rows, say so — that is missing data, not zero performance. Needs X connected.',
|
|
5286
5326
|
inputSchema: {
|
|
5327
|
+
...MANAGE_BRAND,
|
|
5287
5328
|
ids: z.array(z.string()).describe('numeric X post ids (max 25) — the last part of each post URL'),
|
|
5288
5329
|
granularity: z.enum(['Total', 'Daily', 'Hourly', 'Weekly']).optional().describe('default Total'),
|
|
5289
5330
|
},
|
|
5290
5331
|
outputSchema: { granularity: z.string().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), metrics: z.record(z.number()).optional() })).optional(), errors: z.array(z.any()).optional() },
|
|
5291
5332
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5292
5333
|
}, wrap(async (a) => {
|
|
5293
|
-
const d = await apiGet('/api/x/insights', { ids: (a.ids || []).join(','), granularity: a.granularity });
|
|
5334
|
+
const d = await apiGet('/api/x/insights', queryBrand(a, { ids: (a.ids || []).join(','), granularity: a.granularity }));
|
|
5294
5335
|
const rows = d.posts || [];
|
|
5295
5336
|
if (!rows.length) return ok('X returned no insight rows for those posts — that is missing data, not zero performance.', d);
|
|
5296
5337
|
const lines = rows.map(p => {
|
|
@@ -5308,6 +5349,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5308
5349
|
title: 'Advertiser analytics for your own X posts, over any date range',
|
|
5309
5350
|
description: 'The same advertiser-grade X analytics as x_post_insights — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles — over ANY date range instead of only the last 28 hours. This is the one to use for “how did last week’s post do”, “compare these three posts over the month”, or any retrospective: x_post_insights physically cannot see past yesterday, so asking it about an older post returns nothing and that is not zero performance. Takes up to 25 post ids at once; the window defaults to the last 28 days when you name none, and the window actually queried is reported back. COSTS CREDITS PER POST READ — X bills us per API call — so say the cost before pulling a big batch and ask about the posts that matter. Needs X connected.',
|
|
5310
5351
|
inputSchema: {
|
|
5352
|
+
...MANAGE_BRAND,
|
|
5311
5353
|
ids: z.array(z.string()).describe('numeric X post ids (max 25) — the last part of each post URL'),
|
|
5312
5354
|
startDate: z.string().optional().describe('YYYY-MM-DD or a UTC timestamp; defaults to 28 days before the end'),
|
|
5313
5355
|
endDate: z.string().optional().describe('YYYY-MM-DD or a UTC timestamp; defaults to now'),
|
|
@@ -5316,7 +5358,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5316
5358
|
outputSchema: { granularity: z.string().optional(), startTime: z.string().optional(), endTime: z.string().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), metrics: z.record(z.number()).optional() })).optional(), errors: z.array(z.any()).optional() },
|
|
5317
5359
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5318
5360
|
}, wrap(async (a) => {
|
|
5319
|
-
const d = await apiGet('/api/x/insights-historical', { ids: (a.ids || []).join(','), startTime: a.startDate, endTime: a.endDate, granularity: a.granularity });
|
|
5361
|
+
const d = await apiGet('/api/x/insights-historical', queryBrand(a, { ids: (a.ids || []).join(','), startTime: a.startDate, endTime: a.endDate, granularity: a.granularity }));
|
|
5320
5362
|
const rows = d.posts || [];
|
|
5321
5363
|
const win = `${String(d.startTime || '').slice(0, 10)} → ${String(d.endTime || '').slice(0, 10)}`;
|
|
5322
5364
|
if (!rows.length) return ok(`X returned no insight rows for those posts between ${win} — that is missing data, not zero performance. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
@@ -5510,6 +5552,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5510
5552
|
title: 'List Pins on a Pinterest board',
|
|
5511
5553
|
description: 'The Pins on one of the account’s boards — or, with no boardId, the account’s own Pins across all of them. Each row carries the Pin id, title, description, destination link, alt text, board, creation date, and whether it HAS BEEN PROMOTED in an ad. THIS IS WHERE THE pinId EVERY OTHER PIN TOOL NEEDS COMES FROM: post_to_pinterest returns an id only at the instant it pins, so an agent that did not itself just pin had no way to name a Pin. Prefer passing a boardId — Pinterest’s own spec warns the account-wide listing has known timeouts. Read-only, 0 credits. Needs Pinterest connected.',
|
|
5512
5554
|
inputSchema: {
|
|
5555
|
+
...MANAGE_BRAND,
|
|
5513
5556
|
boardId: z.string().optional().describe('numeric board id from list_pinterest_boards — omit for the account’s own Pins across all boards'),
|
|
5514
5557
|
limit: z.number().optional().describe('1–100, default 25'),
|
|
5515
5558
|
cursor: z.string().optional().describe('the cursor a previous call returned'),
|
|
@@ -5517,7 +5560,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5517
5560
|
outputSchema: { boardId: z.string().nullable().optional(), count: z.number().optional(), pins: z.array(z.any()).optional(), cursor: z.string().nullable().optional() },
|
|
5518
5561
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5519
5562
|
}, wrap(async (a) => {
|
|
5520
|
-
const d = await apiGet('/api/pinterest/pins', { ...(a.boardId ? { boardId: a.boardId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) });
|
|
5563
|
+
const d = await apiGet('/api/pinterest/pins', queryBrand(a, { ...(a.boardId ? { boardId: a.boardId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) }));
|
|
5521
5564
|
if (!d.pins?.length) return ok(d.boardId ? 'That Pinterest board has no Pins on it.' : 'This Pinterest account has no Pins yet.', d);
|
|
5522
5565
|
return ok(`${d.count} Pin(s)${d.boardId ? ` on board ${d.boardId}` : ''}:\n${d.pins.map(p => `• ${p.title || '(untitled)'} (id ${p.id})${p.promoted ? ' — HAS BEEN PROMOTED in an ad' : ''}${p.link ? ` → ${p.link}` : ''}`).join('\n')}${d.cursor ? '\n(more available — pass cursor)' : ''}`, d);
|
|
5523
5566
|
}));
|
|
@@ -5531,6 +5574,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5531
5574
|
title: 'Edit a published Pin',
|
|
5532
5575
|
description: 'Edit a published Pin — its title, description, destination link, alt text, or which board it sits on. Only send the fields that should change. TWO LIMITS TO STATE BEFORE OFFERING THIS. (1) Pinterest marks its Update Pin endpoint "currently in beta and not available to all apps" in its own API description, so it may be refused outright whatever the account’s scopes or access tier — reconnecting cannot change that. If it is refused, save_pinterest_pin gets the Pin onto another board (generally available) and changing the wording means deleting and re-pinning. (2) A published Pin’s IMAGE or VIDEO can never be changed by anyone: Pinterest’s update model has no media field at all, so swapping the creative means delete and re-pin, which loses the Pin’s accumulated saves. The values reported back are what Pinterest STORED, not what was sent. 0 credits. Needs Pinterest connected.',
|
|
5533
5576
|
inputSchema: {
|
|
5577
|
+
...MANAGE_BRAND,
|
|
5534
5578
|
pinId: z.string().describe('numeric Pin id from list_pinterest_pins'),
|
|
5535
5579
|
title: z.string().optional().describe('max 100 characters'),
|
|
5536
5580
|
description: z.string().optional().describe('max 800 characters — the text Pinterest search reads'),
|
|
@@ -5563,6 +5607,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5563
5607
|
title: 'Delete a Pin',
|
|
5564
5608
|
description: 'PERMANENTLY delete a Pin. Pinterest has no undelete and no archive for one. Call it WITHOUT confirm first: nothing is deleted, and it answers with the Pin’s real title, its lifetime saves and impressions, and whether it HAS BEEN PROMOTED in an ad — all read back from Pinterest. Show the user that, get an unambiguous yes, then call again with confirm:true plus confirmName (its exact title) once it has saves or has been promoted, because confirming that you meant to delete SOMETHING does not prove you aimed at the right Pin. DELETING A PIN THAT AN AD PROMOTES pulls the creative out from under that ad, so check the promoted flag before agreeing. The verdict is read back from Pinterest, never taken from its 2xx. 0 credits. Needs Pinterest connected.',
|
|
5565
5609
|
inputSchema: {
|
|
5610
|
+
...MANAGE_BRAND,
|
|
5566
5611
|
pinId: z.string().describe('numeric Pin id from list_pinterest_pins'),
|
|
5567
5612
|
confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent'),
|
|
5568
5613
|
confirmName: z.string().optional().describe('the Pin’s EXACT title as the unconfirmed call reported it — required once it has saves or has been promoted'),
|
|
@@ -5570,7 +5615,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5570
5615
|
outputSchema: { ok: z.boolean().optional(), pinId: z.string().optional(), title: z.string().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), note: z.string().optional() },
|
|
5571
5616
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5572
5617
|
}, wrap(async (a) => {
|
|
5573
|
-
const d = await apiPost('/api/pinterest/pin/delete', { pinId: a.pinId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) });
|
|
5618
|
+
const d = await apiPost('/api/pinterest/pin/delete', bodyBrand(a, { pinId: a.pinId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) }));
|
|
5574
5619
|
return ok(d.note, d);
|
|
5575
5620
|
}));
|
|
5576
5621
|
server.registerTool('update_pinterest_board', {
|
|
@@ -5723,22 +5768,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5723
5768
|
server.registerTool('list_google_business_posts', {
|
|
5724
5769
|
title: 'List Google Business Profile Posts',
|
|
5725
5770
|
description: 'List the Posts currently on the brand’s Google Business Profile listing — text, topic type, state (LIVE / PROCESSING / REJECTED / SCHEDULED / RECURRING), button and timestamps. Use it to see what is already showing before writing another, or to get the id of one to remove. Read-only, 0 credits. Needs Google Business Profile connected.',
|
|
5726
|
-
inputSchema: { locationId: z.string().optional().describe('which listing, from list_business_locations — only needed when there is more than one'), limit: z.number().optional().describe('how many to return, max 100 (default 20)') },
|
|
5771
|
+
inputSchema: { ...MANAGE_BRAND, locationId: z.string().optional().describe('which listing, from list_business_locations — only needed when there is more than one'), limit: z.number().optional().describe('how many to return, max 100 (default 20)') },
|
|
5727
5772
|
outputSchema: { count: z.number().optional(), location: z.string().optional(), locationId: z.string().optional(), posts: z.array(z.object({ id: z.string().optional(), summary: z.string().optional(), topicType: z.string().optional(), state: z.string().nullable().optional(), url: z.string().nullable().optional(), cta: z.string().nullable().optional(), ctaUrl: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional() })).optional() },
|
|
5728
5773
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5729
5774
|
}, wrap(async (a) => {
|
|
5730
|
-
const d = await apiGet('/api/google-business/posts', { ...(a.locationId ? { locationId: a.locationId } : {}), ...(a.limit ? { limit: a.limit } : {}) });
|
|
5775
|
+
const d = await apiGet('/api/google-business/posts', queryBrand(a, { ...(a.locationId ? { locationId: a.locationId } : {}), ...(a.limit ? { limit: a.limit } : {}) }));
|
|
5731
5776
|
if (!d.count) return ok(`No Posts on the “${d.location}” listing right now.`, d);
|
|
5732
5777
|
return ok(`${d.count} Post${d.count === 1 ? '' : 's'} on “${d.location}”: ${(d.posts || []).map(p => `[${p.state || '?'}] ${String(p.summary || '(no text)').slice(0, 60)}`).join(' · ')}`, d);
|
|
5733
5778
|
}));
|
|
5734
5779
|
server.registerTool('delete_google_business_post', {
|
|
5735
5780
|
title: 'Delete a Google Business Profile Post',
|
|
5736
5781
|
description: 'Remove a Post from the brand’s Google Business Profile listing. This takes it off Google Search and Maps immediately and CANNOT be undone — confirm with the user first. Pass the full post name from list_google_business_posts. Needs Google Business Profile connected.',
|
|
5737
|
-
inputSchema: { postId: z.string().describe('the full post name from list_google_business_posts (accounts/…/locations/…/localPosts/…)') },
|
|
5782
|
+
inputSchema: { ...MANAGE_BRAND, postId: z.string().describe('the full post name from list_google_business_posts (accounts/…/locations/…/localPosts/…)') },
|
|
5738
5783
|
outputSchema: { ok: z.boolean().optional(), deleted: z.string().optional() },
|
|
5739
5784
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5740
5785
|
}, wrap(async (a) => {
|
|
5741
|
-
const d = await apiDelete(`/api/google-business/post?postId=${encodeURIComponent(a.postId)}`);
|
|
5786
|
+
const d = await apiDelete(`/api/google-business/post?postId=${encodeURIComponent(a.postId)}${brandQs(a, '&')}`);
|
|
5742
5787
|
return ok('Deleted that Post — it is no longer showing on Search or Maps.', d);
|
|
5743
5788
|
}));
|
|
5744
5789
|
// GOOGLE BUSINESS PROFILE REVIEWS + Q&A (2026-08-04). Reviews were never migrated off Google's legacy v4 API and
|
|
@@ -5912,22 +5957,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5912
5957
|
server.registerTool('list_youtube_videos', {
|
|
5913
5958
|
title: 'List the brand’s own YouTube uploads',
|
|
5914
5959
|
description: 'List the connected channel’s OWN recent uploads — video id, title, publish date and privacy — so you can resolve a video WITHOUT asking the user for a link. Call this whenever the user names a video loosely ("my latest", "the shorts one", part of a title) and match it yourself; only ask them when two titles are genuinely ambiguous. This is the tool that gets you the videoId every other YouTube tool needs — youtube_channel returns counts only, and search_youtube searches the PUBLIC index, not your uploads. Includes UNLISTED and PRIVATE videos, which are invisible to any public search. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
5915
|
-
inputSchema: { limit: z.number().optional().describe('how many recent uploads to return (default 25, max 50)') },
|
|
5960
|
+
inputSchema: { ...MANAGE_BRAND, limit: z.number().optional().describe('how many recent uploads to return (default 25, max 50)') },
|
|
5916
5961
|
outputSchema: { videos: z.array(z.object({ videoId: z.string().optional(), title: z.string().optional(), publishedAt: z.string().optional(), privacy: z.string().optional(), url: z.string().optional() })).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
5917
5962
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5918
5963
|
}, wrap(async (a) => {
|
|
5919
|
-
const d = await apiGet('/api/youtube/videos', { ...(a.limit ? { limit: a.limit } : {}) });
|
|
5964
|
+
const d = await apiGet('/api/youtube/videos', queryBrand(a, { ...(a.limit ? { limit: a.limit } : {}) }));
|
|
5920
5965
|
const rows = (d.videos || []).map(v => `• ${v.title || '(untitled)'} — ${v.videoId}${v.publishedAt ? ` · ${String(v.publishedAt).slice(0, 10)}` : ''}${v.privacy ? ` · ${v.privacy}` : ''}`);
|
|
5921
5966
|
return ok(rows.length ? `${rows.length} video(s) on the channel:\n${rows.join('\n')}` : (d.note || 'No videos on that channel yet.'), d);
|
|
5922
5967
|
}));
|
|
5923
5968
|
server.registerTool('youtube_video_insights', {
|
|
5924
5969
|
title: 'Performance of one of your YouTube videos',
|
|
5925
5970
|
description: 'Per-VIDEO performance for a video on the connected channel — views, estimated minutes watched, average view duration, average view PERCENTAGE (the retention number that tells you whether the hook held), likes, comments, shares and subscribers gained. Use it for "how did that video do", "which upload performed best", or to judge an ad before spending more behind it. youtube_channel only returns channel-wide totals and cannot answer this. Defaults to the last 28 days; pass startDate/endDate (YYYY-MM-DD) for another window. Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
5926
|
-
inputSchema: { videoId: z.string().describe('the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)'), startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago'), endDate: z.string().optional().describe('YYYY-MM-DD, default today') },
|
|
5971
|
+
inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)'), startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago'), endDate: z.string().optional().describe('YYYY-MM-DD, default today') },
|
|
5927
5972
|
outputSchema: { videoId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), views: z.number().optional(), estimatedMinutesWatched: z.number().optional(), averageViewDuration: z.number().optional(), averageViewPercentage: z.number().optional(), likes: z.number().optional(), comments: z.number().optional(), shares: z.number().optional(), subscribersGained: z.number().optional(), url: z.string().optional() },
|
|
5928
5973
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5929
5974
|
}, wrap(async (a) => {
|
|
5930
|
-
const d = await apiGet('/api/youtube/video-insights', { videoId: a.videoId, ...(a.startDate ? { startDate: a.startDate } : {}), ...(a.endDate ? { endDate: a.endDate } : {}) });
|
|
5975
|
+
const d = await apiGet('/api/youtube/video-insights', queryBrand(a, { videoId: a.videoId, ...(a.startDate ? { startDate: a.startDate } : {}), ...(a.endDate ? { endDate: a.endDate } : {}) }));
|
|
5931
5976
|
return ok(`${d.views ?? 0} views, ${d.averageViewPercentage ?? 0}% average retention, ${d.estimatedMinutesWatched ?? 0} minutes watched (${d.startDate} → ${d.endDate}).`, d);
|
|
5932
5977
|
}));
|
|
5933
5978
|
// DIMENSIONED YOUTUBE ANALYTICS (2026-08-04). `yt-analytics.readonly` is already granted by every connected
|
|
@@ -5992,7 +6037,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5992
6037
|
server.registerTool('update_youtube_video', {
|
|
5993
6038
|
title: 'Update a YouTube video’s title, description, tags or privacy',
|
|
5994
6039
|
description: 'Edit an existing video on the connected channel: title, description, tags, and/or privacy (unlisted | public | private). THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC — post_to_youtube defaults to UNLISTED, and without this there was no way to publish it afterwards. Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched. Needs a connected YouTube channel.',
|
|
5995
|
-
inputSchema: { videoId: z.string().describe('the YouTube video id'), title: z.string().optional().describe('≤100 chars'), description: z.string().optional().describe('≤5000 chars'), tags: z.array(z.string()).optional(), privacy: z.enum(['unlisted', 'public', 'private']).optional().describe('public = live on the channel; confirm with the user first') },
|
|
6040
|
+
inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id'), title: z.string().optional().describe('≤100 chars'), description: z.string().optional().describe('≤5000 chars'), tags: z.array(z.string()).optional(), privacy: z.enum(['unlisted', 'public', 'private']).optional().describe('public = live on the channel; confirm with the user first') },
|
|
5996
6041
|
outputSchema: { videoId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), url: z.string().optional() },
|
|
5997
6042
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5998
6043
|
}, wrap(async (a) => {
|
|
@@ -6010,6 +6055,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6010
6055
|
title: 'Delete a video from the connected YouTube channel',
|
|
6011
6056
|
description: 'PERMANENTLY delete a video from the connected YouTube channel. IRREVERSIBLE — YouTube has no trash and no undelete, and the video\'s views, comments and every link or embed pointing at it go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the video\'s REAL title, privacy, view count and comment count read back from YouTube. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the video is public or has any views or comments, confirmTitle set to its exact title. confirmTitle exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right video, and a wrong id must not be confirmable blind. If the user only wants it out of public view, use update_youtube_video(privacy:"private") instead — that is reversible and this is not. Get the videoId from list_youtube_videos. 0 credits. Needs a connected YouTube channel.',
|
|
6012
6057
|
inputSchema: {
|
|
6058
|
+
...MANAGE_BRAND,
|
|
6013
6059
|
videoId: z.string().describe('the YouTube video id (from list_youtube_videos)'),
|
|
6014
6060
|
confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent and cannot be undone'),
|
|
6015
6061
|
confirmTitle: z.string().optional().describe('the video\'s EXACT title as the unconfirmed call reported it — required once the video is public or has any views or comments'),
|
|
@@ -6017,7 +6063,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6017
6063
|
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), title: z.string().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
6018
6064
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6019
6065
|
}, wrap(async (a) => {
|
|
6020
|
-
const d = await apiPost('/api/youtube/delete-video', { videoId: a.videoId, confirm: a.confirm === true, ...(a.confirmTitle != null ? { confirmTitle: a.confirmTitle } : {}) });
|
|
6066
|
+
const d = await apiPost('/api/youtube/delete-video', bodyBrand(a, { videoId: a.videoId, confirm: a.confirm === true, ...(a.confirmTitle != null ? { confirmTitle: a.confirmTitle } : {}) }));
|
|
6021
6067
|
// REPORT THE READ-BACK, never the 204: `note` is built from re-reading the id on the server, so an accepted
|
|
6022
6068
|
// delete that did not take says so instead of being narrated as gone.
|
|
6023
6069
|
return ok(d.note, d);
|
|
@@ -6073,20 +6119,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6073
6119
|
title: 'Batch YouTube video stats',
|
|
6074
6120
|
description: "Views, likes and comment counts for up to 50 YouTube videos IN ONE CALL, which is how to answer \"how are my last twenty uploads doing\" without one youtube_video_insights per video. Pass videoIds from list_youtube_videos. IT CARRIES NO TITLES, and that is the resource rather than a bug: VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos when a name is needed. YouTube calls this endpoint \"intentionally not atomic\", so a short answer is normal: a video that is private, deleted, or not visible to the connected account simply does not come back, and this tool names the missing ids. Never report a missing id as zero views. Read-only, free.",
|
|
6075
6121
|
inputSchema: {
|
|
6122
|
+
...MANAGE_BRAND,
|
|
6076
6123
|
videoIds: z.array(z.string()).describe('up to 50 video ids, from list_youtube_videos'),
|
|
6077
6124
|
part: z.array(z.enum(['snippet', 'statistics', 'contentDetails'])).optional().describe('defaults to snippet + statistics. An unknown part 400s the whole call, so it is refused here'),
|
|
6078
6125
|
},
|
|
6079
6126
|
outputSchema: { count: z.number().optional(), asked: z.number().optional(), missing: z.array(z.string()).optional(), parts: z.array(z.string()).optional(), videos: z.array(z.any()).optional(), note: z.string().optional() },
|
|
6080
6127
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
6081
6128
|
}, wrap(async (a) => {
|
|
6082
|
-
const d = await apiGet('/api/youtube/video-stats', { videoIds: (a.videoIds || []).join(','), part: (a.part || []).join(',') });
|
|
6129
|
+
const d = await apiGet('/api/youtube/video-stats', queryBrand(a, { videoIds: (a.videoIds || []).join(','), part: (a.part || []).join(',') }));
|
|
6083
6130
|
const rows = (d?.videos || []).map(v => `${v.title || '(untitled)'} (${v.videoId}) — ${v.viewCount == null ? 'views n/a' : `${v.viewCount} views`}${v.likeCount == null ? '' : `, ${v.likeCount} likes`}${v.commentCount == null ? '' : `, ${v.commentCount} comments`}`);
|
|
6084
6131
|
return ok([d?.note, ...rows].filter(Boolean).join('\n'), d);
|
|
6085
6132
|
}));
|
|
6086
6133
|
server.registerTool('set_youtube_thumbnail', {
|
|
6087
6134
|
title: 'Set the custom thumbnail on a YouTube video',
|
|
6088
6135
|
description: 'Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image — a make_thumbnail render, a generated image, or a frame. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Images over YouTube’s 2MB cap are compressed automatically. The image must be Hermoso-HOSTED, which is not the same as Hermoso-GENERATED: the user’s own artwork works, put it through upload_file first and pass the URL that returns. An arbitrary external host is refused. 0 credits. Needs a connected YouTube channel.',
|
|
6089
|
-
inputSchema: { videoId: z.string().describe('the YouTube video id (what post_to_youtube returned)'), imageUrl: z.string().describe('a Hermoso-hosted image URL — a make_thumbnail / list_library render, OR any image of the user’s own passed through upload_file first. An arbitrary external host is refused.') },
|
|
6136
|
+
inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id (what post_to_youtube returned)'), imageUrl: z.string().describe('a Hermoso-hosted image URL — a make_thumbnail / list_library render, OR any image of the user’s own passed through upload_file first. An arbitrary external host is refused.') },
|
|
6090
6137
|
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), thumbnailUrl: z.string().nullable().optional(), bytes: z.number().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
6091
6138
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
6092
6139
|
}, wrap(async (a) => {
|
|
@@ -6241,6 +6288,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6241
6288
|
title: "List the brand's own Bluesky posts",
|
|
6242
6289
|
description: "The brand's OWN recent Bluesky posts, newest first — and THIS is where the at:// AT-URI every other Bluesky tool needs comes from. bluesky_post_metrics and delete_bluesky_post both address a post by AT-URI, so without this the only way to hold one was to have just published it in the same conversation; an agent reviewing past work had no way to name anything. Each row carries the text, when it went out, its web URL, its live like/repost/reply/quote/bookmark counts, and whether it is a REPOST of someone else's post or a reply — a repost is not the brand's own creative and must not be reported as its performance. Optional filter: posts_no_replies, posts_with_media, posts_with_replies, posts_and_author_threads (an unknown one is refused by name). Bluesky publishes NO impression or view count in any lexicon, so these are counts with no denominator and no engagement rate can be computed from them. Read-only, 0 credits.",
|
|
6243
6290
|
inputSchema: {
|
|
6291
|
+
...MANAGE_BRAND,
|
|
6244
6292
|
limit: z.number().optional().describe('how many posts, 1–100 (default 25). 100 is Bluesky\'s own maximum.'),
|
|
6245
6293
|
cursor: z.string().optional().describe('nextCursor from a previous call — a short page is NOT end-of-feed'),
|
|
6246
6294
|
filter: z.string().optional().describe('posts_no_replies | posts_with_media | posts_with_replies | posts_and_author_threads'),
|
|
@@ -6248,7 +6296,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6248
6296
|
outputSchema: { handle: z.string().optional(), did: z.string().optional(), count: z.number().optional(), posts: z.array(z.any()).optional(), nextCursor: z.string().optional(), note: z.string().optional() },
|
|
6249
6297
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6250
6298
|
}, wrap(async (a) => {
|
|
6251
|
-
const d = await apiGet('/api/bluesky/my-posts', { limit: a.limit, cursor: a.cursor, filter: a.filter });
|
|
6299
|
+
const d = await apiGet('/api/bluesky/my-posts', queryBrand(a, { limit: a.limit, cursor: a.cursor, filter: a.filter }));
|
|
6252
6300
|
const lines = (d.posts || []).map(p => `• ${p.isRepost ? '[REPOST] ' : ''}${p.isReply ? '[reply] ' : ''}${(p.text || '').slice(0, 90)} — ${p.uri}`).join('\n');
|
|
6253
6301
|
return ok(`${d.note}\n${lines}`, d);
|
|
6254
6302
|
}));
|
|
@@ -6418,6 +6466,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6418
6466
|
title: 'Edit a Telegram post',
|
|
6419
6467
|
description: 'Change the TEXT or CAPTION of a message already posted to a Telegram channel, group or chat \u2014 the fix for a typo that would otherwise cost the post AND its accumulated views, since deleting and reposting starts from zero. Pass `text` for a plain post or `caption` for one carrying media; Telegram refuses the wrong one and its error names the other. Free, reversible, and 0 credits \u2014 use this instead of delete_telegram_message wherever the post can be salvaged. Needs Telegram connected.',
|
|
6420
6468
|
inputSchema: {
|
|
6469
|
+
...MANAGE_BRAND,
|
|
6421
6470
|
chatId: z.string().describe('the channel\u2019s @username or numeric chat id'),
|
|
6422
6471
|
messageId: z.number().describe('from post_to_telegram, or the number at the end of a t.me link'),
|
|
6423
6472
|
text: z.string().optional().describe('the new text, for a post with no media'),
|
|
@@ -6433,6 +6482,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6433
6482
|
title: 'Pin or unpin a Telegram message',
|
|
6434
6483
|
description: 'Pin a message to the top of a Telegram channel or group, or unpin one. A channel\u2019s pinned message is its most valuable real estate and its de-facto call to action. Pinning is SILENT by default (no member notification) \u2014 pass notify:true to ping. To unpin, pass unpin:true; omitting messageId then unpins the most recently pinned message, which is what \u201cunpin\u201d usually means. The bot must be an administrator with pin rights. Free, 0 credits. Needs Telegram connected.',
|
|
6435
6484
|
inputSchema: {
|
|
6485
|
+
...MANAGE_BRAND,
|
|
6436
6486
|
chatId: z.string().describe('the channel\u2019s @username or numeric chat id'),
|
|
6437
6487
|
messageId: z.number().optional().describe('required to PIN; optional when unpinning'),
|
|
6438
6488
|
unpin: z.boolean().optional().describe('true to unpin instead of pin'),
|
|
@@ -6592,11 +6642,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6592
6642
|
server.registerTool('bluesky_post_metrics', {
|
|
6593
6643
|
title: 'Read likes, reposts, replies and quotes on your Bluesky posts',
|
|
6594
6644
|
description: 'Read live engagement for up to 25 of the connected account’s Bluesky posts — likes, reposts, replies, quotes and bookmarks. Address a post by its AT-URI (the `at://…` value post_to_bluesky returns), not its web URL. Bluesky publishes NO impression or view count in any AT Protocol lexicon, so these are COUNTS with no denominator and no engagement rate can be computed from them — do not present one. A uri Bluesky returns nothing for is reported as MISSING (deleted, or not on the connected account), never as zero engagement. Read-only, 0 credits. Needs Bluesky connected.',
|
|
6595
|
-
inputSchema: { uris: z.array(z.string()).describe('AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call)') },
|
|
6645
|
+
inputSchema: { ...MANAGE_BRAND, uris: z.array(z.string()).describe('AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call)') },
|
|
6596
6646
|
outputSchema: { handle: z.string().optional(), count: z.number().optional(), found: z.number().optional(), missing: z.number().optional(), posts: z.array(z.object({ uri: z.string().optional(), found: z.boolean().optional(), handle: z.string().optional(), text: z.string().optional(), url: z.string().optional(), indexedAt: z.string().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional() })).optional(), note: z.string().optional() },
|
|
6597
6647
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6598
6648
|
}, wrap(async (a) => {
|
|
6599
|
-
const d = await apiGet('/api/bluesky/post-metrics', { uris: (a.uris || []).join(',') });
|
|
6649
|
+
const d = await apiGet('/api/bluesky/post-metrics', queryBrand(a, { uris: (a.uris || []).join(',') }));
|
|
6600
6650
|
const rows = (d.posts || []).map(p => p.found
|
|
6601
6651
|
? ` • ${p.text ? `“${p.text.slice(0, 80)}${p.text.length > 80 ? '…' : ''}”` : p.uri} — ${Object.entries(p.metrics || {}).map(([k, v]) => `${v} ${k}`).join(', ') || 'no counts returned'}`
|
|
6602
6652
|
: ` • ${p.uri} — MISSING (deleted, or not on this account) — not zero engagement`);
|
|
@@ -6767,6 +6817,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6767
6817
|
// 2026-08-05). An agent asked to take a TikTok down must be able to say so without a failed round trip.
|
|
6768
6818
|
description: 'The connected account’s own TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. TWO WAYS TO ASK: with no arguments it lists the most recent (newest first, up to 20 a page); with videoIds it reads THOSE posts directly however old they are, which is how you answer "how did that specific video do" without paging back through the account. Any id TikTok does not return comes back under `unresolved` — meaning it is not on this account or no longer exists, which TikTok does not distinguish — never as a zero. Only ever the connected user’s OWN videos. Warning: TIKTOK OFFERS NO WAY TO DELETE OR EDIT A PUBLISHED POST through its API — not the caption, not the privacy level, not the comment/duet/stitch settings, not the cover. Every one of those is fixed at the moment of publishing. If the user wants a TikTok changed or taken down, tell them plainly that it has to be done in the TikTok app; do not look for a tool for it. Read-only, 0 credits. Needs TikTok connected.',
|
|
6769
6819
|
inputSchema: {
|
|
6820
|
+
...MANAGE_BRAND,
|
|
6770
6821
|
limit: z.number().optional().describe('1-20, default 10 (ignored when videoIds is given)'),
|
|
6771
6822
|
videoIds: z.array(z.string()).optional().describe('read these specific TikTok video ids instead of listing recent ones — up to 20 per call'),
|
|
6772
6823
|
},
|
|
@@ -6774,7 +6825,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6774
6825
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6775
6826
|
}, wrap(async (a) => {
|
|
6776
6827
|
const ids = (a.videoIds || []).filter(Boolean);
|
|
6777
|
-
const d = await apiGet('/api/tiktok/videos', ids.length ? { videoIds: ids.join(',') } : (a.limit ? { limit: a.limit } : {}));
|
|
6828
|
+
const d = await apiGet('/api/tiktok/videos', queryBrand(a, ids.length ? { videoIds: ids.join(',') } : (a.limit ? { limit: a.limit } : {})));
|
|
6778
6829
|
const rows = (d.videos || []).map((v, i) => `${i + 1}. ${(v.title || '(no caption)').slice(0, 70)} — ${v.views ?? '?'} views, ${v.likes ?? '?'} likes${v.url ? ` — ${v.url}` : ''}`);
|
|
6779
6830
|
// An id TikTok did not return is STATED, never silently dropped — otherwise a short list reads as the whole answer.
|
|
6780
6831
|
const missing = (d.unresolved || []).length ? `\nNOT RETURNED by TikTok (not on this account, or gone): ${d.unresolved.join(', ')}` : '';
|
|
@@ -13641,6 +13692,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13641
13692
|
title: 'Posts carrying the brand’s hashtags on TikTok',
|
|
13642
13693
|
description: "Public posts whose captions carry one of the brand hashtags this account has enabled. It is the hashtag half of brand monitoring, where list_tiktok_mentions covers @-mentions. Omit `hashtag` for the top posts across every enabled tag; pass one to narrow to it. TWO TIKTOK BEHAVIOURS THAT READ AS BUGS IF NOBODY SAYS THEM: the hashtag filter is CASE-SENSITIVE and must exactly match an enabled tag, and filtering to one tag makes matched_hashtags come back EMPTY on every row. NOTHING IS RETURNED UNTIL HASHTAGS ARE ENABLED. That is a setup step rather than a result: use list_tiktok_brand_hashtags and manage_tiktok_brand_hashtags first, and allow 24 hours after enabling. Capped at the top 1,000 posts of the last 90 days. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.",
|
|
13643
13694
|
inputSchema: {
|
|
13695
|
+
...MANAGE_BRAND,
|
|
13644
13696
|
hashtag: z.string().optional().describe('one ENABLED tag, spelled exactly as enabled. The match is case-sensitive'),
|
|
13645
13697
|
days: z.number().optional().describe('1 to 90. Default 90'),
|
|
13646
13698
|
regions: z.array(z.string()).optional(),
|
|
@@ -13653,7 +13705,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13653
13705
|
outputSchema: { hashtag: z.string().nullable().optional(), posts: z.array(z.any()).optional(), cursor: z.number().nullable().optional(), hasMore: z.boolean().optional(), note: z.string().optional() },
|
|
13654
13706
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
13655
13707
|
}, wrap(async (a) => {
|
|
13656
|
-
const d = await apiGet('/api/tiktok-account/brand-hashtag-posts', a);
|
|
13708
|
+
const d = await apiGet('/api/tiktok-account/brand-hashtag-posts', (({ brand: _b, ...q }) => queryBrand(a, q))(a));
|
|
13657
13709
|
const list = d.posts || [];
|
|
13658
13710
|
return ok(list.length
|
|
13659
13711
|
? `${list.length} post(s) carrying ${d.hashtag ? '#' + d.hashtag : 'this brand’s hashtags'}:\n` + list.map(p => `· ${p.itemId} · ${p.likes ?? '?'} likes, ${p.comments ?? '?'} comments${(p.matchedHashtags || []).length ? ` · ${p.matchedHashtags.map(h => '#' + h).join(' ')}` : ''} · ${(p.caption || '(no caption)').slice(0, 80)}`).join('\n') + `\n${d.note || ''}`
|
|
@@ -15747,6 +15799,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15747
15799
|
title: 'Edit or delete a LinkedIn post',
|
|
15748
15800
|
description: 'Edit or delete a published LinkedIn post — personal profile or company Page. Pass postUrn, the full urn returned when it was published. action:"edit" changes ONLY THE COPY: LinkedIn does not allow the image or video of a published post to be replaced, so a new visual means a NEW post — tell the user that instead of promising a swap. action:"delete" is immediate and public and requires confirm:true.',
|
|
15749
15801
|
inputSchema: {
|
|
15802
|
+
...MANAGE_BRAND,
|
|
15750
15803
|
postUrn: z.string().describe('the full LinkedIn post urn returned by publishing'),
|
|
15751
15804
|
action: z.enum(['edit', 'delete']),
|
|
15752
15805
|
text: z.string().optional().describe('the new copy, for action:"edit"'),
|
|
@@ -16196,6 +16249,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16196
16249
|
title: 'Edit or delete a published post',
|
|
16197
16250
|
description: 'Edit the text of, or delete, a published post. target:"facebook" -> edit the message (action:"edit", message:…) OR delete (action:"delete"); target:"threads" -> delete only (Threads has no edit API); target:"instagram" -> DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings > Connectors > Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.',
|
|
16198
16251
|
inputSchema: {
|
|
16252
|
+
...MANAGE_BRAND,
|
|
16199
16253
|
postId: z.string().describe('the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media'),
|
|
16200
16254
|
action: z.enum(['edit', 'delete']).describe('edit the text (FB only) or delete the post'),
|
|
16201
16255
|
target: z.enum(['facebook', 'threads', 'instagram']).optional().describe('default facebook'),
|
|
@@ -19815,6 +19869,7 @@ function memoryNoteVerdict(text) {
|
|
|
19815
19869
|
title: 'List the Page’s / Instagram account’s own posts',
|
|
19816
19870
|
description: "List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one, and until now the only way to have a postId was to have just published it yourself with post_to_meta. Use it for \"how did our last few posts do\", to find a post the user describes loosely, or before backfill_posts. Pass target:'instagram' for the linked IG account (Stories are excluded — Meta's media edge does not return them); Facebook hides unpublished drafts unless you ask for them. Only ever reads a Page the brand has connected. Read-only, 0 credits.",
|
|
19817
19871
|
inputSchema: {
|
|
19872
|
+
...MANAGE_BRAND,
|
|
19818
19873
|
target: z.enum(['facebook', 'instagram']).optional().describe("default facebook; 'instagram' reads the Page's linked IG business account"),
|
|
19819
19874
|
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
|
|
19820
19875
|
pageId: z.string().optional().describe('which connected Page — omit when the brand has only one'),
|
|
@@ -19825,7 +19880,7 @@ function memoryNoteVerdict(text) {
|
|
|
19825
19880
|
outputSchema: { target: z.string().optional(), account: z.string().nullable().optional(), pageId: z.string().optional(), posts: z.array(z.any()).optional(), cursor: z.string().nullable().optional(), note: z.string().optional(), sizeNote: z.string().optional() },
|
|
19826
19881
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
19827
19882
|
}, wrap(async (a) => {
|
|
19828
|
-
const d = await apiGet('/api/meta/posts', { ...(a.target ? { target: a.target } : {}), ...(a.account ? { account: a.account } : {}), ...(a.pageId ? { pageId: a.pageId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.includeUnpublished ? { includeUnpublished: 'true' } : {}) });
|
|
19883
|
+
const d = await apiGet('/api/meta/posts', queryBrand(a, { ...(a.target ? { target: a.target } : {}), ...(a.account ? { account: a.account } : {}), ...(a.pageId ? { pageId: a.pageId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.includeUnpublished ? { includeUnpublished: 'true' } : {}) }));
|
|
19829
19884
|
const rows = (d.posts || []).map(p => `• ${String(p.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 80)} — ${p.id}${p.publishedAt ? ` · ${String(p.publishedAt).slice(0, 10)}` : ''} · ${p.mediaKind}${p.url ? ` ${p.url}` : ''}`);
|
|
19830
19885
|
// A SHORTENED PAGE HAS TO SAY SO ON THE SURFACE PEOPLE USE (2026-09-17). `sizeNote` is how the server reports
|
|
19831
19886
|
// that Meta refused the full page and it asked for fewer rows — and it was produced by the route and read by
|
|
@@ -19840,13 +19895,14 @@ function memoryNoteVerdict(text) {
|
|
|
19840
19895
|
title: 'List what this brand has published',
|
|
19841
19896
|
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. This is the brand's own publishing history across all nine channels in one place, and it is the memory that makes 'which hook worked?' answerable at all. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author actually intended) or 'backfilled' (reconstructed from the platform afterwards, where the hook is only known if the post matched a Hermoso creation). A dash for engagement means the platform reported no number — that is NOT zero engagement. Read-only, 0 credits.",
|
|
19842
19897
|
inputSchema: {
|
|
19898
|
+
...MANAGE_BRAND,
|
|
19843
19899
|
channel: z.string().optional().describe('filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest, google_business'),
|
|
19844
19900
|
limit: z.number().optional().describe('max posts (default 50, max 200), newest first'),
|
|
19845
19901
|
},
|
|
19846
19902
|
outputSchema: { posts: z.array(z.any()).optional(), total: z.number().optional(), windows: z.array(z.string()).optional() },
|
|
19847
19903
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
19848
19904
|
}, wrap(async (a) => {
|
|
19849
|
-
const d = await apiGet('/api/posts', { ...(a.channel ? { channel: a.channel } : {}), ...(a.limit ? { limit: a.limit } : {}) });
|
|
19905
|
+
const d = await apiGet('/api/posts', queryBrand(a, { ...(a.channel ? { channel: a.channel } : {}), ...(a.limit ? { limit: a.limit } : {}) }));
|
|
19850
19906
|
const posts = d.posts || [];
|
|
19851
19907
|
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);
|
|
19852
19908
|
const rows = posts.map(p => {
|
|
@@ -19896,13 +19952,14 @@ function memoryNoteVerdict(text) {
|
|
|
19896
19952
|
title: 'Which hooks and subjects are getting traction',
|
|
19897
19953
|
description: "Aggregate this brand's published posts to answer WHICH HOOKS AND SUBJECTS WORK. Groups by hook (default), subject, format (recipe), channel, media format or posting hour, reports the engagement RATE within each channel, and ranks the best and worst POSTS in each channel. Describe a post by the creative it carried (what it shows, its format, its link), not by its caption — the caption is the least important part of a post. THREE THINGS IT DELIBERATELY WILL NOT DO, and you should repeat them rather than paper over them: (1) it never sums metrics across channels — a LinkedIn impression and a TikTok view are different units, so every comparison is within one channel; (2) it SUPPRESSES a verdict below 5 measured posts and says so, because a confident recommendation from 3 posts is worse than none; (3) a post with no recorded hook (published outside Hermoso, or backfilled without a creation match) counts toward channel and format totals but never votes on which hook works. Present the `finding` verbatim if there is one, and the reason if there is not. Read-only, 0 credits.",
|
|
19898
19954
|
inputSchema: {
|
|
19955
|
+
...MANAGE_BRAND,
|
|
19899
19956
|
axis: z.enum(['hook', 'subject', 'recipe', 'channel', 'media', 'hour']).optional().describe('what to group by — default hook; recipe = the format of the creative'),
|
|
19900
19957
|
channel: z.string().optional().describe('restrict to one channel'),
|
|
19901
19958
|
},
|
|
19902
19959
|
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() },
|
|
19903
19960
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
19904
19961
|
}, wrap(async (a) => {
|
|
19905
|
-
const d = await apiGet('/api/posts/performance', { ...(a.axis ? { axis: a.axis } : {}), ...(a.channel ? { channel: a.channel } : {}) });
|
|
19962
|
+
const d = await apiGet('/api/posts/performance', queryBrand(a, { ...(a.axis ? { axis: a.axis } : {}), ...(a.channel ? { channel: a.channel } : {}) }));
|
|
19906
19963
|
const gs = d.groups || [];
|
|
19907
19964
|
// OVER TIME + MEASUREMENT HEALTH (2026-09-11): week-by-week medians per channel and why unmeasured posts have no
|
|
19908
19965
|
// numbers. Printed even when no hook comparison exists yet — "is it getting better" does not need five hooks.
|
|
@@ -19945,6 +20002,7 @@ function memoryNoteVerdict(text) {
|
|
|
19945
20002
|
title: 'Read how the recorded posts performed',
|
|
19946
20003
|
description: "Fetch fresh performance numbers for this brand's recorded posts and store them as a time-series. Metrics ACCRUE, so a post is read at ~24 hours and again at ~7 days; this collects whichever readings are due and skips the ones already taken. A channel that cannot report a metric records it as ABSENT with the reason — never as zero — and a read that fails is recorded as 'could not tell', which contributes to nothing. X IS SKIPPED BY DEFAULT because X bills us per API call: pass includeMetered:true to include it, and tell the user it costs credits BEFORE you do. The skip is always reported so a channel missing from the numbers is never mistaken for one that performed badly. Free except for X.",
|
|
19947
20004
|
inputSchema: {
|
|
20005
|
+
...MANAGE_BRAND,
|
|
19948
20006
|
includeMetered: z.boolean().optional().describe('also read X, which BILLS CREDITS per post read — ask the user first'),
|
|
19949
20007
|
max: z.number().optional().describe('cap how many posts to read in this run (default 40)'),
|
|
19950
20008
|
remeasure: z.boolean().optional().describe('ALSO re-read posts older than 7 days whose every reading came back empty or failed — use after post_performance reports posts "read but empty", or once a channel\'s reader has been fixed. Otherwise those windows stay closed.'),
|
|
@@ -19952,7 +20010,7 @@ function memoryNoteVerdict(text) {
|
|
|
19952
20010
|
outputSchema: { collected: z.number().optional(), due: z.number().optional(), remaining: z.number().optional(), couldNotTell: z.number().optional(), skippedMetered: z.number().optional(), meteredNote: z.string().optional(), windows: z.array(z.any()).optional(), remeasured: z.number().optional(), remeasuredWithNumbers: z.number().optional() },
|
|
19953
20011
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
19954
20012
|
}, wrap(async (a) => {
|
|
19955
|
-
const d = await apiPost('/api/posts/collect', { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}), ...(a.remeasure ? { remeasure: true } : {}) });
|
|
20013
|
+
const d = await apiPost('/api/posts/collect', bodyBrand(a, { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}), ...(a.remeasure ? { remeasure: true } : {}) }));
|
|
19956
20014
|
if (a.remeasure) return ok(`Re-read ${d.remeasured || 0} old post(s) whose earlier readings were empty or failed; ${d.remeasuredWithNumbers || 0} now have numbers. Read ${d.collected} post(s) in total${d.remaining ? `, ${d.remaining} still waiting — run it again to continue` : ''}.${d.meteredNote ? ` ${d.meteredNote}` : ''}`, d);
|
|
19957
20015
|
const bits = [`Read ${d.collected} post(s)`, d.couldNotTell ? `${d.couldNotTell} could NOT be read (that is "could not tell", not zero engagement)` : null, d.gone ? `${d.gone} no longer exist at the platform (deleted or taken down) and will not be read again` : null, d.remaining ? `${d.remaining} still due — call again` : null, d.meteredNote || null].filter(Boolean);
|
|
19958
20016
|
return ok(`${bits.join('. ')}.${d.collected ? ' Ask post_performance which hooks are winning.' : ''}`, d);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.269",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 855 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",
|