hermoso 0.1.408 → 0.1.410
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/roster-scope.mjs +2 -0
- package/mcp/tools.mjs +104 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**912 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**A Chrome extension too.** [Hermoso: save and clone ads](https://chromewebstore.google.com/detail/hermoso-save-and-clone-ad/kmknkbldeilchhblbfddfnfbadmgcklc)
|
|
@@ -235,7 +235,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
235
235
|
|
|
236
236
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
237
237
|
|
|
238
|
-
### What the
|
|
238
|
+
### What the 912 tools cover
|
|
239
239
|
|
|
240
240
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
241
241
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -97,6 +97,8 @@ export const TOOL_PROVIDER_RULES = [
|
|
|
97
97
|
[/hubspot/, 'hubspot'],
|
|
98
98
|
// Intercom (2026-10-12): the brand's OWN support inbox, offered only to a workspace that connected it.
|
|
99
99
|
[/intercom/, 'intercom'],
|
|
100
|
+
// Vercel (2026-10-16): the brand's OWN websites, offered only to a workspace that connected Vercel.
|
|
101
|
+
[/vercel/, 'vercel'],
|
|
100
102
|
// ── posting-only channels ──
|
|
101
103
|
[/youtube/, 'youtube'],
|
|
102
104
|
[/google_business|business_location|^list_business_(categories|attributes)$|^business_google_updated$/, 'google_business'],
|
package/mcp/tools.mjs
CHANGED
|
@@ -89,7 +89,7 @@ const creatorLine = (c) => {
|
|
|
89
89
|
// import ../lib, so the words live here.
|
|
90
90
|
// "I OWN THE RIGHTS TO THIS FACE" (2026-09-29): the first choice a flagged face offers (server lib/likeness-refusal.mjs).
|
|
91
91
|
const FACE_ROUTE_CLIP_DESC = "'face_lane' = the user's \"I own the rights to this face\" for a real person in the source clip (paid plans). Only on the user's say-so, never on your own.";
|
|
92
|
-
const FACE_ROUTE_DESC = "
|
|
92
|
+
const FACE_ROUTE_DESC = "'face_lane' records the user's statement \"I own the rights to this face\" for the real person in their reference (paid plans; the likeness terms apply). Set it only when the user picked that option after a render said it could not use that face, and send the same request again; it then renders through Hermoso's face library at the normal price. Never set it on your own. The other options are a different `model` or one of the user's saved `creator`s.";
|
|
93
93
|
const LIKENESS_TERMS = 'Using a real person’s face, likeness or voice confirms you are them or have their consent, take full, unlimited responsibility for its use and will cover any claim against Hermoso (hermoso.ai/terms). Paid plan only; recorded.';
|
|
94
94
|
function rowLines(rows, max = 40) {
|
|
95
95
|
const all = Array.isArray(rows) ? rows : [];
|
|
@@ -189,6 +189,7 @@ export const INDEPENDENCE = 'INDEPENDENT AREAS, NOT A PIPELINE — research, cre
|
|
|
189
189
|
export const KEY_CONNECTORS = {
|
|
190
190
|
applovin_ads: { label: 'AppLovin Ads', route: 'applovin-ads', fields: { reportKey: 's', eventKey: '', apiKey: 's', capiKey: 's', accountId: '' }, how: 'the Reporting Key and the AppLovin Event Key from the AppLovin dashboard ▸ your account, top right ▸ Keys; plus apiKey (the Campaign Management key) and capiKey (the Conversion API key) when AppLovin has enabled those for the account (accountId only for an app account)' },
|
|
191
191
|
stripe: { label: 'Stripe', route: 'stripe', fields: { apiKey: 'rs' }, how: 'a secret (sk_) or restricted (rk_) key from Stripe ▸ Developers ▸ API keys' },
|
|
192
|
+
vercel: { label: 'Vercel', route: 'vercel', fields: { token: 'rs', team: '' }, how: 'an access token from vercel.com/account/tokens (Settings ▸ Tokens ▸ Create); team is the team id (team_…) or slug when the token was made for a team' },
|
|
192
193
|
openai_ads: { label: 'ChatGPT Ads', route: 'openai-ads', fields: { apiKey: 'rs' }, how: 'an Advertiser API key from ChatGPT Ads Manager ▸ Settings ▸ General ▸ API Keys ▸ Create New Key' },
|
|
193
194
|
apple_ads: { label: 'Apple Ads', route: 'apple-ads', fields: { clientId: '', teamId: '', keyId: '', privateKey: 's', setupToken: 's', orgId: '' }, how: 'clientId, teamId and keyId (Apple Ads ▸ Account Settings ▸ API shows all three once a public key is saved there), plus privateKey for a key already registered with Apple, or setupToken from a first call with no fields, which generates the key pair' },
|
|
194
195
|
bluesky: { label: 'Bluesky', route: 'bluesky', fields: { identifier: 'r', appPassword: 'rs', pds: '' }, how: 'the handle and an APP password from Bluesky ▸ Settings ▸ Privacy and Security ▸ App Passwords (pds only for a self-hosted server)' },
|
|
@@ -7123,11 +7124,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7123
7124
|
}));
|
|
7124
7125
|
server.registerTool('list_x_chats', {
|
|
7125
7126
|
title: 'List X Chat conversations',
|
|
7126
|
-
description: "The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation returned, so keep maxResults small." + XC_SCOPE_NOTE,
|
|
7127
|
+
description: "The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. view 'message_requests' lists the MESSAGE REQUESTS waiting outside the inbox (inbox 'priority', the default, or 'low_quality' for X's filtered tab): who sent each one (@handle, name, followers, verified), which accounts the brand follows also follow them, and whether they changed their name or picture recently (X's impersonation hint); the message itself is encrypted. Show that to the user before manage_x_chat accept_request. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation or request returned, plus one user lookup per request sender, so keep maxResults small." + XC_SCOPE_NOTE,
|
|
7127
7128
|
inputSchema: {
|
|
7128
|
-
view: z.enum(['conversations', 'admin_requests', 'invite']).optional().describe("default 'conversations'"),
|
|
7129
|
+
view: z.enum(['conversations', 'admin_requests', 'invite', 'message_requests']).optional().describe("default 'conversations'"),
|
|
7130
|
+
inbox: z.enum(['priority', 'low_quality']).optional().describe("message_requests: X's Priority tab (default) or the filtered low-quality one"),
|
|
7131
|
+
withSenders: z.boolean().optional().describe('message_requests: resolve who sent each request (default true, each sender billed)'),
|
|
7129
7132
|
id: z.string().optional().describe('one conversation; for admin_requests, the group (g + digits)'),
|
|
7130
|
-
maxResults: z.number().optional().describe('1-100, default 10; every conversation returned is billed'),
|
|
7133
|
+
maxResults: z.number().optional().describe('1-100 (message_requests 1-50), default 10; every conversation or request returned is billed'),
|
|
7131
7134
|
paginationToken: z.string().optional().describe('nextToken from a previous call'),
|
|
7132
7135
|
withMembers: z.boolean().optional().describe('also resolve member/admin @handles (each one billed)'),
|
|
7133
7136
|
inviteLink: z.string().optional().describe('an x.com/i/chat/group_join/... link, for view invite'),
|
|
@@ -7139,15 +7142,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7139
7142
|
const cost = `Cost ${d.costCredits ?? '?'} credits.`;
|
|
7140
7143
|
if (d.view === 'invite') { const i = d.invite || {}; return ok(`Invite ${i.url || '?'}: "${i.groupName || '(no name)'}", ${i.memberCount ?? '?'} members, the brand is ${i.joinState || '?'}${i.expiresAt ? `, expires ${i.expiresAt}` : ''}. ${cost}`, d); }
|
|
7141
7144
|
if (d.view === 'admin_requests') return ok(`${d.count} admin request(s) in ${d.id}:\n` + (d.requests || []).map((u) => `- ${u.username || u.id} (${u.id})`).join('\n') + `\n${cost}`, d);
|
|
7142
|
-
|
|
7143
|
-
|
|
7145
|
+
if (d.view === 'message_requests') return ok(`${d.count} message request(s) in the ${d.inbox === 'low_quality' ? 'low-quality' : 'Priority'} tab${d.nextToken ? ' (more available, pass paginationToken)' : ''}:\n` + (d.requests || []).map((m) => `- ${(m.sender && m.sender.username) || m.senderId}${m.sender && m.sender.name ? ` (${m.sender.name})` : ''}, id ${m.senderId}${m.sender && m.sender.followers != null ? `, ${m.sender.followers} followers` : ''}${m.sender && m.sender.verified ? ', verified' : ''}${m.followedByIds && m.followedByIds.length ? `, followed by ${m.followedByIds.length} account(s) you follow` : ''}${m.recentlyChangedProfile ? ', WARNING: changed name or picture recently' : ''}`).join('\n') + `\n${d.note || ''} ${cost}`.trim(), d);
|
|
7146
|
+
const rows = (d.conversations || []).slice(0, 25).map((c) => `- ${c.id} ${c.isGroup ? 'group' : '1:1'}${c.unread ? ', unread' : ''}${c.archived ? ', archived' : ''}${c.muted ? ', muted' : ''}${c.disappearAfterMs ? `, messages disappear after ${Math.round(c.disappearAfterMs / 3600e3)}h` : ''}${c.memberIds ? `, ${c.memberIds.length} members` : ''}${c.pendingJoinIds && c.pendingJoinIds.length ? `, ${c.pendingJoinIds.length} waiting to join` : ''}${c.invite && c.invite.url ? `, invite ${c.invite.url}` : ''}`);
|
|
7147
|
+
return ok(`${d.count} X Chat conversation(s)${d.nextToken ? ' (more available, pass paginationToken)' : ''}${d.hasMessageRequests ? '; message requests are waiting (view message_requests)' : ''}:\n${rows.join('\n') || '(none)'}\n${d.note || ''} ${cost}`.trim(), d);
|
|
7144
7148
|
}));
|
|
7145
7149
|
server.registerTool('manage_x_chat', {
|
|
7146
7150
|
title: 'Manage an X Chat conversation',
|
|
7147
|
-
description: "Manage one of the connected X account's X Chat conversations (id from list_x_chats). For the brand only: 'mute' / 'unmute'; 'delete' removes it from the brand's inbox for good while the others keep theirs (confirm:true). For everyone in it: 'set_disappearing' (disappearAfter like '24h' or '7d'), 'clear_disappearing', 'screen_capture' (blockScreenshots OR notifyOnScreenshot, one per call). GROUPS (id starts with g; the brand must be an admin): 'admin_settings' (adminOnly {addMember, editGroupInfo, sendMessage, startCall, editMessageTtl, blockScreenCapture}; sendMessage:true makes it announcement-only), 'add_admins' / 'remove_admins' (userIds), 'remove_members' (userIds, confirm:true), 'leave' (confirm:true), 'approve_admin_request' / 'reject_admin_request' (userId), 'reject_join_requests' (userIds; APPROVING a join re-keys the encrypted group, so it is done in the X app), 'enable_invite' (optional title, avatarUrl, welcomeMessage, expiresAt; X stores that preview UNENCRYPTED and shows it to anyone with the link), 'disable_invite' (the shared link dies for good: confirm:true). Confirm the exact conversation with the user before passing confirm:true; a refusal is free. The answer is X's own yes or no, never an echo." + XC_SCOPE_NOTE,
|
|
7151
|
+
description: "Manage one of the connected X account's X Chat conversations (id from list_x_chats). For the brand only: 'mute' / 'unmute'; 'delete' removes it from the brand's inbox for good while the others keep theirs (confirm:true). For everyone in it: 'set_disappearing' (disappearAfter like '24h' or '7d'), 'clear_disappearing', 'screen_capture' (blockScreenshots OR notifyOnScreenshot, one per call). GROUPS (id starts with g; the brand must be an admin): 'admin_settings' (adminOnly {addMember, editGroupInfo, sendMessage, startCall, editMessageTtl, blockScreenCapture}; sendMessage:true makes it announcement-only), 'add_admins' / 'remove_admins' (userIds), 'remove_members' (userIds, confirm:true), 'leave' (confirm:true), 'approve_admin_request' / 'reject_admin_request' (userId), 'reject_join_requests' (userIds; APPROVING a join re-keys the encrypted group, so it is done in the X app), 'enable_invite' (optional title, avatarUrl, welcomeMessage, expiresAt; X stores that preview UNENCRYPTED and shows it to anyone with the link), 'disable_invite' (the shared link dies for good: confirm:true). INBOX, for the brand only: 'archive' / 'unarchive', 'mark_read' (may send the other person a read receipt). MESSAGE REQUESTS: 'accept_request' (id = the sender's numeric id, or the request's conversationId from list_x_chats view message_requests) moves the conversation into the inbox; show the user who sent it first, since the message itself cannot be read here. Confirm the exact conversation with the user before passing confirm:true; a refusal is free. The answer is X's own yes or no, never an echo." + XC_SCOPE_NOTE,
|
|
7148
7152
|
inputSchema: {
|
|
7149
|
-
action: z.enum(['mute', 'unmute', 'delete', 'set_disappearing', 'clear_disappearing', 'screen_capture', 'admin_settings', 'add_admins', 'remove_admins', 'remove_members', 'leave', 'approve_admin_request', 'reject_admin_request', 'reject_join_requests', 'enable_invite', 'disable_invite']),
|
|
7150
|
-
id: z.string().describe('conversation id from list_x_chats: g + digits for a group, or the other person\'s numeric id for a 1:1'),
|
|
7153
|
+
action: z.enum(['mute', 'unmute', 'delete', 'set_disappearing', 'clear_disappearing', 'screen_capture', 'admin_settings', 'add_admins', 'remove_admins', 'remove_members', 'leave', 'approve_admin_request', 'reject_admin_request', 'reject_join_requests', 'enable_invite', 'disable_invite', 'accept_request', 'archive', 'unarchive', 'mark_read']),
|
|
7154
|
+
id: z.string().describe('conversation id from list_x_chats: g + digits for a group, or the other person\'s numeric id for a 1:1 (accept_request: the sender\'s numeric id)'),
|
|
7151
7155
|
userIds: z.array(z.string()).optional().describe('NUMERIC X account ids (add_admins, remove_admins, remove_members, reject_join_requests)'),
|
|
7152
7156
|
userId: z.string().optional().describe('NUMERIC X account id (approve_admin_request, reject_admin_request)'),
|
|
7153
7157
|
disappearAfter: z.string().optional().describe("set_disappearing: '24h', '7d', '90m' or a number of seconds"),
|
|
@@ -7164,6 +7168,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7164
7168
|
const d = await apiPost('/api/x/chats/manage', a);
|
|
7165
7169
|
const cost = `Cost ${d.costCredits ?? '?'} credits.`;
|
|
7166
7170
|
if (d.action === 'enable_invite') return ok(d.confirmed ? `Invite link on: ${d.invite && d.invite.url}${d.invite && d.invite.expiresAt ? ` (expires ${d.invite.expiresAt})` : ''}. ${d.note || ''} ${cost}` : `X did not return an invite link. ${cost}`, d);
|
|
7171
|
+
if (d.action === 'accept_request') return ok(d.confirmed ? `Accepted: the conversation with ${d.id} is in the brand's X inbox now. ${cost}` : `X did not confirm accepting the request from ${d.id}. ${cost}`, d);
|
|
7167
7172
|
return ok(`${d.confirmed ? 'Done' : 'X did not confirm it'}: ${String(d.action).replace(/_/g, ' ')} on ${d.id}${d.userIds ? ` (${d.userIds.join(', ')})` : ''}. ${cost}`, d);
|
|
7168
7173
|
}));
|
|
7169
7174
|
server.registerTool('x_user', {
|
|
@@ -10945,6 +10950,92 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10945
10950
|
const rows = (d.profiles || []).slice(0, 40).map(pr => `• ${pr.distinctId} — ${Object.entries(pr.properties || {}).slice(0, 6).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ')}`);
|
|
10946
10951
|
return ok([head, ...rows, d.note || '', d.pageNote || ''].filter(Boolean).join('\n'), d);
|
|
10947
10952
|
}));
|
|
10953
|
+
// ---------- Vercel (2026-10-16): the brand's OWN websites — projects, deployments, domains, Web Analytics, and a landing page deployed from files ----------
|
|
10954
|
+
const VC_NEEDS = ' Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).';
|
|
10955
|
+
server.registerTool('list_vercel_projects', {
|
|
10956
|
+
title: 'List Vercel projects',
|
|
10957
|
+
description: "The brand's Vercel projects (its websites): name, id, framework, production URL and state, the latest deployment (URL, state, target, when), the linked git repo, and whether Web Analytics and Speed Insights are on. search filters by project name. Read-only, free." + VC_NEEDS,
|
|
10958
|
+
inputSchema: { search: z.string().optional().describe('part of a project name'), limit: z.number().optional().describe('1-50, default 20'), cursor: z.string().optional().describe('nextCursor from a previous call') },
|
|
10959
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), projects: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
10960
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
10961
|
+
}, wrap(async (a = {}) => {
|
|
10962
|
+
const d = await apiGet('/api/vercel/projects', a);
|
|
10963
|
+
return ok(d.count ? `${d.count} Vercel project(s) on ${d.account}:\n` + (d.projects || []).map(p => `- ${p.name} (${p.id})${p.framework ? `, ${p.framework}` : ''}${p.productionUrl ? `, ${p.productionUrl}` : ', no production deployment'}${p.latestDeployment ? `, latest ${p.latestDeployment.state || '?'} ${p.latestDeployment.target}` : ''}, analytics ${p.webAnalytics}`).join('\n') + (d.nextCursor ? `\n(more: cursor ${d.nextCursor})` : '') : `No Vercel projects${a.search ? ' match that name' : ''} on ${d.account}. That is a real empty list, not a failed read.`, d);
|
|
10964
|
+
}));
|
|
10965
|
+
server.registerTool('list_vercel_deployments', {
|
|
10966
|
+
title: 'List Vercel deployments',
|
|
10967
|
+
description: "Deployments on the brand's Vercel account, newest first: URL, state (READY, BUILDING, QUEUED, ERROR, CANCELED...), target (production or preview), when it was created and became ready, the commit message and Vercel's inspector link. Filter by projectId, target or state, or pass deploymentId (an id or a deployment URL) to read one, e.g. to see whether a page you just deployed is READY. Read-only, free." + VC_NEEDS,
|
|
10968
|
+
inputSchema: {
|
|
10969
|
+
projectId: z.string().optional().describe('project id or name'),
|
|
10970
|
+
deploymentId: z.string().optional().describe('read ONE deployment by id or URL'),
|
|
10971
|
+
target: z.enum(['production', 'preview', 'staging']).optional(),
|
|
10972
|
+
state: z.enum(['BLOCKED', 'BUILDING', 'CANCELED', 'DELETED', 'ERROR', 'INITIALIZING', 'QUEUED', 'READY']).optional(),
|
|
10973
|
+
limit: z.number().optional().describe('1-50, default 20'),
|
|
10974
|
+
},
|
|
10975
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), deployments: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
10976
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
10977
|
+
}, wrap(async (a = {}) => {
|
|
10978
|
+
const d = await apiGet('/api/vercel/deployments', a);
|
|
10979
|
+
return ok(d.count ? `${d.count} deployment(s):\n` + (d.deployments || []).map(x => `- ${x.url || x.id}, ${x.state || '?'}, ${x.target}, ${x.name}${x.createdAt ? `, ${String(x.createdAt).slice(0, 16).replace('T', ' ')}` : ''} (${x.id})${x.error ? `: ${x.error}` : ''}`).join('\n') : 'No deployments match. That is a real empty list, not a failed read.', d);
|
|
10980
|
+
}));
|
|
10981
|
+
server.registerTool('list_vercel_domains', {
|
|
10982
|
+
title: 'List Vercel domains',
|
|
10983
|
+
description: "The domains on the brand's Vercel account (verified or not, when they expire), or with projectId the domains assigned to one project (and any redirect or git branch on them). Read-only, free." + VC_NEEDS,
|
|
10984
|
+
inputSchema: { projectId: z.string().optional().describe("project id or name, for that project's domains"), limit: z.number().optional().describe('1-50, default 20') },
|
|
10985
|
+
outputSchema: { account: z.string().optional(), scope: z.string().optional(), projectId: z.string().optional(), count: z.number().optional(), domains: z.array(z.any()).optional() },
|
|
10986
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
10987
|
+
}, wrap(async (a = {}) => {
|
|
10988
|
+
const d = await apiGet('/api/vercel/domains', a);
|
|
10989
|
+
return ok(d.count ? `${d.count} domain(s)${d.scope === 'project' ? ` on project ${d.projectId}` : ''}:\n` + (d.domains || []).map(x => `- ${x.name}${x.verified === false ? ', NOT verified' : x.verified ? ', verified' : ''}${x.redirect ? `, redirects to ${x.redirect}` : ''}${x.expiresAt ? `, expires ${String(x.expiresAt).slice(0, 10)}` : ''}`).join('\n') : 'No domains here. That is a real empty list, not a failed read.', d);
|
|
10990
|
+
}));
|
|
10991
|
+
server.registerTool('vercel_web_analytics', {
|
|
10992
|
+
title: 'Vercel Web Analytics',
|
|
10993
|
+
description: "Vercel WEB ANALYTICS for one project: page views and unique visitors over a window (default the last 30 days, production traffic), and with `by` a breakdown by requestPath (which pages), referrerHostname (where visitors came from), country, deviceType, osName, browserName, route, utmSource / utmMedium / utmCampaign / utmContent / utmTerm (which campaigns and ads brought them), or a time step (day, week, month). Use it to see which landing pages and ad campaigns actually bring traffic. Needs Web Analytics turned on for the project in Vercel. Read-only, free." + VC_NEEDS,
|
|
10994
|
+
inputSchema: {
|
|
10995
|
+
projectId: z.string().describe('project id or name (list_vercel_projects)'),
|
|
10996
|
+
by: z.enum(['requestPath', 'referrerHostname', 'country', 'deviceType', 'osName', 'browserName', 'route', 'utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm', 'environment', 'hour', 'day', 'week', 'month', 'year']).optional(),
|
|
10997
|
+
days: z.number().optional().describe('1-366, default 30'),
|
|
10998
|
+
since: z.string().optional().describe('YYYY-MM-DD (instead of days)'),
|
|
10999
|
+
until: z.string().optional().describe('YYYY-MM-DD (default now)'),
|
|
11000
|
+
limit: z.number().optional().describe('rows in the breakdown, 1-50, default 10'),
|
|
11001
|
+
filter: z.string().optional().describe("Vercel's OData filter, e.g. utmSource eq 'facebook'"),
|
|
11002
|
+
},
|
|
11003
|
+
outputSchema: { account: z.string().optional(), projectId: z.string().optional(), since: z.string().optional(), until: z.string().optional(), totals: z.any().optional(), by: z.string().optional(), rows: z.array(z.any()).optional(), note: z.string().optional() },
|
|
11004
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
11005
|
+
}, wrap(async (a) => {
|
|
11006
|
+
const d = await apiGet('/api/vercel/analytics', a);
|
|
11007
|
+
const t = d.totals || {};
|
|
11008
|
+
return ok(`Vercel Web Analytics for ${d.projectId}, ${String(d.since).slice(0, 10)} to ${String(d.until).slice(0, 10)}: ${t.pageviews ?? '?'} page views, ${t.visitors ?? '?'} visitors.${d.rows ? `\nBy ${d.by}:\n` + d.rows.map(r => `- ${r.key ?? '(none)'}: ${r.pageviews ?? '?'} views${r.visitors != null ? `, ${r.visitors} visitors` : ''}`).join('\n') : ''}${d.note ? `\n${d.note}` : ''}`, d);
|
|
11009
|
+
}));
|
|
11010
|
+
server.registerTool('deploy_vercel_page', {
|
|
11011
|
+
title: 'Deploy a page to Vercel',
|
|
11012
|
+
description: "Publish a STATIC landing page to the brand's Vercel account from HTML (it becomes index.html) or a few files [{file, data, encoding:'utf-8'|'base64'}], up to 50 files and 4 MB. project names the Vercel project (created if it does not exist; it becomes part of the URL). target 'preview' (default) makes a new preview URL and leaves the live site alone; 'production' REPLACES what the project's domains serve. WITHOUT confirm:true nothing is sent and you get the exact preview to show the user; call again with confirm:true only after they say yes. The answer is Vercel's own deployment row (URL and state); a new deployment is QUEUED or BUILDING for a moment, so read it back with list_vercel_deployments deploymentId until READY. Free." + VC_NEEDS,
|
|
11013
|
+
inputSchema: {
|
|
11014
|
+
project: z.string().describe('Vercel project name, e.g. spring-sale'),
|
|
11015
|
+
html: z.string().optional().describe('the full HTML of the page'),
|
|
11016
|
+
files: z.array(z.object({ file: z.string(), data: z.string(), encoding: z.enum(['utf-8', 'base64']).optional() })).optional().describe('instead of (or as well as) html: relative paths with their content'),
|
|
11017
|
+
target: z.enum(['preview', 'production']).optional().describe('default preview'),
|
|
11018
|
+
confirm: z.boolean().optional().describe('true only after the user approved this exact deploy'),
|
|
11019
|
+
},
|
|
11020
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), preview: z.any().optional(), deployment: z.any().optional(), confirmed: z.boolean().optional(), note: z.string().optional() },
|
|
11021
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11022
|
+
}, wrap(async (a) => {
|
|
11023
|
+
const d = await apiPost('/api/vercel/deploy', a);
|
|
11024
|
+
if (d.dryRun) return ok(d.note, d);
|
|
11025
|
+
const x = d.deployment || {};
|
|
11026
|
+
return ok(`${d.confirmed ? 'Deployed' : 'Vercel did not return a deployment URL'}: ${x.url || x.id} (${x.state || 'queued'}, ${x.target}). ${d.note || ''}`.trim(), d);
|
|
11027
|
+
}));
|
|
11028
|
+
server.registerTool('delete_vercel_deployment', {
|
|
11029
|
+
title: 'Delete a Vercel deployment',
|
|
11030
|
+
description: "PERMANENTLY delete one Vercel deployment: its URL stops working and Vercel has no undo. Without confirm:true nothing is deleted and you get the deployment it would delete (URL, target, state) to show the user; a production deployment the domains still serve takes the site down. Call again with confirm:true only after the user says yes to that exact deployment. Free." + VC_NEEDS,
|
|
11031
|
+
inputSchema: { deploymentId: z.string().describe('from list_vercel_deployments'), confirm: z.boolean().optional().describe('true only after the user approved deleting this exact deployment') },
|
|
11032
|
+
outputSchema: { account: z.string().optional(), dryRun: z.boolean().optional(), deployment: z.any().optional(), deleted: z.string().nullable().optional(), confirmed: z.boolean().optional(), note: z.string().optional() },
|
|
11033
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
11034
|
+
}, wrap(async (a) => {
|
|
11035
|
+
const { deploymentId, ...rest } = a;
|
|
11036
|
+
const d = await apiPost(`/api/vercel/deployments/${encodeURIComponent(String(deploymentId || ''))}/delete`, rest);
|
|
11037
|
+
return ok(d.note, d);
|
|
11038
|
+
}));
|
|
10948
11039
|
// ---------- Stripe (2026-09-09): the brand's OWN revenue, read-only via a restricted key ----------
|
|
10949
11040
|
server.registerTool('stripe_report', {
|
|
10950
11041
|
title: 'Stripe revenue report',
|
|
@@ -17866,12 +17957,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17866
17957
|
server.group('create');
|
|
17867
17958
|
server.registerTool('plan_ad', {
|
|
17868
17959
|
title: 'Plan an ad concept',
|
|
17869
|
-
description: '
|
|
17960
|
+
description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends credits.',
|
|
17870
17961
|
inputSchema: {
|
|
17871
17962
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
|
|
17872
17963
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
17873
17964
|
format: z.enum(['auto', 'image', 'video']).optional().describe("'image', 'video', or 'auto' when unspecified"),
|
|
17874
|
-
durationSeconds: z.number().optional().describe('VIDEO ONLY — the total spot length the user explicitly asked for, in seconds, copied verbatim (30 for "a 30 second ad"). The planner authors the storyboard TO it: the scenes’ seconds sum to it and the script is word-budgeted for it. Supported range 4–180; anything outside is CLAMPED to it (the reply says so). A length that fits ONE clip of the render model renders as a single continuous pass; anything longer is STITCHED from acts filled to that model’s clip maximum with the remainder last (on a 15s-clip model, 40 -> 15+15+10 and 17 -> 13+4) — never time-compressed. The maximum is the model’s own: 15s on most, 30s on the longest-clip model, so a 30s ad can be one unbroken take rather than two acts. Omit when the user named no length; do NOT pass a guess:
|
|
17965
|
+
durationSeconds: z.number().optional().describe('VIDEO ONLY — the total spot length the user explicitly asked for, in seconds, copied verbatim (30 for "a 30 second ad"). The planner authors the storyboard TO it: the scenes’ seconds sum to it and the script is word-budgeted for it. Supported range 4–180; anything outside is CLAMPED to it (the reply says so). A length that fits ONE clip of the render model renders as a single continuous pass; anything longer is STITCHED from acts filled to that model’s clip maximum with the remainder last (on a 15s-clip model, 40 -> 15+15+10 and 17 -> 13+4) — never time-compressed. The maximum is the model’s own: 15s on most, 30s on the longest-clip model, so a 30s ad can be one unbroken take rather than two acts. Omit when the user named no length; do NOT pass a guess: when it is omitted the planner picks a length suited to the format, and a length the user names always wins.'),
|
|
17875
17966
|
hook: z.string().optional().describe('force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "macro_asmr"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.'),
|
|
17876
17967
|
setting: z.string().optional().describe('force the WHERE — a setting id from list_hooks (e.g. "kitchen", "gym", or a surreal one like "volcano_rim" / "airplane_wing", which are played 100% straight and never acknowledged). Omit for a neutral setting.'),
|
|
17877
17968
|
recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
|
|
@@ -18758,7 +18849,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18758
18849
|
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
|
|
18759
18850
|
inputSchema: {
|
|
18760
18851
|
prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
|
|
18761
|
-
raw: z.boolean().optional().describe('
|
|
18852
|
+
raw: z.boolean().optional().describe('Send the prompt to the model exactly as written, without the wording Hermoso usually adds (packaging and label guidance, reference lines, colour names for hex codes, quality hints). Use it when you want the model\'s own interpretation rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence (on Veo it is refused with the length and the cap instead). Billing, durable delivery and per-model validation are unchanged.'),
|
|
18762
18853
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame. ' + LIKENESS_TERMS),
|
|
18763
18854
|
speak: z.string().optional().describe('A PERSON SAYING THESE EXACT WORDS TO CAMERA — the default for any "make my photo talk" / spokesperson / talk-to-camera ask. Pass with `creator` or a portrait as `refImage`: the video model films them saying it in their own voice, matched to who they are, and the length follows the words (leave durationSeconds out). `prompt` is then the staging (e.g. "natural", "walking in a park"). Real footage, not an animated photo; generate_avatar is the animated-photo look, only when asked for by name'),
|
|
18764
18855
|
creator: z.string().optional().describe('STAR A SAVED CREATOR in this clip — their id from list_creators, or the name you know them by, or a PRESET AI creator from list_creators presets (exact name or id; free, no generation). Their saved portrait rides first among the references as the on-camera person, with their saved consent, exactly as render_ad casts them; a real person saved from a photo keeps their real face on camera. An unknown name is refused by name, nothing charged.'),
|
|
@@ -19578,7 +19669,7 @@ function memoryNoteVerdict(text) {
|
|
|
19578
19669
|
}));
|
|
19579
19670
|
server.registerTool('list_connectors', {
|
|
19580
19671
|
title: 'List connectors',
|
|
19581
|
-
description: 'List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. IT ALSO FLAGS A CONNECTION WHOSE PERMISSIONS ARE OUT OF DATE: a provider writes its granted permission set into the token at consent time, so a connection authorized before a permission was approved does not carry it and never will \u2014 those calls are refused by the provider and no retry or wait can change it. Check this FIRST when a connected provider starts refusing things. The fix is a browser: the user reconnects under Workspace > Connectors.
|
|
19672
|
+
description: 'List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. IT ALSO FLAGS A CONNECTION WHOSE PERMISSIONS ARE OUT OF DATE: a provider writes its granted permission set into the token at consent time, so a connection authorized before a permission was approved does not carry it and never will \u2014 those calls are refused by the provider and no retry or wait can change it. Check this FIRST when a connected provider starts refusing things. The fix is a browser: the user reconnects under Workspace > Connectors. Read-only, free.',
|
|
19582
19673
|
inputSchema: {},
|
|
19583
19674
|
outputSchema: { connectors: z.array(z.any()).optional(), providers: z.array(z.string()).optional() },
|
|
19584
19675
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.410",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 906 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|