hermoso 0.1.251 → 0.1.252

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.
@@ -17,10 +17,13 @@ const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
17
17
  instructions: MCP_INSTRUCTIONS,
18
18
  });
19
19
 
20
- // Roster scoping, same groups as the hosted connector's ?tools= (see registerTools). The DEFAULT is every group
21
- // except `ads` and `analytics` (OPT_IN_TOOL_GROUPS) — together ~254k of the ~365k full roster, so the default is
22
- // ~112k. Both are held out on SIZE alone, and nothing is lost: `enable_tools` switches either on mid-session with
23
- // no reconnect. HERMOSO_TOOLS=all restores the full roster; HERMOSO_TOOLS=create,channels narrows it further.
20
+ // Roster scoping, same groups as the hosted connector's ?tools= (see registerTools). SINCE 2026-09-17 THE DEFAULT
21
+ // IS CORE-FIRST: the `core` group plus the handful of tools that make a connection drivable, measured at ~6K tokens
22
+ // against ~87K for the old default of every group but the opt-in three. Nothing is lost — everything else is held
23
+ // out of the LIST on SIZE alone, and `find_tools` finds it, `call_tool` runs it and a direct tools/call to a name
24
+ // you already know still works. `enable_tools` LISTS a whole group mid-session for a client that re-lists (stdio
25
+ // does). HERMOSO_TOOLS=all restores the full roster; HERMOSO_TOOLS=create,channels narrows it further; and
26
+ // MCP_CORE_FIRST=1 opts this process into the small core-first roster (the default is the full roster: see mcp/tools.mjs).
24
27
  // An unknown group EXITS rather than silently serving all of them — a scoped connection you did not get is
25
28
  // worse than one you were told you could not have.
26
29
  // Both env names are read: HERMOSO_TOOLS is the current prefix, HEIST_TOOLS the pre-rebrand name that is live in
package/mcp/http.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
17
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
18
- import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
18
+ import { registerTools, MCP_INSTRUCTIONS, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
19
19
  import { mcpCtx, connectedProviders } from './client.mjs';
20
20
 
21
21
  // Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
@@ -234,8 +234,11 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
234
234
  // `?tools=research,create` narrows the roster this connection advertises (see registerTools). Read here rather
235
235
  // than inside registerTools so BOTH the anonymous discovery handshake and a real session honour the same query,
236
236
  // and so an unknown group is refused at the door with the valid list instead of silently serving every group.
237
- // ABSENT, the DEFAULT is every group except `ads` and `analytics` (OPT_IN_TOOL_GROUPS) — together ~254k of the
238
- // ~365k full roster, so an eagerly-loading client gets ~112k. `?tools=all` restores the full roster.
237
+ // ABSENT, an AUTHENTICATED session resolves to the CORE-FIRST default (see defaultToolGroups in tools.mjs): the
238
+ // core tools plus a few that make the connection drivable, with everything else held out of the LIST on size and
239
+ // reachable through find_tools + call_tool. `?tools=all` restores the full roster for one connection and
240
+ // `MCP_CORE_FIRST=1` opts this process into the small core-first roster; the default is the full roster. The anonymous discovery path above is
241
+ // deliberately NOT core-first — see the comment on its registerTools call.
239
242
  // The scope fixed here is the STARTING roster, not a cage: `enable_tools` widens it mid-session and the SDK
240
243
  // notifies the client. That is deliberate — the old comment's "tools/list must not change under a live client"
241
244
  // was the right instinct for a scope the SERVER changes silently, and the wrong one for a change the CLIENT
@@ -260,7 +263,14 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
260
263
  // scope to and nothing honest to read — and a registry crawler or an agent deciding whether to connect MUST
261
264
  // see the real catalog, not a zero-connector one. registerTools treats an absent `connectors` exactly like a
262
265
  // failed read: full roster. Do not "fix" this by reading the workspace off the request; it is forgeable.
263
- registerTools(server, { only: scope?.groups, directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) , hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
266
+ // ── AND THE ANONYMOUS ROSTER IS NOT CORE-FIRST, DELIBERATELY (2026-09-17) ──────────────────────────────────
267
+ // An authenticated session pays for its roster on every turn, which is the whole argument for core-first. This
268
+ // request pays for nothing and is nobody's turn: it is a registry crawler (registry.modelcontextprotocol.io,
269
+ // Glama, Smithery), OpenAI's own submission scanner, or an agent deciding whether to connect at all — and for
270
+ // every one of those the roster IS the product description. Serving them the core set would publish Hermoso as
271
+ // a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
272
+ // default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
273
+ registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) , hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
264
274
  if (typeof onAnonDiscovery === 'function' && methodsOf(req.body).includes('tools/list')) { try { onAnonDiscovery({ client: clientInfoOf(req.body), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req) }); } catch {} }
265
275
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
266
276
  res.on('close', () => { try { transport.close(); server.close(); } catch {} });
@@ -0,0 +1,103 @@
1
+ // ── WHAT A TOOL COSTS, AS find_tools REPORTS IT (2026-09-17) ───────────────────────────────────────────────────
2
+ //
3
+ // An agent choosing between two tools should be able to see the price before it calls one, the way it can see the
4
+ // parameters. Monid's `inspect` does exactly this and their docs tell the agent to read the live price rather than
5
+ // trust the doc — "it is the source of truth, not this document". Ours has to hold the same line.
6
+ //
7
+ // THERE IS NO PER-TOOL PRICE TABLE IN THIS PRODUCT, AND INVENTING ONE WOULD BE THE WRONG FIX. Credits are spent
8
+ // per MODEL RUN, and what a run costs depends on the model, the resolution and the length — which is why the only
9
+ // exact numbers live in the live catalog `/api/generate/status` serves and `hermoso_capabilities` prints. A number
10
+ // written into this file would be a second, stale answer to a question the server already answers correctly
11
+ // ([[unsourced-capability-comments]]). So: THIS MODULE CLASSIFIES, IT NEVER PRICES. Every digit find_tools shows
12
+ // comes from that same live catalog, fetched once per process and quoted as a range; when the catalog has not been
13
+ // read the row says the class and no number, which is honest rather than guessed.
14
+ //
15
+ // THE CLASSIFICATION IS THE SERVER'S OWN ONE SENTENCE, MAPPED ONTO THE GROUP MARKERS. `COST_MODEL_SENTENCE` in
16
+ // server.js — the one copy every surface prints — says credits are spent on exactly two things, running an AI
17
+ // model and Ad Spy research, with X as the single per-call exception, and that everything else (publishing,
18
+ // scheduling, campaign management, analytics, comments, DMs, connectors, brands, team) is free on every plan.
19
+ // Those two things are the `create` and `research` groups, which each tool declares by the `server.group()` marker
20
+ // it was written under and which `tools/tool-group-truth-check.mjs` already holds true. So the rule derives from
21
+ // two things that are independently maintained, not from a hand-list of 841 names:
22
+ //
23
+ // group `create` → 'model' (it runs a model — image, video, voice, text, planning, post-production)
24
+ // group `research` → 'research' (Ad Spy: our own research key pays the vendor)
25
+ // provider `x` → 'percall' (X bills us per API request; the documented exception)
26
+ // everything else → 'free'
27
+ //
28
+ // THE TWO EXCEPTION SETS BELOW ARE THE READS THAT LIVE INSIDE THOSE GROUPS — a Library listing or a job poll is
29
+ // filed under `create` because that is the section it was written in, and it spends nothing. They are the only
30
+ // hand-maintained part, they are small, and `tools/tool-cost-health-check.mjs` cross-examines them against each
31
+ // tool's OWN description: a tool whose description says "0 credits" or "Free, read-only" may not be classed paid,
32
+ // and one whose description states its OWN price ("Spends credits" at a sentence start, or a "Paid (…)" clause)
33
+ // may not be classed free. That makes the two statements check each other instead of drifting apart. The claim has
34
+ // to be about THIS tool: three genuinely free tools merely CONTAIN the words — hermoso_credits prints the rule
35
+ // itself, list_creators explains that generating a fresh face costs credits, and set_competitor_watch says the
36
+ // weekly RUN spends — so a loose match would have mis-flagged all three.
37
+ //
38
+ // PURE, NO IMPORTS, so the checks RUN these functions and so the cli twin — which ships without lib/ and without a
39
+ // repo around it — resolves the same specifier the server copy does.
40
+
41
+ // Reads inside `create`. Each is a lookup or a poll: it returns something we already hold and runs no model.
42
+ export const CREATE_FREE_READS = Object.freeze([
43
+ 'get_job', 'list_library', 'fetch_asset', 'list_skills', 'get_skill',
44
+ 'list_product_photos', 'fetch_app_screens', 'list_hooks',
45
+ 'list_meta_posts', 'list_published_posts', 'post_performance', 'diagnose_posts', 'backfill_posts',
46
+ ]);
47
+ // Reads inside `research`. `find_competitors` says "0 credits" in its own description (it is the discovery model,
48
+ // billed to us, not a ScrapeCreators call); the watch tools only write and read a stored preference — the weekly
49
+ // run that spends is a job, not this call.
50
+ export const RESEARCH_FREE_READS = Object.freeze(['find_competitors', 'list_watch_findings', 'set_competitor_watch']);
51
+
52
+ export const COST_CLASSES = Object.freeze(['free', 'model', 'research', 'percall']);
53
+
54
+ /**
55
+ * The cost CLASS of one tool. `group` is the registry's own marker for it; `provider` is toolProvider(name).
56
+ * Unknown/unmapped ⇒ 'free', which matches the server's sentence: everything that is not a model run or Ad Spy
57
+ * research is free on every plan.
58
+ */
59
+ export function toolCostClass(name, group, provider = null) {
60
+ const n = String(name || '');
61
+ if (provider === 'x') return 'percall'; // X charges per API request — the one exception
62
+ if (group === 'create') return CREATE_FREE_READS.includes(n) ? 'free' : 'model';
63
+ if (group === 'research') return RESEARCH_FREE_READS.includes(n) ? 'free' : 'research';
64
+ return 'free';
65
+ }
66
+
67
+ // The sentence each class prints. NO DIGITS HERE — see the header. `range` is filled in by the caller from the
68
+ // live catalog when it has read one.
69
+ export function costLabel(cls, range = '') {
70
+ if (cls === 'percall') return 'a few credits per call (X charges per API request)';
71
+ if (cls === 'research') return 'credits (Ad Spy research)';
72
+ if (cls === 'model') return range ? `credits — ${range}` : 'credits (runs a model; hermoso_capabilities has the exact per-model figure)';
73
+ return 'free';
74
+ }
75
+
76
+ /**
77
+ * The one range find_tools quotes, built from the SAME payload hermoso_capabilities prints
78
+ * (`GET /api/generate/status`). Returns '' when the catalog has not been read — an absent number is never
79
+ * replaced by a guessed one ([[failed-read-is-not-empty]]).
80
+ *
81
+ * `kind` picks which catalog to read: a video tool is priced by the video models, an image tool by the image ones.
82
+ * Anything else gets the whole span, which is the honest answer for a tool that could route to either.
83
+ */
84
+ export function creditRangeFrom(status, kind = 'any') {
85
+ if (!status || typeof status !== 'object') return '';
86
+ const nums = [];
87
+ const eat = (rows) => { for (const m of rows || []) {
88
+ if (typeof m?.credits === 'number') nums.push(m.credits);
89
+ else if (m?.credits && typeof m.credits === 'object') for (const v of Object.values(m.credits)) if (typeof v === 'number') nums.push(v);
90
+ if (m?.creditsBySize) for (const v of Object.values(m.creditsBySize)) if (typeof v === 'number') nums.push(v);
91
+ } };
92
+ if (kind === 'image' || kind === 'any') eat(status.options?.image?.models);
93
+ if (kind === 'video' || kind === 'any') eat(status.options?.video?.models);
94
+ const ok = nums.filter((n) => Number.isFinite(n) && n > 0);
95
+ if (!ok.length) return '';
96
+ const lo = Math.min(...ok), hi = Math.max(...ok);
97
+ return lo === hi ? `${lo} credits` : `${lo}-${hi} credits by model/length/resolution`;
98
+ }
99
+
100
+ // Which catalog a generation tool is priced from. Deliberately tiny and name-shaped: a tool it does not recognise
101
+ // gets the class label with no number, which is the safe direction.
102
+ export const costKindOf = (name) => (/video|avatar|sizzle|explainer|stitch|reframe|upscale|recast|dub|clip|subtitle|beat|finish|multiply|motion/i.test(String(name || '')) ? 'video'
103
+ : /image|thumbnail|photo|static|render_ad|template/i.test(String(name || '')) ? 'image' : 'any');
@@ -0,0 +1,103 @@
1
+ // ── IS THIS TOOL WORKING RIGHT NOW? (2026-09-17) ───────────────────────────────────────────────────────────────
2
+ //
3
+ // Monid's discover puts a health status and a median run time on every row, and hides an endpoint that is in
4
+ // outage. That is worth copying: an agent picking between two tools should not have to discover by spending a
5
+ // minute and a credit that one of them has failed its last nine calls.
6
+ //
7
+ // WHERE THE NUMBERS COME FROM, AND WHY NOT FROM THE ERROR LEDGER ALONE. The error ledger is the right instinct —
8
+ // it is our record of what users hit — but it records only FAILURES, with no successes and no durations, so a
9
+ // failure RATE and a typical duration cannot be computed from it at all. A ledger read is also an HTTP round trip
10
+ // per find_tools call, which is exactly the cost this feature must not add. So health is measured where the calls
11
+ // already pass: `wrap()` in mcp/tools.mjs, the one seam every tool handler returns through, and the same seam that
12
+ // FEEDS the error ledger via reportToolError. Same signal, in process, free, and it carries the two things the
13
+ // ledger cannot: the successes and the clock.
14
+ //
15
+ // WHAT IT IS AND IS NOT. It is THIS PROCESS's recent experience, which on the hosted server is every caller's
16
+ // calls and on stdio is this session's. It is therefore evidence, not a fleet statistic — and the difference is
17
+ // stated in the wording find_tools prints. NO RECENT CALLS IS SAID AS SUCH, never rendered as healthy
18
+ // ([[failed-read-is-not-empty]]): "we have not seen this tool run lately" and "this tool works" are different
19
+ // claims and the second one is the dangerous one to guess.
20
+ //
21
+ // BOUNDED BY CONSTRUCTION. At most HEALTH_TOOLS_MAX tools are tracked (LRU), at most HEALTH_SAMPLES_MAX outcomes
22
+ // each, and anything older than HEALTH_WINDOW_MS is dropped on read. Worst case is a few tens of kilobytes, on a
23
+ // process that already holds a 350K-token tool canon. No timer, no I/O, no allocation on the read path beyond the
24
+ // window filter — a find_tools call that scans 841 rows must stay cheap.
25
+ //
26
+ // PURE-ISH AND DEPENDENCY-FREE: one module-level Map and functions over it, so the check RUNS this rather than
27
+ // reading it, and so the cli twin (no lib/, no repo) resolves the same specifier as the server copy.
28
+
29
+ export const HEALTH_WINDOW_MS = 60 * 60 * 1000; // an hour: long enough to see a pattern, short enough to be "now"
30
+ export const HEALTH_SAMPLES_MAX = 20; // per tool
31
+ export const HEALTH_TOOLS_MAX = 400; // distinct tools tracked, LRU by last write
32
+ export const HEALTH_FAILING_RATE = 0.6; // "nearly all failing" — ranked last, and said out loud
33
+ export const HEALTH_DEGRADED_RATE = 0.25;
34
+ export const HEALTH_MIN_CALLS = 3; // below this, one bad call is noise, not a verdict
35
+
36
+ const _tools = new Map(); // name -> [{ at, ok, ms }] (insertion-ordered ⇒ LRU)
37
+
38
+ /** Record one finished tool call. Never throws — a health write may not break a tool's own answer. */
39
+ export function recordToolOutcome(name, opts) {
40
+ try {
41
+ // DESTRUCTURED INSIDE THE try, NOT IN THE PARAMETER LIST. A default only fires on `undefined`, so
42
+ // `recordToolOutcome(name, null)` threw a TypeError BEFORE the try could catch it — and this function is
43
+ // called from inside `wrap()`, where a throw would replace a tool's real answer with a health-bookkeeping
44
+ // error. Measured: the check's "a bad write never throws" assertion went red on exactly that call.
45
+ const { ok, ms = 0, now = Date.now() } = opts || {};
46
+ const n = String(name || '');
47
+ if (!n) return;
48
+ let arr = _tools.get(n);
49
+ if (arr) _tools.delete(n); else arr = []; // re-insert ⇒ this tool is the most recently used
50
+ arr.push({ at: now, ok: !!ok, ms: Number(ms) || 0 });
51
+ if (arr.length > HEALTH_SAMPLES_MAX) arr.splice(0, arr.length - HEALTH_SAMPLES_MAX);
52
+ _tools.set(n, arr);
53
+ while (_tools.size > HEALTH_TOOLS_MAX) _tools.delete(_tools.keys().next().value);
54
+ } catch { /* health is never load-bearing */ }
55
+ }
56
+
57
+ /** Test seam only — the checks need a clean slate between sections. */
58
+ export function _resetToolHealth() { _tools.clear(); }
59
+ export function _healthSize() { return _tools.size; }
60
+
61
+ /**
62
+ * The verdict for one tool. Shapes:
63
+ * { state: 'unseen' } — nothing recent; SAY SO, never "healthy"
64
+ * { state: 'healthy'|'degraded'|'failing', calls, failures, failRate, medianMs }
65
+ */
66
+ export function toolHealth(name, now = Date.now()) {
67
+ const arr = (_tools.get(String(name || '')) || []).filter((s) => now - s.at <= HEALTH_WINDOW_MS);
68
+ if (!arr.length) return { state: 'unseen', calls: 0 };
69
+ const calls = arr.length;
70
+ const failures = arr.filter((s) => !s.ok).length;
71
+ const failRate = failures / calls;
72
+ const times = arr.map((s) => s.ms).filter((m) => m > 0).sort((a, b) => a - b);
73
+ const medianMs = times.length ? times[Math.floor(times.length / 2)] : 0;
74
+ // A SINGLE FAILED CALL IS NOT A VERDICT. Below HEALTH_MIN_CALLS the honest answer is that we have seen it run,
75
+ // with however many failures, and nothing stronger — so it stays 'healthy' for ranking and the counts are
76
+ // printed beside it. Above it, the rate decides.
77
+ const state = calls >= HEALTH_MIN_CALLS && failRate >= HEALTH_FAILING_RATE ? 'failing'
78
+ : calls >= HEALTH_MIN_CALLS && failRate >= HEALTH_DEGRADED_RATE ? 'degraded'
79
+ : 'healthy';
80
+ return { state, calls, failures, failRate, medianMs };
81
+ }
82
+
83
+ /** One short phrase for a find_tools row. `unseen` says it is unseen; it never reads as an endorsement. */
84
+ export function healthLabel(h) {
85
+ if (!h || h.state === 'unseen') return 'no recent calls';
86
+ const t = h.medianMs ? `, ~${h.medianMs >= 1000 ? `${(h.medianMs / 1000).toFixed(1)}s` : `${h.medianMs}ms`} typical` : '';
87
+ if (h.state === 'healthy') return `${h.calls}/${h.calls - h.failures} recent calls ok${t}`.replace(/^(\d+)\/(\d+)/, '$2 of $1');
88
+ return `${h.state.toUpperCase()}: ${h.failures} of ${h.calls} recent calls failed${t}`;
89
+ }
90
+
91
+ /**
92
+ * The rank penalty a row carries. 0 = nothing wrong. Higher sorts LATER.
93
+ * A tool that cannot run at all (a connector this workspace has not made) and a tool that is failing its calls are
94
+ * both worse picks than a working one — but NEITHER IS HIDDEN, because "we have no such tool" is the single most
95
+ * expensive wrong answer this product can give ([[prompt-rosters-go-stale]]).
96
+ */
97
+ export function healthPenalty(h, hold = null) {
98
+ let p = 0;
99
+ if (hold) p += 2; // not connected / host policy / directory cage
100
+ if (h?.state === 'failing') p += 2;
101
+ else if (h?.state === 'degraded') p += 1;
102
+ return p;
103
+ }
@@ -0,0 +1,57 @@
1
+ // ── HINTS: THE NEXT STEP, NAMED (2026-09-17) ───────────────────────────────────────────────────────────────────
2
+ //
3
+ // This product already tells an agent what to do next, constantly and well — "connect it under Settings ▸
4
+ // Connectors, then call again", "run buy_credits to top up", "call find_tools then call_tool", "reconnect with
5
+ // ?tools=all". Every one of those is a SENTENCE inside a wall of other sentences, so reading it is a parsing job
6
+ // an agent may or may not do. Monid rides a `Hints` block on its responses — server-suggested next command,
7
+ // related endpoints, caveats — and tells the agent to prefer hints over guessing. That is the cheap half we were
8
+ // missing: the advice exists, it just was not addressable.
9
+ //
10
+ // SO THIS ADDS A FIELD AND REMOVES NOTHING. The prose stays exactly as it was, because it is what a model
11
+ // actually reads and because half our hosts show only the text. The hint is the same advice, keyed, for a client
12
+ // or an agent that wants to branch on it rather than match a string.
13
+ //
14
+ // WHERE IT RIDES. `_meta`, which the MCP spec makes the sanctioned extension point on any result ("any result MAY
15
+ // carry it"), so every client that does not know the key ignores it. This file already puts the structured error
16
+ // marker there for the same reason. NOT `structuredContent`: a tool declares an outputSchema and a caller may
17
+ // validate against it, and a next-step suggestion is not part of any tool's output contract.
18
+ //
19
+ // THE SHAPE IS {do, why} AND NOTHING ELSE. `do` is the action, written as the call to make where there is one, so
20
+ // it can be executed without interpretation; `why` is the reason, so an agent can decide rather than obey. No
21
+ // severity, no codes, no nesting — a richer shape would need a schema, a version and a migration, and the whole
22
+ // value here is that it is free to add wherever we already write the sentence.
23
+ //
24
+ // PURE, NO IMPORTS — same twin-safety rule as roster-scope.mjs and well-formed.mjs.
25
+
26
+ export const HINTS_KEY = 'hermoso.ai/hints';
27
+ export const HINTS_MAX = 4; // a list nobody reads is not a hint; keep it to the next step, not a plan
28
+ export const HINT_DO_MAX = 200, HINT_WHY_MAX = 300;
29
+
30
+ /** Normalise a hint list. Drops anything without a `do`, trims, caps. Never throws. */
31
+ export function normalizeHints(hints) {
32
+ try {
33
+ const out = [];
34
+ for (const h of Array.isArray(hints) ? hints : []) {
35
+ const doIt = String(h?.do ?? '').replace(/\s+/g, ' ').trim().slice(0, HINT_DO_MAX);
36
+ if (!doIt) continue; // a hint with no action is noise
37
+ out.push({ do: doIt, why: String(h?.why ?? '').replace(/\s+/g, ' ').trim().slice(0, HINT_WHY_MAX) });
38
+ if (out.length >= HINTS_MAX) break;
39
+ }
40
+ return out;
41
+ } catch { return []; }
42
+ }
43
+
44
+ /**
45
+ * Attach hints to a tool result without touching anything already on it — including an existing `_meta`, which
46
+ * carries the structured error marker on every failure and must survive.
47
+ * A result with no usable hints comes back BY REFERENCE, unchanged: adding an empty key to every reply in the
48
+ * product would be pure weight.
49
+ */
50
+ export function withHints(result, hints) {
51
+ const list = normalizeHints(hints);
52
+ if (!list.length || !result || typeof result !== 'object') return result;
53
+ return { ...result, _meta: { ...(result._meta || {}), [HINTS_KEY]: list } };
54
+ }
55
+
56
+ /** Read them back — the shape a check and a client both use, so neither has to know the key. */
57
+ export const hintsOf = (result) => (result && result._meta && Array.isArray(result._meta[HINTS_KEY])) ? result._meta[HINTS_KEY] : [];
package/mcp/tools.mjs CHANGED
@@ -15,6 +15,12 @@ import { wellFormedValue, wellFormedString } from './well-formed.mjs';
15
15
  // re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
16
16
  // twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
17
17
  import { toolHeldBackByConnectors, toolProvider, toolUnoffered, metaAlternativeNote } from './roster-scope.mjs';
18
+ // WHAT A TOOL COSTS AND WHETHER IT IS WORKING — the two facts find_tools puts on every row beside the parameters.
19
+ // Same twin-safe specifier rule as the two imports above: no lib/, no repo, no relative escape.
20
+ import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
21
+ import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
22
+ // THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
23
+ import { withHints } from './tool-hints.mjs';
18
24
 
19
25
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
20
26
  const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
@@ -190,7 +196,7 @@ export const CAPABILITY_MAP = [
190
196
  '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.',
191
197
  '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).',
192
198
  '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) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (revoke and drop a connection; confirm-gated because reconnecting a sign-in account needs a browser) · 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 an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them 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) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · 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) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · 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) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · 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 LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). 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 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 · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · 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) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · 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.',
193
- 'F) YOUR ROSTER STARTS SLIM, AND YOU CAN WIDEN IT YOURSELF — paid-campaign management (`ads`) is NOT loaded by default. On a host that cannot reload its tool list (claude.ai, ChatGPT) use find_tools + call_tool, which reach every tool without a reload. 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. THE SAME APPLIES TO `channel_admin`: you can PUBLISH and SCHEDULE to every connected channel out of the box, but reading a channel back \u2014 its insights, comments, DMs, product catalogs, message templates, webhooks, or editing/deleting an already-published post \u2014 lives in that group. The moment the user asks to read, moderate, reply to, measure or clean up what is ALREADY on a channel, call enable_tools({groups:[\'channel_admin\']}). Never say Hermoso cannot read comments, answer a DM or pull a channel\u2019s numbers. Other groups: research, create, channels, files, workspace, or \'all\'.',
199
+ 'F) YOUR TOOL LIST IS A STARTING POINT, NOT THE PRODUCT \u2014 and the whole catalogue is two calls away. The listed roster is deliberately small (the core tools, plus whatever this connection asked for or you have switched on); every other tool in this map is held out of the LIST on SIZE alone and is fully built, fully live and fully callable. THE ROUTE THAT WORKS ON EVERY HOST, including the ones that cannot reload their tool list at all (claude.ai, ChatGPT): find_tools({query}) searches every tool by task or name and returns its parameters, its credit cost and its recent health in one line, then call_tool({name, args}) RUNS it \u2014 same account, same permissions, same result as if it had been listed. A direct tools/call to a name you already know works too. enable_tools({groups:[\u2026]}) additionally LISTS a whole group for hosts that re-list: `ads` (paid-campaign management on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads, Apple Search Ads \u2014 by far the heaviest group), `analytics` (GA4, Search Console), `channel_admin` (reading a channel back: insights, comments, DMs, catalogs, templates, webhooks, editing or deleting a published post), plus research, create, channels, files, workspace, or \'all\'. NEVER tell a user Hermoso cannot build a campaign, read a comment, answer a DM or pull a channel\u2019s numbers because you cannot see the tool \u2014 look it up and call it.',
194
200
  ].join('\n');
195
201
 
196
202
  // Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
@@ -225,9 +231,12 @@ export const MCP_INSTRUCTIONS = [
225
231
  '• 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.',
226
232
  '• 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.',
227
233
  '• RAW MODELS, prompt only: generate_image / generate_video with useBrand:false, generate_voice, generate_text, upload_file (any file becomes a URL every tool accepts).',
228
- '• 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_bluesky, post_to_telegram, post_to_google_business; schedule_post (+ list/reschedule/cancel); list_connectors. Tools for accounts NOT connected are hidden from your list: say to connect it under Settings ▸ Connectors, never that Hermoso lacks the channel.',
234
+ '• 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_bluesky, post_to_telegram, post_to_google_business; schedule_post (+ list/reschedule/cancel); list_connectors. Not connected = say to connect it under Settings ▸ Connectors, never that Hermoso lacks the channel.',
229
235
  // INSTAGRAM_PATHS_NOTE — an inline copy of lib/instagram-paths.mjs (the twins ship without lib/); tools/instagram-paths-check.mjs asserts they are byte-equal.
230
- '• PAID ADS, LEAD FORMS, CLICK-TO-WHATSAPP, ANALYTICS: not in your starting tool list (size) but one call away — find_tools (search every tool by task) then call_tool (run it by name). enable_tools([\'ads\']) loads the group where the host reloads its list. All created PAUSED and read back. A tool missing from your list never means the feature is missing.',
236
+ // THE LAST LINE OF THE HEAD, AND THE ONE THAT MAKES THE SHORT ROSTER SAFE (2026-09-17). Your tool LIST is now
237
+ // core-first; every name in the bullets above is held out of it on SIZE and answers a call anyway. Say that
238
+ // inside the 2 KB a truncating host reads, or a small roster reads as a small product.
239
+ '• PAID ADS, LEAD FORMS, CLICK-TO-WHATSAPP, ANALYTICS and every name above are NOT all in your short starting list — and ARE callable anyway: call_tool({name,args}) runs ANY Hermoso tool, listed or not, find_tools({query}) finds one first, enable_tools({groups}) lists a whole group where the host reloads. Ads are created PAUSED and read back. A tool missing from your list NEVER means the feature is missing.',
231
240
  '• INSTAGRAM, two connectors, one channel: TWO WAYS AN INSTAGRAM ACCOUNT CONNECTS, SAME FEATURES: through Meta (the account is linked to a Facebook Page and comes with that Page — this is also the only path with ads) or directly through the Instagram connector (the account signs in on instagram.com by itself, no Facebook Page or Meta login — right for people who run several Instagram accounts under different logins). Either way it is one `instagram` channel with publishing, media, post and account insights, comments and Instagram Direct DMs; a Page-linked account is chosen with pageId, a direct account (or one of several) with account = an @handle or id from list_connector_accounts("instagram"). "Not connected to Meta" never means "no Instagram" — check the Instagram connector too.',
232
241
  '• ADS, the tool names: 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.',
233
242
  // Sits AFTER the ADS bullet on purpose: the 2 KB head every area must survive is full (tools/mcp-roster-connector-scope-check), and a first-call hint is worth less than a whole area.
@@ -269,7 +278,10 @@ export const MCP_INSTRUCTIONS = [
269
278
  '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
270
279
  '• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here, each with its own brand, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
271
280
  '• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by pasting an API key; ONE creative format, a text plus image card — no video; Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder (full CRUD over the user’s OneDrive); MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this brand may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). Full read+write control over the user’s own channels, not just generation. LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
272
- 'YOUR ROSTER STARTS SLIM, AND THREE GROUPS ARE HELD BACK ON SIZE ALONE — NONE IS MISSING OR UNFINISHED. find_tools + call_tool reach all of them with no reload. (1) `ads`, paid-campaign management across Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads and Apple Search Ads — by far the largest group, and most sessions never touch it. (2) `analytics`, Google Analytics 4 and Google Search Console — what the traffic and the rankings actually did. (3) `channel_admin`, the READ and ADMIN half of every connected channel — insights, comments and moderation, DMs, product catalogs, message templates, webhooks, and editing or deleting an already-published post. PUBLISHING AND SCHEDULING DO NOT NEED IT and are on by default. The MOMENT the user asks to build, budget, target, report on or change a campaign, call enable_tools({groups:[\'ads\']}); the moment they ask about sessions, conversions, revenue by channel, or how their site ranks, call enable_tools({groups:[\'analytics\']}). The moment they ask to read, moderate, reply to, measure or delete something ALREADY on a channel, call enable_tools({groups:[\'channel_admin\']}). Free and instant. IF THE TOOLS DO NOT ACTUALLY APPEAR after that call — some clients cache their tool list for the whole conversation, or re-serve the original roster after a reconnect — do NOT tell the user the capability does not exist, and do not keep retrying: say the group needs a fresh conversation (or, on a client with a connector Refresh control, a refresh), or use the shell route below, which does not depend on the roster changing at all. NEVER tell a user Hermoso cannot manage their campaigns or read their analytics — turn the group on. The full set of group names is: core, research, create, channels, analytics, files, workspace, ads — or \'all\'.',
281
+ 'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This route needs no reload and works on every host, including claude.ai and ChatGPT, which fix their tool list when the connection is made. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call \u2014 some clients cache their tool list for the whole conversation \u2014 do NOT tell the user the capability does not exist and do not keep retrying: just use call_tool, which does not depend on the roster changing at all. Reconnecting with `?tools=all` on the server URL lists everything at connect time. NEVER tell a user Hermoso cannot manage their campaigns or read their analytics, cannot read a comment, answer a DM or pull a channel\u2019s numbers, because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
282
+ // HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
283
+ // prefers structure does not have to parse a sentence to find it.
284
+ 'WHEN A REPLY SUGGESTS A NEXT STEP IT ALSO SAYS SO IN A FIELD: `_meta["hermoso.ai/hints"]` is a list of {do, why} \u2014 the action written as the call to make, and the reason. It is the SAME advice as the sentence beside it, never different, and it appears on the replies that have one to give (a refusal that names a connection to make, an out-of-credits error, a find_tools result, enable_tools on a host that will not re-list). Prefer it over guessing a next step; ignore the key if you would rather read the text.',
273
285
  SHELL_ROUTE,
274
286
  'SENSITIVE / IRREVERSIBLE ACTIONS — ALWAYS confirm with the user first, and make sure they understand exactly what will happen: before DELETING anything (a campaign / ad set / ad, a published FB or Threads post, or a Google Drive file or folder) or STARTING REAL SPEND (activating a campaign or ad), state the EXACT target by NAME and what it is, say plainly that it is permanent / costs real money, get an unambiguous yes, and ONLY then pass confirm:true. Never delete on a vague, plural or "clean up everything" instruction without confirming each specific target; when the user just wants to stop delivery, PAUSE (update_meta_object status:"PAUSED") instead of deleting. Reads (list_*, *_insights, get_*) are always safe and free.',
275
287
  'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits: with a saved card + admin rights it one-click charges after an explicit confirm:true + the returned quote_token (state the exact price first); the FIRST purchase is a Stripe link your human pays, which saves the card. Always report the final media URL to the user.',
@@ -388,18 +400,39 @@ const wrap = (fn) => {
388
400
  // that gets registered (and therefore tagged) is what wrap RETURNS.
389
401
  const outer = async (args, extra) => {
390
402
  const _tool = outer._hermosoTool || '';
391
- try { return await toolCtx.run({ tool: _tool }, () => fn(args, extra)); }
403
+ // ── HEALTH IS MEASURED HERE, AT THE SEAM THE ERROR LEDGER ALREADY USES (2026-09-17) ───────────────────────
404
+ // find_tools reports each tool's recent failure rate and typical duration, and neither can come from the
405
+ // error ledger: it records failures only, with no successes and no clock, and reading it would be an HTTP
406
+ // round trip per search. This is the same signal one layer earlier — every tool handler returns through
407
+ // here, and `reportToolError` two lines down is what feeds the ledger. `isError` counts as a failure
408
+ // because that is what the caller experienced, whoever authored the refusal.
409
+ const _t0 = Date.now();
410
+ try {
411
+ const r = await toolCtx.run({ tool: _tool }, () => fn(args, extra));
412
+ try { recordToolOutcome(_tool, { ok: !r?.isError, ms: Date.now() - _t0 }); } catch {}
413
+ return r;
414
+ }
392
415
  catch (e) {
416
+ try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
393
417
  if (!e?._viaApi) { try { reportToolError(_tool, e); } catch {} }
394
418
  let msg = `Error: ${e?.message || e}`;
419
+ // THE SAME TWO PIECES OF ADVICE, NAMED (2026-09-17). Everything below stays exactly as it was — the prose is
420
+ // what a model reads and what half the hosts show. `_hints` is the identical advice keyed as {do, why}, so a
421
+ // client or an agent can branch on it instead of matching a sentence. This is the highest-value place in the
422
+ // product for it: it is the ONE catch every tool's failure returns through.
423
+ const _hints = [];
395
424
  // credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
396
425
  // BOTH phrasings. The gates say "You're out of credits" while the reserve path says "Not enough credits";
397
426
  // matching only the latter meant research, X posting and the competitor watch hit a 402 and told the agent
398
427
  // nothing about how to fix it, so the top-up path this whole flow depends on was unreachable from those tools.
399
- if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
428
+ if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
400
429
  // connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
401
430
  // in-agent) — Dave 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
402
- else msg += notConnectedHint(e, msg);
431
+ else {
432
+ msg += notConnectedHint(e, msg);
433
+ // Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
434
+ if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
435
+ }
403
436
  // ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
404
437
  // `msg` above is a SENTENCE, built for a model to read. `/v1/tools` — the REST passthrough — has to answer
405
438
  // the same failure with an HTTP status and the v1 error envelope, and deriving one from prose is exactly
@@ -409,7 +442,7 @@ const wrap = (fn) => {
409
442
  // `_meta` is MCP's own sanctioned extension point (spec: any result MAY carry it), so every MCP client
410
443
  // sees an ordinary error result and ignores a key it does not recognise. `publishWrap` spreads the result,
411
444
  // so its ambiguous-publish advice keeps the marker rather than dropping it.
412
- return { content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '' } } };
445
+ return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '' } } }, _hints);
413
446
  }
414
447
  };
415
448
  return outer;
@@ -1856,6 +1889,56 @@ export const TOOL_GROUP_TOKENS = { core: 4300, research: 6600, create: 26000, ch
1856
1889
  export const OPT_IN_TOOL_GROUPS = ['ads', 'analytics', 'channel_admin'];
1857
1890
  export const DEFAULT_TOOL_GROUPS = TOOL_GROUP_NAMES.filter((g) => !OPT_IN_TOOL_GROUPS.includes(g));
1858
1891
 
1892
+ // ── CORE-FIRST IS THE DEFAULT ROSTER NOW (2026-09-17) ──────────────────────────────────────────────────────────
1893
+ //
1894
+ // THE MEASUREMENT THAT DECIDED IT. Running this file's own registerTools and sizing each tool the way a host
1895
+ // receives it: the whole registry is 841 tools; the roster above (every group but the three opt-in ones) is what
1896
+ // every authenticated session was handed on tools/list, and four tools in it — create_meta_ad, schedule_post,
1897
+ // create_meta_adset, reschedule_post — weigh more than fourteen core rosters. Re-measure with
1898
+ // `node tools/core-first-roster-check.mjs`, never off this comment: the numbers move with every tool added.
1899
+ //
1900
+ // WHY THE WHOLE ROSTER WAS NEVER THE RIGHT DEFAULT. A roster is re-sent on every turn, so it is a per-turn tax on
1901
+ // every agent and every user (a two-word Studio message once cost 45 credits, 90% of it tools for platforms the
1902
+ // user had never connected), and it is an ACCURACY problem before a token one — a roster many times past the
1903
+ // 30–50 tool cliff is what makes a model pick the wrong tool. Monid brokers 1,700+ endpoints behind ~6 verbs for
1904
+ // exactly this reason: "find the best tool at runtime … no need to pre-load every possible endpoint into context".
1905
+ //
1906
+ // WHAT MAKES IT SAFE, AND THIS IS THE WHOLE ARGUMENT: NOTHING BECOMES UNREACHABLE. A tool held out of the LIST on
1907
+ // size alone still ANSWERS a direct tools/call (installHeldToolCalls), still runs through call_tool, and is found
1908
+ // by find_tools — all three were built in September for the hosts that fix their tool list at connect time, and
1909
+ // they are what turns a small roster from a capability cut into a lookup. Size was never a reason to refuse a
1910
+ // call, only a reason not to carry the schema. The connector gate, the host-policy withholding and the directory
1911
+ // cage are untouched: those are real refusals and they still refuse, by name, with the way out.
1912
+ //
1913
+ // TWO WAYS BACK, because a default that cannot be turned off is a cage:
1914
+ // • `MCP_CORE_FIRST=1` in the environment turns the small roster on for the whole process, and
1915
+ // • `?tools=core` turns it on for one connection; any explicit `tools=` scope or `enable_tools({groups:[…]})`
1916
+ // mid-session decides the roster outright. An explicit scope ALWAYS wins.
1917
+ // 🚨 CORE-FIRST IS OPT-IN, NOT THE DEFAULT (2026-09-17, Dave: "can chatgpt, cursor etc and other mcps properly use that
1918
+ // to access all our tools or will they think we're missing a lot of functionality? We DO NOT want to hurt quality or
1919
+ // make it seem like we have less functionality"). `find_tools` is OUR tool, not a host feature, so a client only reaches
1920
+ // the other 800 tools if its model READS the instructions that say so — and ChatGPT's connector truncates server
1921
+ // instructions at ~2 KB (docs/knowledge/platform-core.md), which is exactly how a held-out group became unreachable
1922
+ // there once already. A listed roster is what most hosts show a user as "what this server can do". So the default stays
1923
+ // the full roster, and core-first is turned on per host only once it has been RUN there and shown to find and call a
1924
+ // tool that was never listed: MCP_CORE_FIRST=1 for the process, or `?tools=core` for one connection.
1925
+ export const CORE_FIRST_ENV = ['MCP_CORE_FIRST', 'HERMOSO_CORE_FIRST'];
1926
+ export const coreFirstRoster = (env = process.env) => CORE_FIRST_ENV.some((k) => String(env?.[k] ?? '') === '1');
1927
+ // The tools a core-first roster carries BESIDES the `core` group, because without them the connection is not
1928
+ // drivable and the saving would be paid for in capability rather than in tokens:
1929
+ // get_brand — every create tool hydrates the saved brand; an agent that cannot read it cannot say what
1930
+ // it is about to use.
1931
+ // get_job/list_jobs— a render and an async publish return a job id. No poller, no result.
1932
+ // list_connectors — "what can I publish to?" must be answerable before anything is enabled.
1933
+ // list_scheduled — the same row the Studio put in its core set for the same reason: people ask what is queued
1934
+ // constantly, and it is one of the cheapest definitions in the product.
1935
+ // A NAMED SET AND NOT A GROUP, deliberately: moving these into `core` would change what `?tools=core` means for
1936
+ // every existing caller and every per-group count the site and the docs derive. tools/core-first-roster-check.mjs
1937
+ // asserts each name is registered and that NONE of them is connector-gated (one that were would be listed and
1938
+ // then answer 401, which is the thing the connector gate exists to prevent).
1939
+ export const CORE_FIRST_EXTRA = Object.freeze(['get_brand', 'get_job', 'list_jobs', 'list_connectors', 'list_scheduled']);
1940
+ export function defaultToolGroups(env = process.env) { return coreFirstRoster(env) ? ['core'] : [...DEFAULT_TOOL_GROUPS]; }
1941
+
1859
1942
  // Parse a `tools=` scope. Returns {groups} or {error} — an unknown name is REFUSED BY NAME rather than dropped,
1860
1943
  // because silently ignoring it would hand back the full 301-tool roster to someone who explicitly asked for less
1861
1944
  // and thought they got it. Empty/absent means the full roster (the documented default).
@@ -1873,7 +1956,7 @@ export function parseToolScope(raw) {
1873
1956
  if (unknown.length) {
1874
1957
  // No tool COUNT in this message: a committed count goes stale (four different wrong numbers shipped at once
1875
1958
  // on 2026-08-05), and the caller does not need one to fix their query.
1876
- return { error: `Unknown tool group${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Valid groups: ${TOOL_GROUP_NAMES.join(', ')} — or 'all'. Omit it for the default roster, which is every group except ${OPT_IN_TOOL_GROUPS.join(' and ')}; those are held out on SIZE alone and either can be switched on mid-session with enable_tools.` };
1959
+ return { error: `Unknown tool group${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Valid groups: ${TOOL_GROUP_NAMES.join(', ')} — or 'all'. Omit it for the default roster, which is ${coreFirstRoster() ? 'core-first: the `core` group plus a few tools that make the connection drivable. Every other tool is held out of the LIST on size alone and stays callable — find_tools finds it, call_tool runs it, enable_tools lists a whole group' : `every group except ${OPT_IN_TOOL_GROUPS.join(' and ')}; those are held out on SIZE alone and either can be switched on mid-session with enable_tools`}.` };
1877
1960
  }
1878
1961
  return { groups: asked };
1879
1962
  }
@@ -2097,7 +2180,7 @@ async function regateForWorkspace(ctx, pre = null) {
2097
2180
  let enabled = 0, disabled = 0;
2098
2181
  for (const [name, grp] of Object.entries(ctx.groupOf)) {
2099
2182
  const h = ctx.handleOf[name];
2100
- if (!h || !ctx.enabledGroups.has(grp)) continue; // a group still switched OFF is not this function's business
2183
+ if (!h || !listedInCoreFirst(name, grp, ctx)) continue; // a group still switched OFF is not this function's business (a core-first EXTRA is, though — it IS listed)
2101
2184
  const hold = toolHeldBackByConnectors(name, ctx.conn);
2102
2185
  try { if (hold) { h.disable(); disabled++; } else { h.enable(); enabled++; } } catch {}
2103
2186
  }
@@ -2122,6 +2205,16 @@ export const holdReasonFor = (name, ctx) => {
2122
2205
  if (toolHeldBackByDirectory(name, ctx.groupOf[name], ctx)) return 'directory';
2123
2206
  return null;
2124
2207
  };
2208
+ // THE WAY OUT OF A HOLD, AS A HINT (2026-09-17). holdReasonText is the one sentence every refusal prints; this is
2209
+ // the same advice keyed {do, why} so an agent can act on it without parsing the sentence. Derived from the SAME
2210
+ // `why` the sentence is, so the two can never disagree about what the hold was.
2211
+ export const holdHints = (name, why, ctx = null) => {
2212
+ if (why === 'not_connected') { const p = toolProvider(name) || 'that'; return [{ do: `have the user connect "${p}" under Settings \u25b8 Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
2213
+ if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Settings \u25b8 Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
2214
+ if (why === 'host_policy') return [{ do: 'use the Hermoso app or another MCP client for this one', why: `${name} is withheld by this host's own policy, not by Hermoso` }];
2215
+ if (why === 'directory') return [{ do: 'use the Hermoso app, or connect the unscoped server URL https://app.hermoso.ai/mcp', why: `${name} is outside what this Claude directory connection may run` }];
2216
+ return [];
2217
+ };
2125
2218
  export const holdReasonText = (name, why, ctx = null) => {
2126
2219
  if (why === 'not_offered') {
2127
2220
  const prov = toolProvider(name) || 'that';
@@ -2171,6 +2264,40 @@ async function holdReasonRechecked(name, ctx) {
2171
2264
  try { await regateForWorkspace(ctx, fresh); } catch { return why; }
2172
2265
  return holdReasonFor(name, ctx);
2173
2266
  }
2267
+ // MODULE SCOPE, NOT INSIDE buildTools — and the reason is worth keeping. tools/generation-matrix-check.mjs
2268
+ // derives the generation surface by slicing the source between registerTool() calls and asking which
2269
+ // /api/generate/* routes each slice touches. Written just above find_tools, this helper's apiGet fell
2270
+ // inside the PREVIOUS tool's slice and enable_tools was reported as a generation tool. Nothing here needs
2271
+ // the session ctx, so module scope is both correct and unambiguous.
2272
+ // ── THE LIVE PRICE, READ ONCE, AND NEVER ON THE HOT PATH (2026-09-17) ─────────────────────────────────────────
2273
+ // find_tools quotes exact credit figures for the tools that run a model, and they come from the SAME payload
2274
+ // `hermoso_capabilities` prints — `GET /api/generate/status` — because a number written into this file would be
2275
+ // a second, stale answer to a question the server already answers ([[unsourced-capability-comments]]).
2276
+ //
2277
+ // IT IS NEVER AWAITED BY A SEARCH. find_tools has to stay a cheap, instant lookup, and on stdio that GET crosses
2278
+ // the public internet. So a search reads whatever is CACHED: the first one starts the fetch and renders the cost
2279
+ // CLASS with no digits, every later one has the numbers. A failed read leaves the cache empty, which renders as
2280
+ // the class with no number — an absent price, never an invented one ([[failed-read-is-not-empty]]).
2281
+ let _genStatus = null, _genStatusAt = 0, _genStatusInFlight = false;
2282
+ const GEN_STATUS_TTL = 10 * 60e3;
2283
+ const genStatusCached = () => {
2284
+ if (!_genStatusInFlight && (!_genStatus || Date.now() - _genStatusAt > GEN_STATUS_TTL)) {
2285
+ _genStatusInFlight = true;
2286
+ apiGet('/api/generate/status')
2287
+ .then((d) => { _genStatus = d; _genStatusAt = Date.now(); })
2288
+ .catch(() => { _genStatusAt = Date.now(); }) // back off on a failure too, so a search never storms the route
2289
+ .finally(() => { _genStatusInFlight = false; });
2290
+ }
2291
+ return _genStatus;
2292
+ };
2293
+ const costOf = (name, group, hold) => {
2294
+ const cls = toolCostClass(name, group, toolProvider(name));
2295
+ // A tool the caller cannot run at all has no price worth quoting — the answer is the hold, which the row
2296
+ // already carries. Saying "free" there would read as an invitation.
2297
+ const range = cls === 'model' ? creditRangeFrom(genStatusCached(), costKindOf(name)) : '';
2298
+ return { cls, label: costLabel(cls, range), free: cls === 'free', hold: hold || null };
2299
+ };
2300
+
2174
2301
  export function installHeldToolCalls(mcp, ctx) {
2175
2302
  try {
2176
2303
  const low = mcp && mcp.server;
@@ -2184,7 +2311,7 @@ export function installHeldToolCalls(mcp, ctx) {
2184
2311
  if (!h && LEGACY_TOOL_NAMES[name]) return legacyToolAnswer(name, request, extra, ctx); // a name only an old snapshot still holds
2185
2312
  if (h && h.enabled === false) {
2186
2313
  const why = await holdReasonRechecked(name, ctx);
2187
- if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return { content: [{ type: 'text', text: t }], isError: true }; }
2314
+ if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return withHints({ content: [{ type: 'text', text: t }], isError: true }, holdHints(name, why, ctx)); }
2188
2315
  // The call itself is the evidence: this host's tool list still names a tool the session holds out on size,
2189
2316
  // i.e. the host is serving a stale roster. Run it (that is the point) and record that it happened.
2190
2317
  reportDeadEnd('stale_roster', name, `${name} was called directly while held out of this session's list on size — the host's tool list is stale`);
@@ -2226,6 +2353,9 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
2226
2353
  const active = ctx.enabledGroups, groupOf = ctx.groupOf, handles = ctx.handleOf;
2227
2354
  const added = expand.filter((g) => !active.has(g));
2228
2355
  let n = 0, heldBack = 0;
2356
+ // WHICH tools were switched on, not just how many: on a host that cannot refresh its list, the name is what lets
2357
+ // the agent call_tool immediately. 19 dead ends in 5 days were recorded against a reply that gave only a count.
2358
+ const enabledNames = [];
2229
2359
  for (const g of added) {
2230
2360
  active.add(g);
2231
2361
  for (const [name, grp] of Object.entries(groupOf)) {
@@ -2240,7 +2370,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
2240
2370
  // explanation is the [[prompt-rosters-go-stale]] failure — the agent concludes the capability is missing.
2241
2371
  if (toolHeldBackByConnectors(name, ctx.conn)) { heldBack++; continue; }
2242
2372
  if (toolHeldBackByDirectory(name, grp, ctx)) continue; // the cage holds across a group flip too
2243
- try { h.enable(); n++; } catch {}
2373
+ try { h.enable(); n++; enabledNames.push(name); } catch {}
2244
2374
  }
2245
2375
  }
2246
2376
  const enabled = TOOL_GROUP_NAMES.filter((g) => active.has(g));
@@ -2286,13 +2416,23 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
2286
2416
  // The agent took the route our own instructions name, on a host where it provably cannot show anything. That is
2287
2417
  // our guidance failing, not the agent, so it is recorded on our side of the board.
2288
2418
  if (fixedRoster && added.length) reportDeadEnd('enable_on_fixed_roster', 'enable_tools', `enable_tools(${added.join(',')}) on a host that fixed its tool list at connect — ${n} tool(s) switched on that this host cannot show`, { groups: added });
2289
- return ok(note, { enabled, added, toolsAdded: n, toolsAwaitingConnection: heldBack, note, rosterFixedForThisConnection: fixedRoster });
2419
+ // NAMED, because this reply's whole job is to say what to do when the tools do not appear — and on a fixed-roster
2420
+ // host they will not. Same advice as the prose above, keyed.
2421
+ const hints = [];
2422
+ if (fixedRoster && added.length) hints.push({ do: 'call_tool({name, args}) with any name in toolNames (or find_tools({query}) to search them) instead of waiting for the list to change', why: 'this host fixed its tool list when the connection was made, so the tools are switched on server-side but will not appear in this conversation' });
2423
+ if (heldBack) hints.push({ do: 'have the user connect the missing accounts (list_connectors shows what is linked, https://app.hermoso.ai/?connect=<provider> is the one-click link)', why: `${heldBack} tool${heldBack === 1 ? ' is' : 's are'} built and ready but not listed because their account is not connected in this workspace` });
2424
+ const NAMES_CAP = 60; // bounded: a reply an agent cannot read is no better than a count
2425
+ return withHints(ok(note, { enabled, added, toolsAdded: n, toolsAwaitingConnection: heldBack, note, rosterFixedForThisConnection: fixedRoster, ...(enabledNames.length ? { toolNames: enabledNames.slice(0, NAMES_CAP), toolNamesTruncated: Math.max(0, enabledNames.length - NAMES_CAP) } : {}) }), hints);
2290
2426
  };
2291
2427
 
2292
2428
  // The per-session scope every roster needs, whether it was built or replayed. ONE builder so the two paths cannot
2293
2429
  // drift into disagreeing about what a group is or which tools a host is denied.
2294
2430
  function newToolScope(opts) {
2295
- const asked = opts.only ? new Set(opts.only) : new Set(DEFAULT_TOOL_GROUPS);
2431
+ // AN EXPLICIT SCOPE ALWAYS WINS. `only` is what `?tools=`, `HERMOSO_TOOLS` and `/v1` pass; core-first is only
2432
+ // what an UNSTATED default resolves to, so a caller who named their groups gets exactly those and nothing here
2433
+ // narrows them. `coreFirst` is therefore false for every explicit scope, including `?tools=all`.
2434
+ const coreFirst = !opts.only && coreFirstRoster();
2435
+ const asked = opts.only ? new Set(opts.only) : new Set(defaultToolGroups());
2296
2436
  asked.add('core'); // discovery/credits/billing/jobs must exist in EVERY roster or the connection is unusable
2297
2437
  // `connectors` is `{connected:Set<provider>, readOk:boolean}` from the transport's own free read, or absent.
2298
2438
  // ABSENT AND `readOk:false` BEHAVE IDENTICALLY, and that is the fail-open law rather than a convenience:
@@ -2303,8 +2443,12 @@ function newToolScope(opts) {
2303
2443
  conn: opts.connectors || null,
2304
2444
  widgetHost: !!opts.widgetHost,
2305
2445
  directory: opts.directory === 'full' ? 'full' : (opts.directory ? 'scoped' : false),
2446
+ coreFirst,
2306
2447
  };
2307
2448
  }
2449
+ // A core-first roster carries the `core` group plus CORE_FIRST_EXTRA — see the block beside that constant.
2450
+ // Expressed as a predicate rather than as a branch inside applyToolGates so the check can RUN it.
2451
+ export const listedInCoreFirst = (name, group, ctx) => ctx.enabledGroups.has(group) || (!!ctx.coreFirst && CORE_FIRST_EXTRA.includes(name));
2308
2452
  // THE THREE REASONS A REGISTERED TOOL IS HELD BACK. Applied identically on the build path, the replay path and
2309
2453
  // `enable_tools`, from ONE function, because a gate applied at two of the three is a gate a group flip undoes.
2310
2454
  //
@@ -2319,7 +2463,7 @@ function newToolScope(opts) {
2319
2463
  // rather than the SDK's "unknown tool".
2320
2464
  function applyToolGates(h, name, group, ctx, opts) {
2321
2465
  if (!h) return;
2322
- if (!ctx.enabledGroups.has(group)) { try { h.disable(); } catch {} }
2466
+ if (!listedInCoreFirst(name, group, ctx)) { try { h.disable(); } catch {} }
2323
2467
  if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && opts.widgetHost) { try { h.disable(); } catch {} }
2324
2468
  if (toolHeldBackByConnectors(name, ctx.conn)) { try { h.disable(); } catch {} }
2325
2469
  if (toolHeldBackByDirectory(name, group, ctx)) { try { h.disable(); } catch {} } // (4) the directory cage — see DIRECTORY_GROUPS
@@ -2569,13 +2713,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
2569
2713
  // `notifications/tools/list_changed` on its own and a compliant client re-lists without being asked.
2570
2714
  server.registerTool('enable_tools', {
2571
2715
  title: 'Turn on more Hermoso tools',
2572
- description: "Switch on a group of tools that is not in this session's roster. WORKS ON CLIENTS THAT RE-READ THE TOOL LIST (stdio, the CLI); a host that fixed its roster at connect time — ChatGPT does — will not show the new tools until it reconnects, and this tool says so in its reply rather than reporting a success you cannot use. The connect-time route that always works is `?tools=all` on the server URL. "
2573
- + `The default roster is every group EXCEPT ${OPT_IN_TOOL_GROUPS.map((g) => '`' + g + '`').join(' and ')}, which are held out purely on SIZE: `
2574
- + "paid-campaign management is by far the largest group, most of the total schema weight across eleven ad platforms, "
2575
- + "and measurement is a third again on top of everything else. Most sessions need neither. Nothing in either is "
2576
- + "unfinished or unsafe — they are one call away. "
2577
- + "CALL THIS THE MOMENT YOU NEED ONE. If the user asks to build, budget, target, report on or change an ad "
2578
- + "campaign on any platform, call enable_tools({groups:['ads']}) first and the tools appear. If they ask about "
2716
+ description: "LIST a group of tools that is not in this session's roster. IT IS NOT HOW YOU REACH A TOOL — call_tool runs any Hermoso tool whether or not it is listed, and that works everywhere. Use this when the session will use MANY tools from one area and you want them in your list. WORKS ON CLIENTS THAT RE-READ THE TOOL LIST (stdio, the CLI, Cursor, Claude Code); a host that fixed its roster at connect time — claude.ai and ChatGPT do — will not show the new tools until it reconnects, and this tool says so in its reply rather than reporting a success you cannot use. The connect-time route that always works is `?tools=all` on the server URL. "
2717
+ + "The default roster is CORE-FIRST: the core tools plus a few that make the connection drivable. Every other "
2718
+ + "tool is held out of the LIST on SIZE alone — the whole registry is several hundred thousand tokens of schema "
2719
+ + "re-sent on every turn, and a roster far past the 30-50 tool mark measurably degrades tool choice. "
2720
+ + `The heaviest groups are ${OPT_IN_TOOL_GROUPS.map((g) => '`' + g + '`').join(', ')}: `
2721
+ + "paid-campaign management is most of the total schema weight across eleven ad platforms. NOTHING held out is "
2722
+ + "unfinished or unsafe, and nothing is unreachable — find_tools finds it and call_tool runs it. "
2723
+ + "CALL THIS WHEN A WHOLE AREA IS IN PLAY. If the user settles into building, budgeting, targeting or reporting on "
2724
+ + "ad campaigns, call enable_tools({groups:['ads']}) and the tools appear. If they ask about "
2579
2725
  + "their own site or product analytics, a tag/tracking container, or how a search engine crawls, indexes or "
2580
2726
  + "ranks their site, call enable_tools({groups:['analytics']}). "
2581
2727
  + `Groups: ${TOOL_GROUP_NAMES.join(', ')} — or 'all'. Free, instant, and it never turns anything off.`,
@@ -2724,7 +2870,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2724
2870
  for (const m of String(h.description || '').matchAll(/\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+\b|\b[a-z]+(?:[A-Z][a-z0-9]+)+\b/g)) if (m[0].length >= 6) set.add(sq(m[0]));
2725
2871
  _squashIdx.set(h, set); return set;
2726
2872
  };
2727
- const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12 } = {}) => {
2873
+ const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12, onlyHealthy = false } = {}) => {
2728
2874
  const q = String(query || '').toLowerCase().trim();
2729
2875
  const g = String(group || '').toLowerCase().trim();
2730
2876
  const cap = Math.max(1, Math.min(40, Number(limit) || 12));
@@ -2772,7 +2918,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
2772
2918
  if (words.length > 1 && nameHits === words.length) score += 2; // every word landed in the NAME: a phrase hit
2773
2919
  }
2774
2920
  const hold = toolHoldReason(name, ctx);
2775
- rows.push({ name, group: grp, score, inRoster: !!h.enabled, callable: !hold, hold, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
2921
+ const health = toolHealth(name);
2922
+ // A CALLER MAY ASK FOR ONLY THE WORKING ONES, AND NOTHING IS EVER HIDDEN UNLESS THEY DO. Monid hides an
2923
+ // endpoint in outage by default; we do not, because "Hermoso has no such tool" is the most expensive wrong
2924
+ // answer this product can give, and a hidden row is indistinguishable from an absent capability.
2925
+ if (onlyHealthy && (hold || health.state === 'failing')) continue;
2926
+ rows.push({ name, group: grp, score, inRoster: !!h.enabled, callable: !hold, hold, cost: costOf(name, grp, hold), health, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
2776
2927
  }
2777
2928
  // AN EXACT TOOL NAME OUTRANKS THE GROUP FILTER (2026-09-12). The name-shaped split above made a scoped search for a real
2778
2929
  // tool in the wrong group find its WORDS in-group (tiktok_creator_info in channels → post_to_tiktok), so `total` was no
@@ -2782,11 +2933,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
2782
2933
  for (const lit of new Set(q.split(/[\s,]+/).map((r) => r.replace(/[^a-z0-9_]/g, '')).filter((r) => r.includes('_')))) {
2783
2934
  const off = _offGroup.get(lit); if (!off) continue;
2784
2935
  const hold = toolHoldReason(lit, ctx);
2785
- rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, inRoster: !!off.h.enabled, callable: !hold, hold, title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
2936
+ rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, inRoster: !!off.h.enabled, callable: !hold, hold, cost: costOf(lit, off.grp, hold), health: toolHealth(lit), title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
2786
2937
  }
2787
2938
  }
2939
+ // ── WHAT IS SHOWN IS DECIDED BY RELEVANCE; THE ORDER WITHIN IT IS DECIDED BY HEALTH (2026-09-17) ───────────
2940
+ // A failing tool, or one this workspace cannot reach, is a worse pick than a working one and must sort last.
2941
+ // But `cap` truncates, so ranking a row last is the same thing as HIDING it once the list is longer than the
2942
+ // limit — and that is the one outcome this whole surface exists to prevent: measured on the first draft, a
2943
+ // search for "lead form" on a workspace with no Meta connection pushed create_meta_lead_form off the end and
2944
+ // the agent would have concluded the feature does not exist. So the two decisions are separated: the CAP is
2945
+ // taken by score, so the best answers are always shown whatever their health, and the penalty then reorders
2946
+ // what is shown. An exact name hit (score MAX_SAFE_INTEGER) carries no penalty at all — an agent that named a
2947
+ // tool outright gets it first, with its hold and its health printed beside it.
2788
2948
  rows.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name)); // ties: the shorter, more specific name first
2789
2949
  const total = rows.length, top = rows.slice(0, cap);
2950
+ for (const r of top) r._penalty = r.score === Number.MAX_SAFE_INTEGER ? 0 : healthPenalty(r.health, r.hold);
2951
+ top.sort((a, b) => a._penalty - b._penalty || b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name));
2790
2952
  // THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
2791
2953
  // not name. Unquoted and lowercased on purpose — the ledger collapses quoted strings to <q>, and one group per
2792
2954
  // distinct ask is exactly what we want to read.
@@ -2800,11 +2962,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
2800
2962
  }
2801
2963
  if (!total) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
2802
2964
  for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
2803
- const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
2965
+ const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
2804
2966
  const text = total
2805
- ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing ${cap} — narrow the query)` : ''}. Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first.\n${lines.join('\n')}`
2967
+ ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing ${cap} — narrow the query)` : ''}. Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first. COST is what the call spends (free means free on every plan); HEALTH is what this server has seen recently — "no recent calls" means we have not seen it run, not that it is broken, and a row marked FAILING or held is ranked last rather than hidden.\n${lines.join('\n')}`
2806
2968
  : `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
2807
- return { content: [{ type: 'text', text }], structuredContent: { total, tools: top.map(({ score, ...r }) => r) } };
2969
+ // THE NEXT STEP, NAMED. The text already ends "Run any of them with call_tool({name, args})"; this is the same
2970
+ // instruction with the actual name in it, plus the connect step when the best match is the one that is held.
2971
+ const best = top[0];
2972
+ const hints = [];
2973
+ if (best) {
2974
+ hints.push(best.hold
2975
+ ? { do: (holdHints(best.name, best.hold, ctx)[0] || {}).do || `resolve the ${best.hold} hold on ${best.name}`, why: `${best.name} is the best match and this workspace cannot run it yet` }
2976
+ : { do: `call_tool({ name: '${best.name}', args: { … } })`, why: `${best.name} is the best match${best.inRoster ? '' : ' and is not in your list, which does not stop it running'}${best.cost?.free ? ' and it is free' : ''}` });
2977
+ if (best.health?.state === 'failing') hints.push({ do: `consider the next row, or tell the user ${best.name} is currently failing`, why: `${best.failures || best.health.failures} of its last ${best.health.calls} calls on this server failed` });
2978
+ }
2979
+ return withHints({ content: [{ type: 'text', text }], structuredContent: { total, tools: top.map(({ score, _penalty, ...r }) => r) } }, hints);
2808
2980
  };
2809
2981
  const makeCallToolHandler = (ctx) => async ({ name, args } = {}, extra) => {
2810
2982
  const n = String(name || '').trim();
@@ -2821,7 +2993,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2821
2993
  if (why) {
2822
2994
  const t = holdReasonText(n, why, ctx);
2823
2995
  reportDeadEnd(why, n, t);
2824
- if (t) return { content: [{ type: 'text', text: t }], isError: true };
2996
+ if (t) return withHints({ content: [{ type: 'text', text: t }], isError: true }, holdHints(n, why, ctx));
2825
2997
  }
2826
2998
  let input = args && typeof args === 'object' ? args : {};
2827
2999
  if (h.inputSchema) {
@@ -2841,11 +3013,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
2841
3013
  };
2842
3014
  server.registerTool('find_tools', {
2843
3015
  title: 'Find a Hermoso tool by name or task',
2844
- description: 'Search EVERY Hermoso tool — including the paid-campaign (`ads`), `analytics` and `channel_admin` groups that are NOT in this session\'s starting list because of their size — by name, task or group, and get each one\'s parameters in one line. Use it the moment the user asks for something you do not see a tool for (a campaign, an ad set, a lead form, a click-to-WhatsApp ad, a report, keywords, audiences): a tool missing from your list is NEVER proof the feature is missing. Then run the tool with call_tool. Free, read-only.',
3016
+ description: 'Search EVERY Hermoso tool — your starting list is deliberately short, and everything else in the product is here — by name, task or group. Each row gives the tool\'s PARAMETERS in one line, its CREDIT COST (free means free on every plan; a tool that runs a model quotes the live per-model figure) and its recent HEALTH on this server (failure rate and typical duration, or "no recent calls", which means unseen and not broken). Use it the moment the user asks for something you do not see a tool for (a campaign, an ad set, a lead form, a click-to-WhatsApp ad, a report, keywords, audiences): a tool missing from your list is NEVER proof the feature is missing. Then run the tool with call_tool. A tool that is failing or needs a connector this workspace has not made is ranked last and marked, never hidden — pass onlyHealthy:true if you want those left out. Free, read-only.',
2845
3017
  inputSchema: {
2846
3018
  query: z.string().optional().describe('words from the task or the tool name, e.g. "lead form", "whatsapp", "google ads keyword", "meta insights"'),
2847
3019
  group: z.string().optional().describe(`limit to one group: ${TOOL_GROUP_NAMES.join(', ')}`),
2848
3020
  limit: z.number().optional().describe('how many to return (default 12, max 40)'),
3021
+ onlyHealthy: z.boolean().optional().describe('leave out tools that are failing their recent calls or that need a connector this workspace has not made. Default false — nothing is hidden unless you ask, because a missing row reads as a missing capability.'),
2849
3022
  },
2850
3023
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2851
3024
  }, sessionBound(makeFindToolsHandler, ctx));
@@ -18820,15 +18993,18 @@ function memoryNoteVerdict(text) {
18820
18993
  instruction: z.string().describe('the exact transformation to apply, in the user’s own words'),
18821
18994
  keepAudio: z.boolean().optional().describe('default true — keep the source clip’s audio track. Set false to return the edit silent'),
18822
18995
  elements: z.array(z.object({ frontal: z.string().describe('the reference image URL'), refs: z.array(z.string()).optional().describe('up to 2 extra angles of the SAME subject') }).passthrough()).optional().describe('OPTIONAL identity/product grounding (≤4): a creator portrait or the real product photo, so the edit restores the REAL thing instead of re-inventing it. Describe each one in the instruction. Leave out for a plain restyle'),
18996
+ interactionId: z.string().optional().describe('OPTIONAL: the interactionId an earlier Gemini Omni render or edit returned. The edit then continues that clip on the SAME Omni model from its own stored context (identity-true, no re-upload, usually cheaper). If that edit cannot run, the clip is edited by the video editor instead and the reply says so.'),
18823
18997
  },
18824
18998
  outputSchema: { ...JOB_OUT },
18825
18999
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
18826
- }, wrap(async ({ video, instruction, keepAudio, elements }) => {
19000
+ }, wrap(async ({ video, instruction, keepAudio, elements, interactionId }) => {
18827
19001
  const prompt = String(instruction || '').trim();
18828
19002
  if (!prompt) return { content: [{ type: 'text', text: 'Say what to change — edit_video needs an instruction.' }], isError: true };
18829
19003
  const els = (Array.isArray(elements) ? elements : []).filter(e => e && e.frontal).slice(0, 4);
18830
- const r = await renderJob('videoedit', { video, prompt, keepAudio: keepAudio !== false, ...(els.length ? { elements: els } : {}) }, `Video edit · ${prompt.slice(0, 40)}`);
18831
- return okVideo(`Edited clip: ${r.url}`, r);
19004
+ const _iid = String(interactionId || '').trim();
19005
+ const r = await renderJob('videoedit', { video, prompt, keepAudio: keepAudio !== false, ...(els.length ? { elements: els } : {}), ...(_iid ? { interactionId: _iid } : {}) }, `Video edit · ${prompt.slice(0, 40)}`);
19006
+ const _nextIid = renderPayload(r)?.interactionId;
19007
+ return okVideo(`Edited clip: ${r.url}${r.model ? ` (${r.model})` : ''}${_nextIid ? `\ninteractionId: ${_nextIid} (pass it to edit_video again to keep editing this clip)` : ''}${switchNote(r)}`, r);
18832
19008
  }));
18833
19009
 
18834
19010
  // AD MULTIPLIER (2026-09-01): ONE winning ad → N variants (new character / outfit / location / objects) with the edit, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.251",
3
+ "version": "0.1.252",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI ad studio and marketing MCP server with 841 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",