hermoso 0.1.158 → 0.1.161
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 +3 -3
- package/mcp/client.mjs +25 -0
- package/mcp/hermoso-mcp.mjs +9 -2
- package/mcp/http.mjs +54 -5
- package/mcp/roster-scope.mjs +125 -0
- package/mcp/tools.mjs +206 -29
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**718 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
|
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
@@ -119,7 +119,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
119
119
|
|
|
120
120
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
121
121
|
|
|
122
|
-
### What the
|
|
122
|
+
### What the 718 tools cover
|
|
123
123
|
|
|
124
124
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
125
125
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -161,7 +161,7 @@ reviews, Q&A, insights) and is not offered — Google allowlists that API per pr
|
|
|
161
161
|
every call would 403 for every user. It is in `schedule_post`'s channel enum and refused at enqueue.
|
|
162
162
|
|
|
163
163
|
**Message customers on WhatsApp** — messaging, not an eleventh publishing channel: you message a person, and
|
|
164
|
-
|
|
164
|
+
nothing here posts to a feed. `list_whatsapp_accounts` finds the Business Account and its
|
|
165
165
|
numbers, `list_whatsapp_templates` / `create_whatsapp_template` / `delete_whatsapp_template` manage the templates
|
|
166
166
|
Meta reviews, and `send_whatsapp_message` sends one — confirm-gated, because it reaches a real phone and Meta
|
|
167
167
|
bills the business for the conversation. Two limits that are permanent facts about Meta's API rather than
|
package/mcp/client.mjs
CHANGED
|
@@ -177,6 +177,31 @@ export function forgetWorkspaceScope() {
|
|
|
177
177
|
const ctx = mcpCtx.getStore();
|
|
178
178
|
if (ctx) delete ctx._storeSuffix;
|
|
179
179
|
}
|
|
180
|
+
// ── WHICH PROVIDERS THIS WORKSPACE HAS ACTUALLY CONNECTED (2026-08-26) ───────────────────────────────────────────
|
|
181
|
+
// Read ONCE per session, at `initialize`, and handed to registerTools so the roster it advertises carries only
|
|
182
|
+
// tools the caller can actually use — see mcp/roster-scope.mjs for the law and applyToolGates for the seam.
|
|
183
|
+
//
|
|
184
|
+
// `/api/connectors/providers` and NOT `/api/connectors`: the full route does a live-token label backfill and a
|
|
185
|
+
// per-provider scope-drift read, i.e. outbound provider calls, and this sits on the handshake every client makes.
|
|
186
|
+
// The lean route answers from the store alone and resolves the workspace exactly the way the Studio's own
|
|
187
|
+
// connector read does (brand-shared from the owner's scope + personal from the caller's).
|
|
188
|
+
//
|
|
189
|
+
// NEVER THROWS, AND THAT IS THE WHOLE CONTRACT. A read that fails for any reason — an older server with no such
|
|
190
|
+
// route, a store blip, no bearer at all — returns `readOk:false`, which makes toolHeldBackByConnectors answer
|
|
191
|
+
// false for every tool and ships the FULL roster. A failed read must never be able to remove a paying customer's
|
|
192
|
+
// tools ([[failed-read-is-not-empty]]); the cost of being wrong in this direction is a slightly larger roster.
|
|
193
|
+
//
|
|
194
|
+
// DELIBERATELY NOT MEMOIZED. It is one call per session; caching it would be the one way a user who connects an
|
|
195
|
+
// account and reconnects their client still does not see the tools, and on the hosted twin a module-level cache
|
|
196
|
+
// would be a cross-tenant leak besides.
|
|
197
|
+
export async function connectedProviders() {
|
|
198
|
+
try {
|
|
199
|
+
const r = await apiGet('/api/connectors/providers');
|
|
200
|
+
const list = Array.isArray(r?.providers) ? r.providers : null;
|
|
201
|
+
if (!list) return { connected: new Set(), readOk: false }; // a shape we do not recognise is a failed read
|
|
202
|
+
return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true };
|
|
203
|
+
} catch { return { connected: new Set(), readOk: false }; }
|
|
204
|
+
}
|
|
180
205
|
// Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
|
|
181
206
|
// the server reads the raw body. Lets an agent post ARBITRARY user files (not just Hermoso renders).
|
|
182
207
|
export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
|
|
12
|
-
import { API_BASE } from './client.mjs';
|
|
12
|
+
import { API_BASE, connectedProviders } from './client.mjs';
|
|
13
13
|
|
|
14
14
|
// instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
|
|
15
15
|
// in tools.mjs, shared with the hosted connector (http.mjs), so every surface tells agents the same breadth.
|
|
@@ -27,7 +27,14 @@ const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
|
27
27
|
// people's configs today. Renaming a variable someone already set is how a working setup goes quiet.
|
|
28
28
|
const _scope = parseToolScope(process.env.HERMOSO_TOOLS || process.env.HEIST_TOOLS);
|
|
29
29
|
if (_scope.error) { console.error(`[hermoso-mcp] ${_scope.error}`); process.exit(1); }
|
|
30
|
-
|
|
30
|
+
// AND SCOPED TO WHAT THIS WORKSPACE HAS CONNECTED, not only to the groups asked for (2026-08-26). A tool bound to
|
|
31
|
+
// a provider nobody has connected can only answer `401 {connector:'<p>'}`, so listing it costs the caller context
|
|
32
|
+
// on every turn and buys them nothing — and a roster far past the 30-50 tool accuracy cliff is what makes a model
|
|
33
|
+
// pick the wrong tool. ONE read, here at startup; it NEVER throws and a failed read ships the FULL roster
|
|
34
|
+
// ([[failed-read-is-not-empty]]). Awaited before registerTools because the gate reads it at registration time —
|
|
35
|
+
// wiring it after would leave `readOk:false`, the gate would fail open, and the change would be silently inert.
|
|
36
|
+
const _conn = await connectedProviders();
|
|
37
|
+
registerTools(server, { only: _scope.groups, connectors: _conn });
|
|
31
38
|
|
|
32
39
|
const transport = new StdioServerTransport();
|
|
33
40
|
await server.connect(transport);
|
package/mcp/http.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
17
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
18
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
19
|
import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
|
|
20
|
-
import { mcpCtx } from './client.mjs';
|
|
20
|
+
import { mcpCtx, connectedProviders } from './client.mjs';
|
|
21
21
|
|
|
22
22
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
23
23
|
// `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
|
|
@@ -130,7 +130,22 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
130
130
|
// an OOM. Status and message are byte-identical to the SDK's, so no client sees a behaviour change.
|
|
131
131
|
const needSession = (res) => res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' }, id: null });
|
|
132
132
|
// The one place the host name is turned into a decision, so the anon and session paths cannot diverge.
|
|
133
|
-
|
|
133
|
+
//
|
|
134
|
+
// IT READS THE USER-AGENT TOO, AND THAT IS THE HALF THAT MAKES IT WORK (2026-08-24). `clientInfo` rides the
|
|
135
|
+
// `initialize` params and NOTHING ELSE, so a body-only test can only see the host on that one request. The
|
|
136
|
+
// anon discovery path is deliberately stateless (`sessionIdGenerator: undefined`, a fresh server per request),
|
|
137
|
+
// so a `tools/list` arriving as its own POST carried no name and every widget-host decision there was FALSE —
|
|
138
|
+
// and it silently stayed false, because the answer it produces is a full roster, which looks exactly like
|
|
139
|
+
// success. Measured on prod: `hermoso_capabilities` still shipped its widget and `buy_credits` was still
|
|
140
|
+
// offered to ChatGPT, i.e. the commerce withholding added in 2ba8e3a63 had never once applied.
|
|
141
|
+
//
|
|
142
|
+
// The UA is durable where clientInfo is not: Cloud Run's own request log shows ChatGPT sending
|
|
143
|
+
// `openai-mcp/1.0.0` on EVERY /mcp request (38 of 38 over the sampled window), initialize and tools/list
|
|
144
|
+
// alike. Read from the request rather than remembered, so a stateless path is covered by construction.
|
|
145
|
+
// Still advisory and still presentation-only: it may not change auth, scope or spend. Same regex for both
|
|
146
|
+
// fields so a host recognised one way is recognised the other.
|
|
147
|
+
const WIDGET_HOST_RE = /openai|chatgpt/i;
|
|
148
|
+
const isWidgetHost = (name, req) => WIDGET_HOST_RE.test(String(name || '')) || WIDGET_HOST_RE.test(String(req?.headers?.['user-agent'] || ''));
|
|
134
149
|
// The client's own name, off the initialize params. Never trusted for anything but presentation.
|
|
135
150
|
const clientInfoOf = (body) => {
|
|
136
151
|
try {
|
|
@@ -139,6 +154,22 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
139
154
|
} catch {}
|
|
140
155
|
return '';
|
|
141
156
|
};
|
|
157
|
+
// WHAT GETS REMEMBERED ON THE SESSION, and it is NOT simply "the name, or the UA if there isn't one". Written
|
|
158
|
+
// that way first and a check caught it: `clientInfoOf(req.body) || ua` never falls through, because a host
|
|
159
|
+
// ALWAYS names itself — the SDK refuses an initialize with no clientInfo at all ("Server not initialized"). So
|
|
160
|
+
// ChatGPT calling itself anything unremarkable would be remembered under that name, and `hostRendersWidgets()`
|
|
161
|
+
// matches this string at CALL time to decide whether a render is handed over as a URL or inlined as base64. A
|
|
162
|
+
// false negative there is how a 1.03MB inline block once made ChatGPT drop structuredContent entirely.
|
|
163
|
+
//
|
|
164
|
+
// So it prefers whichever field IDENTIFIES the host, exactly as isWidgetHost does, and falls back to the name
|
|
165
|
+
// when neither does — a host that is not a widget host is still remembered by its own name, for the log.
|
|
166
|
+
const rememberedClient = (req) => {
|
|
167
|
+
const named = clientInfoOf(req.body);
|
|
168
|
+
const ua = String(req.headers['user-agent'] || '').slice(0, 64);
|
|
169
|
+
if (named && WIDGET_HOST_RE.test(named)) return named;
|
|
170
|
+
if (WIDGET_HOST_RE.test(ua)) return ua;
|
|
171
|
+
return named || ua;
|
|
172
|
+
};
|
|
142
173
|
const hasInitialize = (body) => (Array.isArray(body) ? body : [body]).some((m) => m && m.method === 'initialize');
|
|
143
174
|
|
|
144
175
|
// ── PRE-AUTH DISCOVERY (registry crawlers + evaluating agents) ────────────────────────────────────────────────
|
|
@@ -178,7 +209,11 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
178
209
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
179
210
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
180
211
|
// only the authenticated path would leave both tools listed in the submission.
|
|
181
|
-
|
|
212
|
+
// NO `connectors` HERE, DELIBERATELY. There is no authorization on this request, so there is no workspace to
|
|
213
|
+
// scope to and nothing honest to read — and a registry crawler or an agent deciding whether to connect MUST
|
|
214
|
+
// see the real catalog, not a zero-connector one. registerTools treats an absent `connectors` exactly like a
|
|
215
|
+
// failed read: full roster. Do not "fix" this by reading the workspace off the request; it is forgeable.
|
|
216
|
+
registerTools(server, { only: scope?.groups, widgetHost: isWidgetHost(clientInfoOf(req.body), req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
182
217
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
183
218
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
184
219
|
await server.connect(transport);
|
|
@@ -216,8 +251,22 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
216
251
|
const scope = scopeFor(req, res);
|
|
217
252
|
if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
|
|
218
253
|
sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
|
|
254
|
+
// ── WHAT THIS CALLER CAN ACTUALLY USE (2026-08-26) ────────────────────────────────────────────────────────
|
|
255
|
+
// One free store read, on the ONE request per session that mints the roster, so the tools we advertise are
|
|
256
|
+
// the tools their workspace can call. A connector-bound tool for an unconnected provider can only answer
|
|
257
|
+
// `401 {connector:'<p>'}`; carrying it costs the caller context every turn and pushes the roster further
|
|
258
|
+
// past the 30-50 tool accuracy cliff. The MCP spec permits exactly this and nothing looser — the tool set
|
|
259
|
+
// "MAY vary by the authorization presented on the request … since credentials are per-request input, not
|
|
260
|
+
// connection state" (rev 2026-07-28, Tools ▸ Capabilities) — which is why it is keyed to the BEARER and not
|
|
261
|
+
// to the connection or to a query parameter.
|
|
262
|
+
//
|
|
263
|
+
// INSIDE `mcpCtx.run`, because that store is what puts this caller's token on the outbound /api call. Read
|
|
264
|
+
// it outside and it would go out unauthenticated, answer 401, and — correctly, by its own contract — fail
|
|
265
|
+
// OPEN with the full roster, so the whole change would be silently inert. Never throws; see
|
|
266
|
+
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
267
|
+
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
219
268
|
const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
220
|
-
registerTools(server, { only: scope.groups, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body)) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
269
|
+
registerTools(server, { only: scope.groups, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
221
270
|
const transport = new StreamableHTTPServerTransport({
|
|
222
271
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
223
272
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|
|
@@ -227,7 +276,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
227
276
|
// WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
|
|
228
277
|
// request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
|
|
229
278
|
// tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
|
|
230
|
-
entry = { transport, server, user, lastSeen: Date.now(), client:
|
|
279
|
+
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req) };
|
|
231
280
|
// LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
|
|
232
281
|
// ever read is a guess. One line per session (not per call) so a new host identifies itself once and
|
|
233
282
|
// the predicate can be corrected from evidence instead of from a hunch.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ── THE CONNECTOR SCOPE — ONE LAW, TWO SURFACES (2026-08-26) ─────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// WHICH CONNECTOR A TOOL NEEDS, and therefore whether carrying it in a roster buys the caller anything. Shipped
|
|
4
|
+
// first for the Studio chat (lib/studio-roster.mjs, which re-exports every symbol below), and lifted here so the
|
|
5
|
+
// MCP twins can apply the IDENTICAL decision. It lives in mcp/ rather than lib/ for one mechanical reason: the
|
|
6
|
+
// published npm package (cli/) ships `cli/mcp/**` and nothing else, so `./roster-scope.mjs` is the only path that
|
|
7
|
+
// resolves from BOTH mcp/tools.mjs and its byte-identical cli/mcp/tools.mjs twin.
|
|
8
|
+
//
|
|
9
|
+
// WHY GATE ON THE CONNECTION AT ALL. A connector-bound tool for a provider the account has not connected can only
|
|
10
|
+
// ever answer `401 {connector:'<p>'}` — "not connected has ONE shape". Carrying it buys the caller nothing, costs
|
|
11
|
+
// them context on every turn, and actively harms them: a roster many times past the 30–50 tool accuracy cliff is
|
|
12
|
+
// exactly what makes a model pick the wrong tool. The MCP spec (rev 2026-07-28, Tools ▸ Capabilities) blesses this
|
|
13
|
+
// precise shape and no other: the tool set "MUST NOT vary per-connection or as a side effect of other requests on
|
|
14
|
+
// the connection. The set MAY vary by the authorization presented on the request — for example, returning only the
|
|
15
|
+
// tools the caller's granted scopes permit — since credentials are per-request input, not connection state."
|
|
16
|
+
//
|
|
17
|
+
// FIVE SAFETY PROPERTIES, each mutation-tested, because the failure mode of getting this wrong is INVISIBLE — a
|
|
18
|
+
// silently missing tool reads as the model refusing, not as a bug:
|
|
19
|
+
//
|
|
20
|
+
// 1. **A failed connector read is never a refusal.** `readOk:false` holds back NOTHING. An unreadable store must
|
|
21
|
+
// never manufacture a capability loss ([[failed-read-is-not-empty]]).
|
|
22
|
+
// 2. **An UNMAPPED tool is never held back.** The rules below are an allow-list of things we can justify, not a
|
|
23
|
+
// classifier. Anything this module cannot confidently attribute to a connector stays in the roster — which is
|
|
24
|
+
// what makes adding a tool safe: tool N+1 keeps working, it just does not get the saving.
|
|
25
|
+
// 3. **Every provider named here must exist in the live connector registry.** A typo'd provider id would match
|
|
26
|
+
// nothing in `connected` and silently drop its whole family FOREVER, on every account. The check asserts the
|
|
27
|
+
// rule table against server.js's own CONNECTOR_INFO keys.
|
|
28
|
+
// 4. **Research is never gated** — see NEVER_GATE below, and note the measured result on the MCP roster: of the
|
|
29
|
+
// 18 tools in the `research` group, the `core` group and the `workspace` group, ZERO are connector-mapped.
|
|
30
|
+
// 5. **A tool we did not register is not ours to filter.** Both callers hand these functions OUR tool names only;
|
|
31
|
+
// a user's own MCP server may call something `search_youtube_transcripts` and it must never be touched.
|
|
32
|
+
//
|
|
33
|
+
// Pure by design so the checks RUN these functions rather than reading the source. NO IMPORTS: the cli twin is
|
|
34
|
+
// rsync'd into a published package that has no lib/ and no repo around it.
|
|
35
|
+
// RESEARCH IS NEVER GATED, AND THIS LIST IS THE REASON THE WHOLE CHANGE IS SAFE.
|
|
36
|
+
//
|
|
37
|
+
// The ad libraries and organic social search run on OUR ScrapeCreators key, not on the user's connection — a brand
|
|
38
|
+
// with nothing connected can and must still spy on its competitors' Meta ads. But their NAMES look exactly like
|
|
39
|
+
// connector tools: `search_meta_ads` contains `_meta_`, `search_youtube` contains `youtube`. Six of the nine would
|
|
40
|
+
// have been silently gated by the rules below, which would have broken the product's single most-used feature for
|
|
41
|
+
// every new account — the exact users this change exists to protect.
|
|
42
|
+
//
|
|
43
|
+
// DERIVED, NOT HAND-LISTED. `tools/studio-roster-check.mjs` asserts this set is a SUPERSET of server.js's own
|
|
44
|
+
// `SC_WINDOW_TOOLS` — the set the route already uses to decide which tools take a ScrapeCreators balance window.
|
|
45
|
+
// So a tenth SC-backed tool added there fails the suite rather than quietly losing research for zero-connector
|
|
46
|
+
// accounts. Anything ScrapeCreators pays for, the user reaches without connecting anything.
|
|
47
|
+
//
|
|
48
|
+
// NOTE what is deliberately NOT here: `search_instagram_hashtag`, `instagram_profile`, `search_threads_keyword`,
|
|
49
|
+
// `discover_tiktok_creators`, `tiktok_creator_info`. Those read the PLATFORM's data through the USER'S token
|
|
50
|
+
// (Business Discovery, the Threads API, TikTok's Creator Marketplace) — free to us, but impossible without the
|
|
51
|
+
// connection, so gating them is correct.
|
|
52
|
+
export const NEVER_GATE = new Set([
|
|
53
|
+
'search_meta_ads', 'search_google_ads', 'search_linkedin_ads', 'search_tiktok',
|
|
54
|
+
'search_instagram', 'search_youtube', 'search_reddit', 'search_threads', 'scrapecreators_fetch',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// Tool-name → connector provider. ORDERED: the first match wins, so the more specific pattern must come first.
|
|
58
|
+
//
|
|
59
|
+
// THE ORDER IS LOAD-BEARING AND IS THE EASIEST THING TO GET WRONG. Six platforms hold TWO independent connections —
|
|
60
|
+
// posting and ads are separate consents with separate tokens (CLAUDE.md records this explicitly for TikTok: "IT IS
|
|
61
|
+
// A SEPARATE CONNECTION FROM THE `tiktok` POSTING CONNECTOR", and the same split is real for X, Pinterest, Reddit,
|
|
62
|
+
// Snapchat and Microsoft). So `*_tiktok_ads_*` must be tested BEFORE the bare `tiktok` rule, or every ads tool
|
|
63
|
+
// would be gated on the posting connector and a user with TikTok Ads connected but not TikTok posting would lose
|
|
64
|
+
// the whole ads family they are paying for.
|
|
65
|
+
export const TOOL_PROVIDER_RULES = [
|
|
66
|
+
// ── ads platforms that are their OWN connection (must precede the posting rules below) ──
|
|
67
|
+
[/_tiktok_ads_|^tiktok_ads_/, 'tiktok_ads'],
|
|
68
|
+
[/_x_ads_|^x_ads_/, 'x_ads'],
|
|
69
|
+
[/_pinterest_ads_|^pinterest_ads_/, 'pinterest_ads'],
|
|
70
|
+
[/_reddit_ads_|^reddit_ads_/, 'reddit_ads'],
|
|
71
|
+
[/_snapchat_ads_|^snapchat_ads_/, 'snapchat_ads'],
|
|
72
|
+
[/_microsoft_ads_|^microsoft_ads_|_microsoft_merchant_/, 'microsoft_ads'],
|
|
73
|
+
[/_apple_ads_|^apple_ads_/, 'apple_ads'],
|
|
74
|
+
[/_openai_ads_|^openai_ads_/, 'openai_ads'],
|
|
75
|
+
// Google Ads owns Merchant Center + the Ads↔Analytics link (both are Google Ads API surfaces, not GA4 ones).
|
|
76
|
+
[/_google_ads_|^google_ads_|_merchant_|^merchant_|^list_merchant_|link_google_ads_to_analytics/, 'google_ads'],
|
|
77
|
+
// ── LinkedIn: ads and posting share ONE connection (the Advertising API grant carries w_organization_social —
|
|
78
|
+
// see [[linkedin-connector-live]]), so both families gate on the single `linkedin` provider. ──
|
|
79
|
+
[/linkedin/, 'linkedin'],
|
|
80
|
+
// ── Meta: ads management AND FB/IG posting are one connector ([[meta-integration]]). Threads is separate. ──
|
|
81
|
+
[/^threads_|_thread$|_threads_|^(list|search|reply_to|repost|delete|hide)_thread/, 'threads'],
|
|
82
|
+
[/_meta_|^meta_|_meta$|instagram|whatsapp/, 'meta'],
|
|
83
|
+
// ── analytics / measurement, each its own connection ──
|
|
84
|
+
[/_analytics_|^analytics_(realtime|report)$|analytics_compatibility|analytics_stream/, 'google_analytics'],
|
|
85
|
+
[/mixpanel/, 'mixpanel'],
|
|
86
|
+
[/tag_manager/, 'google_tag_manager'],
|
|
87
|
+
[/search_console/, 'google_search_console'],
|
|
88
|
+
[/bing_webmaster/, 'bing_webmaster'],
|
|
89
|
+
[/posthog/, 'posthog'],
|
|
90
|
+
[/amplitude/, 'amplitude'],
|
|
91
|
+
// ── posting-only channels ──
|
|
92
|
+
[/youtube/, 'youtube'],
|
|
93
|
+
[/google_business|business_location/, 'google_business'],
|
|
94
|
+
[/_drive_|^list_drive|^get_drive|^create_drive|^save_to_drive$|_doc$|^read_doc|^create_doc|^update_doc|^append_to_doc|sheet/, 'google_drive'],
|
|
95
|
+
[/onedrive/, 'microsoft_onedrive'],
|
|
96
|
+
[/bluesky/, 'bluesky'],
|
|
97
|
+
[/telegram/, 'telegram'],
|
|
98
|
+
[/^post_to_tiktok$|^tiktok_|_tiktok_/, 'tiktok'],
|
|
99
|
+
[/^post_to_x$|^delete_x_post$|^send_x_dm$|^list_x_dms$|^x_(mentions|post)/, 'x'],
|
|
100
|
+
[/pinterest/, 'pinterest'],
|
|
101
|
+
[/reddit/, 'reddit'],
|
|
102
|
+
[/snapchat/, 'snapchat'],
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
// The provider a tool needs, or null when this module cannot attribute it. null ⇒ NEVER dropped (property 2).
|
|
106
|
+
export function toolProvider(name) {
|
|
107
|
+
const n = String(name || '');
|
|
108
|
+
if (!n) return null;
|
|
109
|
+
if (NEVER_GATE.has(n)) return null; // research runs on our key — checked FIRST, before any pattern can claim it
|
|
110
|
+
for (const [re, provider] of TOOL_PROVIDER_RULES) if (re.test(n)) return provider;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// THE ONE DECISION, and the only one either surface is allowed to make. `conn` is `{connected, readOk}` — the
|
|
114
|
+
// shape both the Studio route's connector read and the MCP transports' `/api/connectors/providers` read produce.
|
|
115
|
+
//
|
|
116
|
+
// Returns TRUE only when we KNOW the read succeeded AND we can attribute the tool to a provider AND that provider
|
|
117
|
+
// is not connected. Every other answer is FALSE, i.e. keep it — which is properties 1 and 2 expressed as the
|
|
118
|
+
// default rather than as two branches somebody could forget to write.
|
|
119
|
+
export function toolHeldBackByConnectors(name, conn) {
|
|
120
|
+
if (!conn || !conn.readOk) return false; // property 1 — fail OPEN on an unreadable store
|
|
121
|
+
const p = toolProvider(name);
|
|
122
|
+
if (p === null) return false; // property 2 — unmapped is never held back
|
|
123
|
+
const on = conn.connected instanceof Set ? conn.connected : new Set(conn.connected || []);
|
|
124
|
+
return !on.has(p);
|
|
125
|
+
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -11,6 +11,10 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
11
11
|
// The UTF-16 well-formedness law — see wellFormedServer() below for why it is applied here, and
|
|
12
12
|
// ./well-formed.mjs for why that specifier is the only one that can work in a byte-identical twin.
|
|
13
13
|
import { wellFormedValue, wellFormedString } from './well-formed.mjs';
|
|
14
|
+
// WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
|
|
15
|
+
// re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
|
|
16
|
+
// twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
|
|
17
|
+
import { toolHeldBackByConnectors } from './roster-scope.mjs';
|
|
14
18
|
|
|
15
19
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
16
20
|
const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
|
|
@@ -151,7 +155,7 @@ export const CAPABILITY_MAP = [
|
|
|
151
155
|
'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here; each has its own brand, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
152
156
|
'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
|
|
153
157
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this brand).',
|
|
154
|
-
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this brand may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · disconnect_connector (revoke and drop a connection; confirm-gated because RECONNECTING NEEDS A BROWSER and no agent can do it) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING a new account is the one thing that is not headless — it is an OAuth consent screen, so send the user to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC — the step that finishes the default publish flow; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this brand is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this brand can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad, and until now the one platform Hermoso could not moderate. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this brand can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT (post, then actually live with it — the thread is where the value is): post_to_reddit (submit a text, link or native image post to ONE subreddit — Reddit bans near-identical posts across communities, so write for one subreddit and never fan out) · list_reddit_posts (the account’s OWN submissions with their ids — THIS is where the postId every other Reddit tool needs comes from) · reddit_post_stats (score, comments, upvote ratio on a post you made) · list_reddit_comments + reply_to_reddit_comment (read the questions and objections in the community’s own words and answer them as the brand — Reddit judges a brand on how it behaves in comments far more than on what it posts) · edit_reddit_post (rewrite a TEXT post’s body; a link post cannot be edited at all and a TITLE can never be changed by any API, so say that rather than implying otherwise) · delete_reddit_post (take one down — confirm-gated, and note deleting the post does NOT delete the comments under it). REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 4 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each brand has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this brand can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS BRAND, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this brand and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
158
|
+
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this brand may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · disconnect_connector (revoke and drop a connection; confirm-gated because RECONNECTING NEEDS A BROWSER and no agent can do it) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING a new account is the one thing that is not headless — it is an OAuth consent screen, so send the user to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this brand may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC — the step that finishes the default publish flow; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this brand is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this brand can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad, and until now the one platform Hermoso could not moderate. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this brand can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT (post, then actually live with it — the thread is where the value is): post_to_reddit (submit a text, link or native image post to ONE subreddit — Reddit bans near-identical posts across communities, so write for one subreddit and never fan out) · list_reddit_posts (the account’s OWN submissions with their ids — THIS is where the postId every other Reddit tool needs comes from) · reddit_post_stats (score, comments, upvote ratio on a post you made) · list_reddit_comments + reply_to_reddit_comment (read the questions and objections in the community’s own words and answer them as the brand — Reddit judges a brand on how it behaves in comments far more than on what it posts) · edit_reddit_post (rewrite a TEXT post’s body; a link post cannot be edited at all and a TITLE can never be changed by any API, so say that rather than implying otherwise) · delete_reddit_post (take one down — confirm-gated, and note deleting the post does NOT delete the comments under it). REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 4 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each brand has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this brand can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS BRAND, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this brand and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
155
159
|
'F) YOUR ROSTER STARTS SLIM, AND YOU CAN WIDEN IT YOURSELF — paid-campaign management (`ads`) is NOT loaded by default. It is by far the largest group — roughly two thirds of the schema weight, and most sessions never touch it. THE MOMENT the user asks to build, budget, target, report on or change a campaign on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads or Apple Search Ads, call enable_tools({groups:[\'ads\']}) — it is free and instant, the tools appear immediately, and you then proceed normally. Do NOT tell the user a campaign cannot be built here; turn the group on. Other groups: research, create, channels, files, workspace, or \'all\'.',
|
|
156
160
|
].join('\n');
|
|
157
161
|
|
|
@@ -187,8 +191,21 @@ export const MCP_INSTRUCTIONS = [
|
|
|
187
191
|
'• RESEARCH the ads already winning: find_competitors, competitor_teardown, pull_competitor_ads, research_ads, search_meta_ads, search_google_ads, search_linkedin_ads, search_tiktok, search_instagram, search_youtube, search_reddit, search_threads, mine_angles, analyze_video, check_ad_policy.',
|
|
188
192
|
'• CREATE finished on-brand ads: render_ad, generate_image, generate_video, generate_avatar, make_template_ad, make_thumbnail, make_explainer, plan_ad, plan_variations; get_brand / draft_brand / update_brand; list_creators / save_creator; edit_video, dub_video, clip_video, reframe_video, upscale_video, stitch_video.',
|
|
189
193
|
'• RAW MODELS, prompt only: generate_image / generate_video with useBrand:false, generate_voice, generate_text, upload_file (any local or external file becomes a URL every publish, schedule and ad tool accepts).',
|
|
190
|
-
'• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta, post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_reddit,
|
|
194
|
+
'• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_reddit, post_to_bluesky, post_to_telegram, post_to_google_business; schedule_post, list_scheduled, reschedule_post, cancel_scheduled; list_connectors, list_connector_accounts, set_connector_accounts.',
|
|
191
195
|
'• ADS on eleven platforms, their accounts and their money: create_meta_campaign / _adset / _ad, create_google_ads_campaign / _ad_group / _ad and the TikTok, LinkedIn, Pinterest, Reddit, Microsoft and OpenAI equivalents; meta_insights, google_ads_report and the per-platform reports. Everything is created PAUSED and read back before it is described.',
|
|
196
|
+
// ── YOUR ROSTER IS NOT THE PRODUCT (2026-08-26) ──────────────────────────────────────────────────────────────
|
|
197
|
+
// The roster is scoped to the accounts this workspace has connected, because a tool for an unconnected provider
|
|
198
|
+
// can only answer 401. That is a saving, and it has ONE failure mode, which this line exists to prevent: an
|
|
199
|
+
// agent reads its roster as the boundary of what exists and tells the user we do not support their platform
|
|
200
|
+
// ([[prompt-rosters-go-stale]] — our own prose has already refused shipped capability on five surfaces).
|
|
201
|
+
//
|
|
202
|
+
// TWO CONSTRAINTS DECIDE ITS POSITION AND ITS LENGTH, and both are real budgets someone else is paying.
|
|
203
|
+
// POSITION: immediately AFTER the ADS bullet, because some hosts truncate this string to ~2KB and all five areas
|
|
204
|
+
// plus "ADS on eleven platforms" must stay inside that window — put it above and it evicts the ads bullet, which
|
|
205
|
+
// causes the exact failure it exists to stop. LENGTH: §2d of agent-surface-guidance-check caps the whole string,
|
|
206
|
+
// so it says only what is NEW and points at nothing already named below — `list_connectors`, `Workspace ▸
|
|
207
|
+
// Connectors`, `?connect=<provider>` and the CLI escape hatch are all already in here, further down.
|
|
208
|
+
'A tool NOT in your roster is filtered out — that account is not connected here. Never say Hermoso lacks a platform.',
|
|
192
209
|
// SECOND LINE, before the capability map: those four jobs read as four STAGES, and by the time an agent has
|
|
193
210
|
// scrolled the map it has already decided Hermoso is a funnel it must enter at the top. See INDEPENDENCE above.
|
|
194
211
|
// NOT REPEATED HERE ANY MORE: the head above states independence in its own first sentence, and every byte
|
|
@@ -1894,13 +1911,21 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
1894
1911
|
if (unknown.length) return { content: [{ type: 'text', text: `Unknown tool group${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Valid: ${TOOL_GROUP_NAMES.join(', ')} — or 'all'.` }], isError: true };
|
|
1895
1912
|
const active = ctx.enabledGroups, groupOf = ctx.groupOf, handles = ctx.handleOf;
|
|
1896
1913
|
const added = expand.filter((g) => !active.has(g));
|
|
1897
|
-
let n = 0;
|
|
1914
|
+
let n = 0, heldBack = 0;
|
|
1898
1915
|
for (const g of added) {
|
|
1899
1916
|
active.add(g);
|
|
1900
1917
|
for (const [name, grp] of Object.entries(groupOf)) {
|
|
1901
1918
|
if (grp !== g) continue;
|
|
1902
1919
|
const h = handles[name];
|
|
1903
|
-
if (h)
|
|
1920
|
+
if (!h) continue;
|
|
1921
|
+
// THE GROUP FLIP MUST NOT UNDO THE CONNECTOR GATE (2026-08-26). Enabling a group is a statement about SIZE —
|
|
1922
|
+
// "I am willing to carry these schemas" — not a claim that the workspace has connected eleven ad platforms.
|
|
1923
|
+
// A blind `h.enable()` here would have re-listed every tool applyToolGates had just held back, so the saving
|
|
1924
|
+
// would survive exactly until the first `enable_tools(['ads'])`. Counted, not silently skipped: the reply
|
|
1925
|
+
// says how many and why, because a group that turns on "8 tools" when the agent expected 240 with no
|
|
1926
|
+
// explanation is the [[prompt-rosters-go-stale]] failure — the agent concludes the capability is missing.
|
|
1927
|
+
if (toolHeldBackByConnectors(name, ctx.conn)) { heldBack++; continue; }
|
|
1928
|
+
try { h.enable(); n++; } catch {}
|
|
1904
1929
|
}
|
|
1905
1930
|
}
|
|
1906
1931
|
const enabled = TOOL_GROUP_NAMES.filter((g) => active.has(g));
|
|
@@ -1922,14 +1947,28 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
1922
1947
|
// NOT DELETED, deliberately. It still works on stdio and in the CLI, where the client re-lists, and deleting it
|
|
1923
1948
|
// there would remove the only in-session route to the two opt-in groups. Made honest, not removed.
|
|
1924
1949
|
const fixedRoster = hostRendersWidgets();
|
|
1950
|
+
// NEVER "that platform is not supported". The tools exist, are built and are live; they are simply not listed for
|
|
1951
|
+
// an account that has not connected the platform yet, because they could only answer 401. Say that, and say where
|
|
1952
|
+
// the one-click fix is — the failure this sentence prevents is an agent telling a user we cannot run their ads.
|
|
1953
|
+
const held = heldBack
|
|
1954
|
+
? ` ${heldBack} more tool${heldBack === 1 ? ' is' : 's are'} built and ready but not listed because their account is not connected in this workspace yet — Hermoso supports them all; connect the account under Workspace ▸ Connectors (https://app.hermoso.ai/?connect=<provider>, or list_connectors to see what is linked) and they appear.`
|
|
1955
|
+
: '';
|
|
1925
1956
|
const route = 'reconnect with `?tools=all` on the server URL, or run the `hermoso` CLI, which reaches every'
|
|
1926
1957
|
+ ' tool with no roster at all.';
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1958
|
+
// A CACHED CLIENT AND AN UNCONNECTED ACCOUNT ARE DIFFERENT DIAGNOSES, AND ONLY ONE OF THEM IS EVER TRUE HERE
|
|
1959
|
+
// (2026-08-26). The two branches below tell an agent that if the tools do not appear, its client cached the
|
|
1960
|
+
// roster — correct when we really did enable something. When nothing was enabled BECAUSE nothing is connected,
|
|
1961
|
+
// that sentence sends the agent to reconnect its client, which provably cannot help, and the reconnect it is
|
|
1962
|
+
// told to try will produce the identical roster. So the zero-added case answers with the real cause and the
|
|
1963
|
+
// real fix instead, and says the platforms exist. Same law as the read-back rule: report the measurement.
|
|
1964
|
+
const note = !added.length
|
|
1965
|
+
? `Already on: ${expand.join(', ')}. Nothing changed. Active groups: ${enabled.join(', ')}.`
|
|
1966
|
+
: (n === 0 && heldBack
|
|
1967
|
+
? `Switched on ${added.join(', ')} server-side, but nothing new is listed:${held} Active groups: ${enabled.join(', ')}.`
|
|
1968
|
+
: fixedRoster
|
|
1969
|
+
? `Switched on ${added.join(', ')} server-side — but THIS host fixed its tool list when the connection was made and will not pick up the ${n} new tool${n === 1 ? '' : 's'} until it reconnects, so do not expect to see them in this conversation. To use them, ${route}${held} Active groups: ${enabled.join(', ')}.`
|
|
1970
|
+
: `Switched on ${added.join(', ')} — ${n} more tool${n === 1 ? '' : 's'} are callable now. If they do not appear your client has cached its tool list, in which case ${route}${held} Active groups: ${enabled.join(', ')}.`);
|
|
1971
|
+
return ok(note, { enabled, added, toolsAdded: n, toolsAwaitingConnection: heldBack, note, rosterFixedForThisConnection: fixedRoster });
|
|
1933
1972
|
};
|
|
1934
1973
|
|
|
1935
1974
|
// The per-session scope every roster needs, whether it was built or replayed. ONE builder so the two paths cannot
|
|
@@ -1937,13 +1976,33 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
1937
1976
|
function newToolScope(opts) {
|
|
1938
1977
|
const asked = opts.only ? new Set(opts.only) : new Set(DEFAULT_TOOL_GROUPS);
|
|
1939
1978
|
asked.add('core'); // discovery/credits/billing/jobs must exist in EVERY roster or the connection is unusable
|
|
1940
|
-
|
|
1979
|
+
// `connectors` is `{connected:Set<provider>, readOk:boolean}` from the transport's own free read, or absent.
|
|
1980
|
+
// ABSENT AND `readOk:false` BEHAVE IDENTICALLY, and that is the fail-open law rather than a convenience:
|
|
1981
|
+
// toolHeldBackByConnectors answers false for both, so an unreadable store, an anonymous discovery handshake and
|
|
1982
|
+
// a transport that has not been taught to read yet all advertise the FULL roster ([[failed-read-is-not-empty]]).
|
|
1983
|
+
return {
|
|
1984
|
+
enabledGroups: asked, groupOf: Object.create(null), handleOf: Object.create(null),
|
|
1985
|
+
conn: opts.connectors || null,
|
|
1986
|
+
widgetHost: !!opts.widgetHost,
|
|
1987
|
+
};
|
|
1941
1988
|
}
|
|
1942
|
-
//
|
|
1989
|
+
// THE THREE REASONS A REGISTERED TOOL IS HELD BACK. Applied identically on the build path, the replay path and
|
|
1990
|
+
// `enable_tools`, from ONE function, because a gate applied at two of the three is a gate a group flip undoes.
|
|
1991
|
+
//
|
|
1992
|
+
// (3) IS THE CONNECTOR GATE (2026-08-26). A connector-bound tool for a provider this workspace has not connected
|
|
1993
|
+
// can only ever answer `401 {connector:'<p>'}`, so carrying it buys the caller nothing and costs them context on
|
|
1994
|
+
// every turn — and a roster many times past the 30–50 tool accuracy cliff is what makes a model pick the wrong
|
|
1995
|
+
// tool. The MCP spec (rev 2026-07-28, Tools ▸ Capabilities) permits exactly this and nothing looser: the set
|
|
1996
|
+
// "MUST NOT vary per-connection or as a side effect of other requests on the connection. The set MAY vary by the
|
|
1997
|
+
// authorization presented on the request … since credentials are per-request input, not connection state."
|
|
1998
|
+
// DISABLED, NEVER SKIPPED, like the other two — the handler still exists, so connecting the account and
|
|
1999
|
+
// reconnecting reveals it with no code path of its own, and `tools/call` on it still answers its real 401
|
|
2000
|
+
// rather than the SDK's "unknown tool".
|
|
1943
2001
|
function applyToolGates(h, name, group, ctx, opts) {
|
|
1944
2002
|
if (!h) return;
|
|
1945
2003
|
if (!ctx.enabledGroups.has(group)) { try { h.disable(); } catch {} }
|
|
1946
2004
|
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && opts.widgetHost) { try { h.disable(); } catch {} }
|
|
2005
|
+
if (toolHeldBackByConnectors(name, ctx.conn)) { try { h.disable(); } catch {} }
|
|
1947
2006
|
}
|
|
1948
2007
|
|
|
1949
2008
|
// Replay the cached canon onto a fresh server. This is the whole hot path for every session after the first.
|
|
@@ -2014,6 +2073,22 @@ export function wellFormedServer(rawServer) {
|
|
|
2014
2073
|
throw e;
|
|
2015
2074
|
}
|
|
2016
2075
|
};
|
|
2076
|
+
// FORWARD THE TOOL-NAME STAMP DOWN TO THE HANDLER THAT READS IT (2026-08-26), exactly as publishWrap
|
|
2077
|
+
// already does one layer in and for the same reason. buildTools' registry proxy stamps `_hermosoTool`
|
|
2078
|
+
// onto the function it is HANDED, but the function that gets REGISTERED from here is `safe` — so the
|
|
2079
|
+
// stamp landed on an object the SDK never sees. It happened to keep working, because wrap() reads the
|
|
2080
|
+
// name off its OWN closure and that closure is the handler `safe` calls; that is an accident of
|
|
2081
|
+
// ordering, not a guarantee, and anything reading the name off the registered handler (as the error
|
|
2082
|
+
// ledger's own coverage sweep does) sees `undefined` for all 714 tools. Forwarding makes the property
|
|
2083
|
+
// true on both objects, so the ledger cannot start filing errors under an empty op the next time a
|
|
2084
|
+
// layer is added on top.
|
|
2085
|
+
try {
|
|
2086
|
+
Object.defineProperty(safe, '_hermosoTool', {
|
|
2087
|
+
set(v) { try { handler._hermosoTool = v; } catch {} },
|
|
2088
|
+
get() { return handler._hermosoTool; },
|
|
2089
|
+
configurable: true,
|
|
2090
|
+
});
|
|
2091
|
+
} catch {}
|
|
2017
2092
|
try { _wfWrapped.set(handler, safe); } catch {}
|
|
2018
2093
|
}
|
|
2019
2094
|
return t.registerTool(name, def, safe);
|
|
@@ -2106,7 +2181,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2106
2181
|
const h = t.registerTool(name, defForHost(name, finalDef, opts.widgetHost), handler);
|
|
2107
2182
|
handleOf[name] = h;
|
|
2108
2183
|
// DISABLED, NOT SKIPPED — see (1) above. `disable()` is the SDK's own call and removes it from tools/list.
|
|
2109
|
-
|
|
2184
|
+
//
|
|
2185
|
+
// ONE GATE FUNCTION, NOT A SECOND COPY OF THE RULES (2026-08-26). This branch and the widget branch below
|
|
2186
|
+
// used to be spelled out here AND inside applyToolGates, which replayTools calls — two implementations of
|
|
2187
|
+
// the same law, on the two paths a session can take. That is how the build path and the replay path come to
|
|
2188
|
+
// disagree, and it is invisible when they do: the first session in a process takes one, every session after
|
|
2189
|
+
// it takes the other. The third (connector) gate would have had to be written twice for the same reason.
|
|
2190
|
+
applyToolGates(h, name, group, ctx, opts);
|
|
2110
2191
|
// WITHHELD FROM ONE HOST, for that host's rules rather than ours (2026-08-23).
|
|
2111
2192
|
// OpenAI's plugin policy permits commerce only in PHYSICAL goods: "selling digital products or services,
|
|
2112
2193
|
// including subscriptions, digital content, tokens, or credits, is not allowed." buy_credits hands back a
|
|
@@ -2125,7 +2206,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2125
2206
|
// rather than only its `enabled:true` branch, because a tool whose declared schema can arm a charge is what a
|
|
2126
2207
|
// commerce reviewer reads, not the branch it happens to take; turning auto-reload OFF stays available in the
|
|
2127
2208
|
// app and on every other surface.
|
|
2128
|
-
|
|
2209
|
+
// (The disable itself now lives in applyToolGates above, with the other two gates. This comment stays HERE
|
|
2210
|
+
// because it explains WHICH tools are in WITHHELD_FROM_WIDGET_HOSTS and why, which is the part that has to
|
|
2211
|
+
// be read next to the roster rather than next to the mechanism.)
|
|
2129
2212
|
return h;
|
|
2130
2213
|
};
|
|
2131
2214
|
const v = Reflect.get(t, p);
|
|
@@ -3577,10 +3660,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3577
3660
|
actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE.'),
|
|
3578
3661
|
event: z.object({ title: z.string().optional(), startDate: z.string().optional(), startTime: z.string().optional(), endDate: z.string().optional(), endTime: z.string().optional() }).optional().describe('GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:"YYYY-MM-DD", endDate, startTime:"HH:MM", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them.'),
|
|
3579
3662
|
offer: z.object({ couponCode: z.string().optional(), redeemOnlineUrl: z.string().optional(), termsConditions: z.string().optional() }).optional().describe('GOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer.'),
|
|
3580
|
-
thread: z.array(z.string()).optional().describe('X — publish a THREAD, one entry per post, each replying to the one before (at most 25,
|
|
3663
|
+
thread: z.array(z.string()).optional().describe('X — publish a THREAD, one entry per post, each replying to the one before (at most 25). 280 characters per part without X Premium, up to 25,000 with it — nothing is truncated, and on a Premium account one long post is usually better AND cheaper than a thread. It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll.'),
|
|
3581
3664
|
poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — attach a poll: {options:["…","…"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post.'),
|
|
3582
3665
|
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply. Omit for everyone, which is the right default for a brand post.'),
|
|
3583
3666
|
madeWithAi: z.boolean().optional().describe('X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf.'),
|
|
3667
|
+
// THE THREE X FIELDS `xPost` HAS ACCEPTED SINCE THE DAY THEY LANDED AND NOTHING COULD SCHEDULE (2026-08-26).
|
|
3668
|
+
// Same publisher-can/scheduler-cannot shape as SCHED_ID_FIELDS and the five Threads options one channel over:
|
|
3669
|
+
// reachable when you publish NOW, unreachable when you schedule, and invisible until the parity sweep named it.
|
|
3670
|
+
xQuotePostId: z.string().optional().describe('X — the numeric id of an X post this one QUOTES: the last part of its URL. X renders that post inside yours and it stands alone on your own timeline, which is what makes a quote different from a reply. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS — a schedule going to both channels would otherwise be silently ambiguous. Billed at X’s higher LINK rate, because X appends the quoted post’s t.co URL to yours.'),
|
|
3671
|
+
communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
|
|
3672
|
+
paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP, the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.'),
|
|
3584
3673
|
collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
|
|
3585
3674
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first, so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
|
|
3586
3675
|
// ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
|
|
@@ -3671,6 +3760,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3671
3760
|
poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — replaces the poll; an empty options list removes it.'),
|
|
3672
3761
|
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply; "" goes back to everyone.'),
|
|
3673
3762
|
madeWithAi: z.boolean().optional().describe('X — the AI-media label; false turns it off.'),
|
|
3763
|
+
xQuotePostId: z.string().optional().describe('X — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule.'),
|
|
3764
|
+
communityId: z.string().optional().describe('X — the community to publish into; an empty string goes back to the main timeline.'),
|
|
3765
|
+
paidPartnership: z.boolean().optional().describe('X — the paid-partnership label; false turns it off.'),
|
|
3674
3766
|
collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet \u2014 an invite already sent cannot be withdrawn from here.'),
|
|
3675
3767
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE', '']).optional().describe('INSTAGRAM \u2014 replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued \u2014 a Reel already published cannot be converted into a trial.'),
|
|
3676
3768
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
@@ -3848,11 +3940,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3848
3940
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
3849
3941
|
server.registerTool('post_to_x', {
|
|
3850
3942
|
title: 'Publish a post to X (Twitter)',
|
|
3851
|
-
description: 'Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply.
|
|
3943
|
+
description: 'Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Only the first ~280 characters show in the timeline; the rest sits behind \u201cShow more\u201d. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. Each brand also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings ▸ Connectors ▸ X).',
|
|
3852
3944
|
inputSchema: {
|
|
3853
3945
|
...HOOK_ATTR,
|
|
3854
|
-
text: z.string().optional().describe('the post text
|
|
3855
|
-
thread: z.array(z.string()).optional().describe('a thread: each string is one post
|
|
3946
|
+
text: z.string().optional().describe('the post text. 280 characters without X Premium, up to 25,000 with it \u2014 write the full thing, it is never truncated. Use this OR thread, not both.'),
|
|
3947
|
+
thread: z.array(z.string()).optional().describe('a thread: each string is one post, published in order, each replying to the previous. Max 25. Each part follows the same length rule as `text`, and on an X Premium account ONE long post is usually both better reading and cheaper than a thread.'),
|
|
3856
3948
|
mediaUrl: z.string().optional().describe('a Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media'),
|
|
3857
3949
|
mediaUrls: z.array(z.string()).optional().describe('UP TO FOUR Hermoso-hosted media attached to ONE post — X\u2019s own schema caps media_ids at 4. X renders them as a GRID: every image visible at once, nothing to swipe to. That is NOT a carousel, and a numbered "1/6 \u00b7 SWIPE" slide deck must still not be sent here — it would publish as a grid and the "swipe" instruction would make no sense. Order decides the layout. On a thread the media rides the FIRST post; give each later part its own post to attach more. Mutually exclusive with mediaUrl, and more than 4 is refused before anything is uploaded.'),
|
|
3858
3950
|
altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility description of the attached media, max 1000 characters \u2014 write one whenever you attach a render. Costs a small extra amount: X bills one metadata write PER media. With SEVERAL media, pass an ARRAY aligned to their order \u2014 X attaches alt text per media id, and a single string describes only the FIRST one (X renders up to four media as a GRID, not a carousel, so one sentence would be wrong for the other three).'),
|
|
@@ -3862,14 +3954,56 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3862
3954
|
}).optional().describe('run a poll on the post. X does not allow a poll and media on the same post, and a poll cannot ride on a thread.'),
|
|
3863
3955
|
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('restrict who can reply — omit for everyone, which is the right default for a brand post'),
|
|
3864
3956
|
replyToId: z.string().optional().describe('numeric id of an existing X post to reply to'),
|
|
3957
|
+
quotePostId: z.string().optional().describe('numeric id of a post to QUOTE \u2014 X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post\u2019s t.co URL whatever your text says.'),
|
|
3958
|
+
communityId: z.string().optional().describe('publish into an X COMMUNITY instead of the main timeline \u2014 the number in the community\u2019s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
|
|
3959
|
+
paidPartnership: z.boolean().optional().describe('label the post a PAID PARTNERSHIP on X \u2014 the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user\u2019s behalf.'),
|
|
3865
3960
|
},
|
|
3866
|
-
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), thread: z.boolean().optional(), media: z.boolean().optional(), altText: z.boolean().optional(), poll: z.boolean().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), text: z.string().optional(), url: z.string().optional() })).optional() },
|
|
3961
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), thread: z.boolean().optional(), media: z.boolean().optional(), altText: z.boolean().optional(), poll: z.boolean().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), text: z.string().optional(), url: z.string().optional() })).optional(), quotedPostId: z.string().optional(), communityId: z.string().optional(), paidPartnership: z.boolean().optional() },
|
|
3867
3962
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
3868
3963
|
}, wrap(async (a) => {
|
|
3869
3964
|
const d = await apiPost('/api/x/post', a);
|
|
3870
3965
|
const extra = d.media ? (d.altText ? ' with the render + alt text' : ' with the render attached (no alt text was written)') : d.poll ? ' with a poll' : '';
|
|
3871
3966
|
return ok(`Published to X${d.thread ? ` — a ${(d.posts || []).length}-post thread` : ''}${extra}: ${d.url}. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
3872
3967
|
}));
|
|
3968
|
+
server.registerTool('post_x_article', {
|
|
3969
|
+
title: 'Publish a long-form Article to X',
|
|
3970
|
+
description: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. Give it a `title` and a `body` written in MARKDOWN (or plain prose) and Hermoso converts it into the DraftJS `content_state` structure X requires: headings, paragraphs, bulleted and numbered lists, blockquotes, bold / italic / strikethrough, links, horizontal rules, fenced code blocks and pipe tables all carry across. FORMATTING IS NEVER SILENTLY DROPPED — anything X Articles cannot represent (inline `code`, an inline image) REFUSES the article for free and names exactly what and why, and `allowLossy: true` is the explicit way to publish it as plain text anyway. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. An Article appears in the timeline as a title card, not as body text; readers open it. Costs credits (X bills per API request, and this is three of them). Needs X connected (Settings ▸ Connectors ▸ X).',
|
|
3971
|
+
inputSchema: {
|
|
3972
|
+
...HOOK_ATTR,
|
|
3973
|
+
title: z.string().describe('the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card.'),
|
|
3974
|
+
body: z.string().describe('the article body, as markdown or plain prose. Markdown headings, lists, quotes, links, emphasis, ``` code fences and | pipe | tables | are all converted to X’s own Article structure.'),
|
|
3975
|
+
coverImageUrl: z.string().optional().describe('optional cover picture for the Article — a Hermoso render URL or an upload_file url. Must be a STILL image; X Article covers are not videos.'),
|
|
3976
|
+
headings: z.enum(['blocks', 'text']).optional().describe('how headings are rendered. “blocks” (default) uses X’s own heading block types, which is the faithful conversion. “text” renders each heading as a BOLD standalone paragraph instead: the words survive and the heading structure does not, the reply says so, and it is the documented fallback if X’s Articles service rejects heading blocks.'),
|
|
3977
|
+
allowLossy: z.boolean().optional().describe('publish even though part of the source cannot be represented on X, rendering those parts as plain text. OFF by default and it should usually stay off — silently publishing a user’s copy with formatting missing is worse than refusing and telling them.'),
|
|
3978
|
+
publish: z.boolean().optional().describe('default true. Pass false to save it as a DRAFT in the account’s X Articles composer instead — nothing becomes public, the user can review and publish it from X, and it does not spend one of the five daily publishes.'),
|
|
3979
|
+
},
|
|
3980
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), draftId: z.string().optional(), draft: z.boolean().optional(), url: z.string().optional(), title: z.string().optional(), note: z.string().optional(), costCredits: z.number().optional(), stats: z.record(z.number()).optional(), budget: z.object({ limit: z.number().nullable().optional(), remaining: z.number().nullable().optional(), resetsAt: z.string().nullable().optional() }).optional(), article: z.object({ found: z.boolean().optional(), title: z.string().nullable().optional(), plainText: z.string().nullable().optional(), chars: z.number().nullable().optional(), links: z.array(z.string()).optional(), editable: z.boolean().optional(), note: z.string().optional() }).optional() },
|
|
3981
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
3982
|
+
}, wrap(async (a) => {
|
|
3983
|
+
const d = await apiPost('/api/x/article', a);
|
|
3984
|
+
if (d.draft) return ok(`Saved an X Article DRAFT — “${d.title}”. Nothing is public yet. ${d.note || ''}`.trim(), d);
|
|
3985
|
+
// THE READ-BACK IS THE ANSWER, NEVER THE 201: X’s publish response carries a bare post id, so what is reported
|
|
3986
|
+
// here is what X STORED when the post was read again. `found: false` means the read did not answer — the Article
|
|
3987
|
+
// is published either way, and saying otherwise would report a successful publish as a failure.
|
|
3988
|
+
const art = d.article || {};
|
|
3989
|
+
const stored = art.found
|
|
3990
|
+
? `X stored it as “${art.title}”, ${art.chars ?? '?'} characters${(art.links || []).length ? `, keeping ${(art.links || []).length} link(s)` : ''}`
|
|
3991
|
+
: 'X did not answer the read-back, so the stored body is unconfirmed — the Article IS published';
|
|
3992
|
+
return ok(`Published an X Article: ${d.url}. ${stored}. ${d.note || ''} Cost ${d.costCredits ?? '?'} credits.`.trim(), d);
|
|
3993
|
+
}));
|
|
3994
|
+
server.registerTool('edit_x_post', {
|
|
3995
|
+
title: 'Edit a post on X',
|
|
3996
|
+
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.',
|
|
3997
|
+
inputSchema: {
|
|
3998
|
+
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.'),
|
|
3999
|
+
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.'),
|
|
4000
|
+
},
|
|
4001
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), previousId: z.string().optional(), editedFromId: z.string().optional(), url: z.string().nullable().optional(), text: z.string().nullable().optional(), editHistory: z.array(z.string()).optional(), editsRemaining: z.number().nullable().optional(), editableUntil: z.string().nullable().optional(), note: z.string().optional(), costCredits: z.number().optional() },
|
|
4002
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
4003
|
+
}, wrap(async (a) => {
|
|
4004
|
+
const d = await apiPost('/api/x/edit', a);
|
|
4005
|
+
return ok(`Edited the post on X. ${d.note || ''} Cost ${d.costCredits ?? '?'} credits.`.trim(), d);
|
|
4006
|
+
}));
|
|
3873
4007
|
server.registerTool('delete_x_post', {
|
|
3874
4008
|
title: 'Delete a post on X',
|
|
3875
4009
|
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.',
|
|
@@ -3984,14 +4118,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3984
4118
|
paginationToken: z.string().optional().describe('nextToken from a previous call, to page further back. There is no since/until filter at X — this is the only way to walk history.'),
|
|
3985
4119
|
eventTypes: z.array(z.string()).optional().describe('MessageCreate (default), ParticipantsJoin, ParticipantsLeave. The join/leave events carry no message and are billed like any other, so the default is messages only.'),
|
|
3986
4120
|
},
|
|
3987
|
-
outputSchema: { account: z.string().optional(), count: z.number().optional(), nextToken: z.string().nullable().optional(), costCredits: z.number().optional(), historyNote: z.string().optional(), events: z.array(z.any()).optional(), conversations: z.array(z.any()).optional() },
|
|
4121
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), nextToken: z.string().nullable().optional(), costCredits: z.number().optional(), historyNote: z.string().optional(), events: z.array(z.any()).optional(), conversations: z.array(z.any()).optional(), emptyReason: z.string().optional(), emptyNote: z.string().optional(), xLooked: z.boolean().nullable().optional(), xErrors: z.array(z.any()).optional() },
|
|
3988
4122
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
3989
4123
|
}, wrap(async (a) => {
|
|
3990
4124
|
const d = await apiGet('/api/x/dms', { maxResults: a.maxResults, conversationId: a.conversationId, participantId: a.participantId, paginationToken: a.paginationToken, eventTypes: (a.eventTypes || []).join(',') });
|
|
3991
4125
|
const cost = `Cost ${d.costCredits ?? '?'} credits.`;
|
|
3992
4126
|
// AN EMPTY READ IS REPORTED WITH THE WINDOW, NOT AS SILENCE. "No DMs" and "no DMs in the 30 days X will serve"
|
|
3993
4127
|
// are different facts, and only one of them is something we actually know.
|
|
3994
|
-
|
|
4128
|
+
// AN EMPTY READ NOW SAYS WHICH KIND OF EMPTY IT WAS. X can answer 200 with a top-level errors[] and no data
|
|
4129
|
+
// \u2014 a resource this token may not see, an app whose X permission level excludes Direct Messages \u2014 and for a
|
|
4130
|
+
// day that was reported as an empty inbox on an account with a DM in it. `emptyNote` names the three cases
|
|
4131
|
+
// apart; it is absent when events came back, so a normal answer is unchanged.
|
|
4132
|
+
if (!d.count) return ok(`No direct messages came back for ${d.account || 'that account'}. ${d.emptyNote || ''} ${d.historyNote || ''} ${cost}`.replace(/\s+/g, ' ').trim(), d);
|
|
3995
4133
|
const rows = (d.conversations || []).map((c) => `${c.needsReply ? '•' : '✓'} ${c.with || 'someone'} (${c.conversationId}) — ${String(c.lastMessage || '').replace(/\s+/g, ' ').slice(0, 160)}${c.needsReply ? ' ← waiting on a reply' : ' (you replied last)'}`);
|
|
3996
4134
|
return ok(`${d.count} message${d.count === 1 ? '' : 's'} across ${rows.length} conversation${rows.length === 1 ? '' : 's'} for ${d.account}. ${cost}\n${rows.join('\n')}\n\n${d.historyNote || ''}`.trim(), d);
|
|
3997
4135
|
}));
|
|
@@ -5006,25 +5144,64 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5006
5144
|
to: z.string().optional().describe('the recipient\u2019s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given.'),
|
|
5007
5145
|
text: z.string().describe('the message, up to 1000 characters'),
|
|
5008
5146
|
replyToMessageId: z.string().optional().describe('reply to a specific message in the conversation'),
|
|
5147
|
+
dryRun: z.boolean().optional().describe('CHECK FIRST, SEND NOTHING. Asks Bluesky whether a DM to `to` would be delivered at all and returns canChat plus the reason when it would not \u2014 that account has DMs off, only accepts them from people it follows, has blocked this one, is suspended, or does not exist. Free, and the message is NOT sent. Use it before writing a long DM to someone who has never been messaged, so a refusal costs nothing instead of arriving after the words were written. `text` is still validated for length so a dry run cannot pass on a message the real send would refuse.'),
|
|
5009
5148
|
},
|
|
5010
|
-
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), sentAt: z.string().optional(), text: z.string().optional(), to: z.string().optional(), delivered: z.boolean().optional() },
|
|
5149
|
+
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), sentAt: z.string().optional(), text: z.string().optional(), to: z.string().optional(), delivered: z.boolean().optional(), dryRun: z.boolean().optional(), sent: z.boolean().optional(), canChat: z.boolean().optional(), reason: z.string().optional(), note: z.string().optional() },
|
|
5011
5150
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
5012
5151
|
}, wrap(async (a) => {
|
|
5013
5152
|
const d = await apiPost('/api/bluesky/dm', a);
|
|
5153
|
+
// A DRY RUN MUST NEVER RENDER AS A SEND. The send sentence below reads "Sent the DM ..." and would be a
|
|
5154
|
+
// plain falsehood about a call that deliberately delivered nothing.
|
|
5155
|
+
if (d.dryRun) return ok(d.note || `Pre-flight only \u2014 nothing was sent. canChat: ${d.canChat === true}.`, d);
|
|
5014
5156
|
return ok(`${d.delivered ? 'Sent' : 'Bluesky accepted but returned no message id for'} the DM${d.to ? ` to ${d.to}` : ''} \u2014 conversation ${d.convoId}${d.sentAt ? `, recorded at ${d.sentAt}` : ''}.\n\u201c${d.text}\u201d`, d);
|
|
5015
5157
|
}));
|
|
5016
5158
|
server.registerTool('mark_bluesky_convo_read', {
|
|
5017
|
-
title: 'Mark
|
|
5018
|
-
description: 'Clear the unread count on
|
|
5159
|
+
title: 'Mark Bluesky conversations as read',
|
|
5160
|
+
description: 'Clear the unread count on Bluesky DMs \u2014 ONE conversation, or the WHOLE ACCOUNT when convoId is omitted (Bluesky\u2019s own mark-all, narrowable with status to just the message requests or just the accepted threads). Useful after triaging an inbox so the next list_bluesky_convos does not surface the same threads again. Reports the unread count \u2014 or the number of conversations \u2014 Bluesky reads back, not the one requested. Free.',
|
|
5019
5161
|
inputSchema: {
|
|
5020
|
-
convoId: z.string().describe('from list_bluesky_convos'),
|
|
5162
|
+
convoId: z.string().optional().describe('from list_bluesky_convos. OMIT to mark EVERY conversation on the account read.'),
|
|
5021
5163
|
messageId: z.string().optional().describe('mark read only up to this message; omit to clear the whole conversation'),
|
|
5164
|
+
status: z.enum(['request', 'accepted']).optional().describe('mark-all only: narrow it to just the message requests, or just the accepted conversations. Omit to clear both. An unknown value is refused rather than dropped \u2014 a dropped one would clear everything when you asked to clear only the requests.'),
|
|
5022
5165
|
},
|
|
5023
|
-
outputSchema: { convoId: z.string().optional(), unread: z.number().optional(), with: z.string().optional() },
|
|
5166
|
+
outputSchema: { convoId: z.string().optional(), unread: z.number().optional(), with: z.string().optional(), all: z.boolean().optional(), status: z.string().optional(), updatedCount: z.number().nullable().optional(), note: z.string().optional() },
|
|
5024
5167
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
5025
5168
|
}, wrap(async (a) => {
|
|
5026
5169
|
const d = await apiPost('/api/bluesky/mark-read', a);
|
|
5027
|
-
return ok(`Conversation with ${d.with || d.convoId} now reads ${d.unread} unread.`, d);
|
|
5170
|
+
return ok(d.all ? (d.note || 'Marked read.') : `Conversation with ${d.with || d.convoId} now reads ${d.unread} unread.`, d);
|
|
5171
|
+
}));
|
|
5172
|
+
// \u2500\u2500 THE CONVERSATION ACTIONS (2026-08-26) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5173
|
+
// We LISTED a conversation as a message REQUEST and offered no way to accept it, and reported it as MUTED with
|
|
5174
|
+
// no way to mute or unmute it \u2014 a state drawn on the screen with no handle behind it. Seven lexicons behind
|
|
5175
|
+
// ONE tool rather than seven tools: this roster is past 700 and 30-50 is where instruction-following starts to
|
|
5176
|
+
// degrade, and the repo already spells this pattern manage_meta_post / manage_linkedin_post.
|
|
5177
|
+
server.registerTool('manage_bluesky_convo', {
|
|
5178
|
+
title: 'Accept, mute, lock, leave or prune a Bluesky DM conversation',
|
|
5179
|
+
description: 'Act on one Bluesky DM conversation. ACCEPT a message request \u2014 Bluesky holds DMs from people the account does not follow in a separate requests folder, and until one is accepted it stays there. MUTE / UNMUTE it. LOCK / UNLOCK it (no new messages). LEAVE it. Or DELETE one message from THIS account\u2019s view. TWO THINGS TO SAY OUT LOUD BEFORE USING THEM: deleting is FOR SELF ONLY \u2014 the other person still sees the message, because Bluesky offers no delete-for-everyone in chat \u2014 and leaving a conversation cannot be undone from here. Accepting a request that was already accepted is reported as such rather than as a change, because Bluesky says so by returning no revision. Free, and it needs the same PRIVILEGED app password as every other Bluesky DM tool. Get a convoId from list_bluesky_convos.',
|
|
5180
|
+
inputSchema: {
|
|
5181
|
+
action: z.enum(['accept', 'mute', 'unmute', 'lock', 'unlock', 'leave', 'deleteMessage']).describe('what to do to the conversation'),
|
|
5182
|
+
convoId: z.string().describe('from list_bluesky_convos'),
|
|
5183
|
+
messageId: z.string().optional().describe('required for deleteMessage \u2014 from read_bluesky_dm'),
|
|
5184
|
+
},
|
|
5185
|
+
outputSchema: { action: z.string().optional(), convoId: z.string().optional(), messageId: z.string().optional(), accepted: z.boolean().optional(), alreadyAccepted: z.boolean().optional(), left: z.boolean().optional(), deletedForSelf: z.boolean().optional(), muted: z.boolean().optional(), unread: z.number().optional(), with: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
5186
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
5187
|
+
}, wrap(async (a) => {
|
|
5188
|
+
const d = await apiPost('/api/bluesky/convo-action', a);
|
|
5189
|
+
return ok(d.note || `${a.action} done on ${a.convoId}.`, d);
|
|
5190
|
+
}));
|
|
5191
|
+
server.registerTool('react_to_bluesky_dm', {
|
|
5192
|
+
title: 'React to a Bluesky direct message',
|
|
5193
|
+
description: 'Add or remove an emoji reaction on one message in a Bluesky DM \u2014 the light acknowledgement that does not need a written reply, and the thing to reach for when someone says \u201cthanks\u201d and a paragraph back would be worse than a thumbs-up. A REACTION IS EXACTLY ONE EMOJI: that is Bluesky\u2019s own rule, so a word or a phrase is refused here rather than by the service (where it comes back as an opaque ReactionInvalidValue). Reports the reactions Bluesky reads the message back carrying, not the one requested. Free.',
|
|
5194
|
+
inputSchema: {
|
|
5195
|
+
convoId: z.string().describe('from list_bluesky_convos'),
|
|
5196
|
+
messageId: z.string().describe('from read_bluesky_dm'),
|
|
5197
|
+
value: z.string().describe('exactly one emoji, e.g. \uD83D\uDC4D'),
|
|
5198
|
+
remove: z.boolean().optional().describe('true to take the reaction off instead of putting it on'),
|
|
5199
|
+
},
|
|
5200
|
+
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), value: z.string().optional(), removed: z.boolean().optional(), reactions: z.array(z.string()).optional(), text: z.string().optional(), note: z.string().optional() },
|
|
5201
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
5202
|
+
}, wrap(async (a) => {
|
|
5203
|
+
const d = await apiPost('/api/bluesky/react', a);
|
|
5204
|
+
return ok(d.note || `Reaction ${a.value} ${a.remove ? 'removed' : 'added'}.`, d);
|
|
5028
5205
|
}));
|
|
5029
5206
|
server.registerTool('tiktok_creator_info', {
|
|
5030
5207
|
title: 'Read the connected TikTok creator’s posting options',
|
|
@@ -5388,7 +5565,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5388
5565
|
// not an approval; and removal is a different HTTP verb on each of the two edges. All of it in
|
|
5389
5566
|
// lib/meta-partnership-ads.mjs and applied by the routes, so there is no second copy to drift.
|
|
5390
5567
|
server.registerTool('list_meta_partnership_creators', {
|
|
5391
|
-
title: '
|
|
5568
|
+
title: 'The brand’s Partnership Ads creators',
|
|
5392
5569
|
description: 'The creators this brand has set up for PARTNERSHIP ADS — ads that run from a CREATOR’s handle instead of the brand’s, which is what a paid collaboration looks like in feed. TWO SEPARATE LISTS come back and NEITHER IMPLIES THE OTHER: the AD-PERMISSION list is whose content this brand may run as an advert, and the TAG-APPROVAL list is who may tag this brand as a paid partner on their own organic post. Adding a creator to one does nothing for the other, and that is the mistake that makes a partnership ad fail for a reason Meta’s error does not name. Every row carries the status Meta reports VERBATIM. Meta’s own wording on the wire is prose, not the enum its docs publish — measured 2026-08-25, a fresh request reads “Pending Approval” and a revoked one reads “Canceled” (Partnership Ads Hub calls the same two rows “Request sent” and “Inactive”). PENDING means the creator has not accepted yet and this brand cannot advertise their content until they do. REVOKING DOES NOT REMOVE THE ROW: Meta keeps it and flips the status, so read the status rather than the presence of a row. An unrecognised status is passed through as Meta’s own word, never blanked. A list that could not be READ says exactly that; it is never rendered as "this brand has no creators". THE TWO LISTS ANSWER DIFFERENTLY, and Meta is the reason: the ad-permission list can be ENUMERATED, and the tag-approval list CANNOT — Meta requires `user_ids` on that edge and returns "a list of approved creators, filtered by the user IDs provided", i.e. it CHECKS the people you name and cannot report the rest. So pass `creatorIds` to ask about specific creators; anyone already on the ad-permission list is checked for you, which is what makes "on one list but not the other" answerable in a single call. With nobody to check, the reply says Meta publishes no way to list that edge rather than reporting a failed read. Read-only, 0 credits.',
|
|
5393
5570
|
inputSchema: {
|
|
5394
5571
|
creatorUsername: z.string().optional().describe('narrow the AD-PERMISSION list to one creator handle. It does not affect the tag-approval list, which is addressed by numeric id only.'),
|
|
@@ -5442,7 +5619,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5442
5619
|
return ok(`${d.summary}${bio}${d.warning ? `\n\n\u26a0 ${d.warning}` : ''}\n\n${d.idUse || ''}`, d);
|
|
5443
5620
|
}));
|
|
5444
5621
|
server.registerTool('list_instagram_shopping_catalogs', {
|
|
5445
|
-
title: 'Instagram Shopping
|
|
5622
|
+
title: 'What Instagram Shopping can tag',
|
|
5446
5623
|
description: 'Whether this Instagram account can tag products at all, and which catalogs its SHOP can tag from. CALL THIS FIRST: product tagging needs an APPROVED INSTAGRAM SHOP, and if the account does not have one, tagging fails AFTER the photo is already uploaded. The reply says which of three things is true — eligible, not eligible (a Commerce Manager approval nothing in Hermoso can grant, and not a sign anything is broken), or "could not tell", which is NOT the same as not eligible. AN EMPTY CATALOG LIST IS NOT AN EMPTY CATALOG: Instagram reaches a catalog through the account\'s SHOP, while list_meta_catalogs reads the business PORTFOLIO — a merchant can have a full catalog there and nothing available here until the shop is approved. Read-only, 0 credits.',
|
|
5447
5624
|
inputSchema: { pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected. Its linked Instagram account is the one that gets tagged.') },
|
|
5448
5625
|
outputSchema: { igId: z.string().optional(), account: z.string().optional(), eligible: z.boolean().nullable().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), eligibilityNote: z.string().optional(), note: z.string().optional(), readError: z.string().optional(), limits: z.any().optional() },
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.161",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio and marketing MCP server with
|
|
5
|
+
"description": "AI ad studio and marketing MCP server with 718 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|