hermoso 0.1.194 → 0.1.195

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **748 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **741 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
@@ -116,8 +116,9 @@ the routes.
116
116
 
117
117
  ## Instant: the hosted Claude.ai connector
118
118
 
119
- Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
120
- your Hermoso account, done — the full toolset with your saved brand context, billed to your plan.
119
+ Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, pick
120
+ **Always required** when Claude asks about authentication (its detector suggests "None" because our discovery
121
+ handshake is open; "None" would leave every tool call unauthenticated), approve with your Hermoso account, done — the full toolset with your saved brand context, billed to your plan.
121
122
 
122
123
  ## Quickstart for Claude Code (one line)
123
124
 
@@ -170,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
170
171
 
171
172
  Then ask your agent: *“Generate an image ad with Hermoso.”*
172
173
 
173
- ### What the 748 tools cover
174
+ ### What the 741 tools cover
174
175
 
175
176
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
176
177
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/client.mjs CHANGED
@@ -99,6 +99,29 @@ export function reportToolError(tool, err) {
99
99
  } catch { /* an instrument never breaks the thing it measures */ }
100
100
  }
101
101
 
102
+ /**
103
+ * Report an AGENT DEAD END — the user's AI asked for something and could not reach it (no throw, no 4xx, so nothing
104
+ * else records it). `kind` must be one of the server's DEAD_END_KINDS; the server allowlists it and decides which
105
+ * side of the board it lands on. Same transport, same per-process cap and the same never-throw rule as above.
106
+ */
107
+ export function reportDeadEnd(kind, tool, detail, inputs) {
108
+ try {
109
+ if (_reportedThisProcess++ > 200) return;
110
+ fetch(`${API_BASE}/api/errors/report`, {
111
+ method: 'POST',
112
+ headers: headers(),
113
+ body: JSON.stringify({
114
+ op: String(tool || 'unknown').slice(0, 60),
115
+ errorClass: 'DeadEnd',
116
+ status: 0,
117
+ message: String(detail || kind).slice(0, 300),
118
+ deadEnd: String(kind || '').slice(0, 40),
119
+ ...(inputs && typeof inputs === 'object' ? { inputs } : {}),
120
+ }),
121
+ }).catch(() => {});
122
+ } catch { /* an instrument never breaks the thing it measures */ }
123
+ }
124
+
102
125
  export async function apiGet(p, query) {
103
126
  // URLSearchParams stringifies undefined/null as the LITERAL "undefined"/"null" — so an omitted optional param
104
127
  // arrives as a truthy string and silently changes server behaviour. Live 2026-07-27: list_google_ads_campaigns
@@ -247,7 +270,9 @@ export async function connectedProviders() {
247
270
  const r = await apiGet('/api/connectors/providers');
248
271
  const list = Array.isArray(r?.providers) ? r.providers : null;
249
272
  if (!list) return { connected: new Set(), readOk: false }; // a shape we do not recognise is a failed read
250
- return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true };
273
+ const offered = Array.isArray(r?.offered) ? new Set(r.offered.filter((p) => typeof p === 'string' && p)) : null; // null = server predates the field → fail open
274
+ const gated = r?.gated && typeof r.gated === 'object' ? r.gated : {};
275
+ return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true, offered, gated };
251
276
  } catch { return { connected: new Set(), readOk: false }; }
252
277
  }
253
278
  // Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
package/mcp/tools.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
5
5
  // needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
6
6
  import { z } from 'zod';
7
- import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, hostRendersWidgets, connectedProviders} from './client.mjs';
7
+ import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders} from './client.mjs';
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -14,7 +14,7 @@ import { wellFormedValue, wellFormedString } from './well-formed.mjs';
14
14
  // WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.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
- import { toolHeldBackByConnectors, toolProvider } from './roster-scope.mjs';
17
+ import { toolHeldBackByConnectors, toolProvider, toolUnoffered } from './roster-scope.mjs';
18
18
 
19
19
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
20
20
  const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
@@ -200,7 +200,7 @@ export const MCP_INSTRUCTIONS = [
200
200
  '• 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.',
201
201
  '• 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.',
202
202
  '• 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).',
203
- '• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_reddit, post_to_bluesky, post_to_telegram, post_to_google_business; schedule_post (+ list/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.',
203
+ '• 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.',
204
204
  // 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.
205
205
  '• 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.',
206
206
  '• 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.',
@@ -241,7 +241,7 @@ export const MCP_INSTRUCTIONS = [
241
241
  '• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
242
242
  '• 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.',
243
243
  '• 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.',
244
- '• 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; Reddit — post_to_reddit (ONE subreddit at a time; never repost the same content across communities), reddit_post_stats; 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.',
244
+ '• 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; never repost the same content across communities), reddit_post_stats; 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.',
245
245
  '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\'.',
246
246
  SHELL_ROUTE,
247
247
  '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.',
@@ -2000,6 +2000,83 @@ async function regateForWorkspace(ctx) {
2000
2000
 
2001
2001
  const sessionBound = (factory, ctx) => { const h = factory(ctx); try { h._hermosoFactory = factory; } catch {} return h; };
2002
2002
 
2003
+ // ── WHY A REGISTERED TOOL IS NOT CALLABLE HERE — one answer for find_tools, call_tool and a DIRECT call ──────────
2004
+ // Mirrors applyToolGates exactly. `null` means "held out of the list on SIZE only", which is never a reason to
2005
+ // refuse a call — only a reason not to carry the schema.
2006
+ export const holdReasonFor = (name, ctx) => {
2007
+ if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost) return 'host_policy';
2008
+ if (toolHeldBackByConnectors(name, ctx.conn)) {
2009
+ // NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Settings ▸ Connectors" is
2010
+ // right for a provider with a tile and a dead end for one without (Reddit posting: no tile until Reddit approves
2011
+ // the API; Reddit ADS are a separate, live connector). `offered` is null on a server that predates the field.
2012
+ const prov = toolProvider(name);
2013
+ if (prov && ctx.conn?.offered instanceof Set && !ctx.conn.offered.has(prov)) return 'not_offered';
2014
+ return 'not_connected';
2015
+ }
2016
+ if (toolHeldBackByDirectory(name, ctx.groupOf[name], ctx)) return 'directory';
2017
+ return null;
2018
+ };
2019
+ export const holdReasonText = (name, why, ctx = null) => {
2020
+ if (why === 'not_offered') {
2021
+ const prov = toolProvider(name) || 'that';
2022
+ const because = ctx?.conn?.gated?.[prov] ? ` (${ctx.conn.gated[prov]})` : '';
2023
+ const reddit = prov === 'reddit' ? ' Reddit ADS are available through the reddit_ads tools; organic Reddit posting is not.' : '';
2024
+ return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Settings ▸ Connectors and do not offer this capability.${reddit}`;
2025
+ }
2026
+ if (why === 'host_policy') return `${name} is not offered on this host (the host's own commerce policy). Use the Hermoso app or another client for it.`;
2027
+ if (why === 'not_connected') return `${name} needs the "${toolProvider(name)}" connection and this workspace has not made it. Connect it under Settings ▸ Connectors in the Hermoso app, then call again.`;
2028
+ if (why === 'directory') return `${name} is outside what this Claude directory connection may run. Use the Hermoso app, or connect the unscoped server URL.`;
2029
+ return null;
2030
+ };
2031
+
2032
+ // ── A DIRECT tools/call TO A DISABLED TOOL GETS AN ANSWER, NOT "Tool X disabled" (2026-09-03) ──────────────────
2033
+ // MEASURED IN CHATGPT after the passthrough shipped: its roster was fetched at install time and still listed tools
2034
+ // the session had since disabled, and a call to one came back as the SDK's bare `Tool list_meta_pages disabled`
2035
+ // — the model read that as "Hermoso cannot do this" and stopped. The SDK refuses BEFORE any handler of ours runs,
2036
+ // so the answer has to be installed one layer down: the low-level Server's tools/call entry is wrapped, and a call
2037
+ // to a disabled handle is answered by the SAME rule as call_tool — RUN it when the only hold is size (a stale
2038
+ // roster that still names the tool is exactly the caller this is for), REFUSE it by name with the way out when a
2039
+ // real gate holds. Everything else falls through to the SDK untouched. Installed on both the build and the replay
2040
+ // path from ONE function, because a gate answered on one path and not the other is the drift replayTools was
2041
+ // written to prevent.
2042
+ export function installHeldToolCalls(mcp, ctx) {
2043
+ try {
2044
+ const low = mcp && mcp.server;
2045
+ const map = low && low._requestHandlers;
2046
+ if (!map || typeof map.get !== 'function') return false;
2047
+ const orig = map.get('tools/call');
2048
+ if (typeof orig !== 'function' || orig._hermosoHeldWrap) return false;
2049
+ const wrapped = async (request, extra) => {
2050
+ const name = String(request?.params?.name || '');
2051
+ const h = name && ctx.handleOf[name];
2052
+ if (h && h.enabled === false) {
2053
+ const why = holdReasonFor(name, ctx);
2054
+ if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return { content: [{ type: 'text', text: t }], isError: true }; }
2055
+ // The call itself is the evidence: this host's tool list still names a tool the session holds out on size,
2056
+ // i.e. the host is serving a stale roster. Run it (that is the point) and record that it happened.
2057
+ 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`);
2058
+ const fn = h.handler || h.callback;
2059
+ if (typeof fn === 'function') {
2060
+ let input = request?.params?.arguments && typeof request.params.arguments === 'object' ? request.params.arguments : {};
2061
+ if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
2062
+ const parsed = h.inputSchema.safeParse(input);
2063
+ if (!parsed.success) {
2064
+ const issues = (parsed.error?.issues || []).slice(0, 8).map((i) => `${(i.path || []).join('.') || '(root)'}: ${i.message}`).join('; ');
2065
+ return { content: [{ type: 'text', text: `Arguments for ${name} did not validate — ${issues}.` }], isError: true };
2066
+ }
2067
+ input = parsed.data;
2068
+ }
2069
+ return h.inputSchema ? await fn(input, extra) : await fn(extra);
2070
+ }
2071
+ }
2072
+ return orig(request, extra);
2073
+ };
2074
+ wrapped._hermosoHeldWrap = true;
2075
+ map.set('tools/call', wrapped);
2076
+ return true;
2077
+ } catch { return false; }
2078
+ }
2079
+
2003
2080
  // The `enable_tools` handler, lifted to module scope so it can be re-bound to each session's own context.
2004
2081
  const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
2005
2082
  const want = (Array.isArray(groups) ? groups : []).map((g) => String(g || '').trim().toLowerCase()).filter(Boolean);
@@ -2073,6 +2150,9 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
2073
2150
  : fixedRoster
2074
2151
  ? `Switched on ${added.join(', ')} server-side — but THIS host fixed its tool list when the connection was made and will not pick up the ${n} new tool${n === 1 ? '' : 's'} until it reconnects, so do not expect to see them in this conversation. To use them, ${route}${held} Active groups: ${enabled.join(', ')}.`
2075
2152
  : `Switched on ${added.join(', ')} — ${n} more tool${n === 1 ? '' : 's'} are callable now. If they do not appear your client has cached its tool list, in which case ${route}${held} Active groups: ${enabled.join(', ')}.`);
2153
+ // The agent took the route our own instructions name, on a host where it provably cannot show anything. That is
2154
+ // our guidance failing, not the agent, so it is recorded on our side of the board.
2155
+ 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 });
2076
2156
  return ok(note, { enabled, added, toolsAdded: n, toolsAwaitingConnection: heldBack, note, rosterFixedForThisConnection: fixedRoster });
2077
2157
  };
2078
2158
 
@@ -2124,6 +2204,7 @@ function replayTools(rawServer, opts, canon) {
2124
2204
  ctx.handleOf[e.name] = h;
2125
2205
  applyToolGates(h, e.name, e.group, ctx, opts);
2126
2206
  }
2207
+ installHeldToolCalls(rawServer, ctx);
2127
2208
  return rawServer;
2128
2209
  }
2129
2210
 
@@ -2272,6 +2353,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
2272
2353
  // Doing it at the registry means it is true for all 436 tools by construction — there is no per-tool line to
2273
2354
  // forget, and a tool added tomorrow inherits it. try/catch because a frozen handler must not break registration.
2274
2355
  if (p === 'registerTool') return (name, def, handler) => {
2356
+ // NEVER REGISTERED, NOT MERELY DISABLED (2026-09-03): a provider in UNOFFERED_PROVIDERS has no roster row,
2357
+ // no canon entry, no find_tools hit and no count. An inert handle keeps the 7 call sites below unchanged.
2358
+ if (toolUnoffered(name)) return { enabled: false, enable() {}, disable() {}, remove() {}, update() {} };
2275
2359
  groupOf[name] = group;
2276
2360
  try { if (handler) handler._hermosoTool = name; } catch {}
2277
2361
  // TITLE IS MIRRORED INTO `annotations`, and it is deliberately a DUPLICATE rather than a move.
@@ -2385,12 +2469,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2385
2469
  // do is widen a gate: a tool withheld by host policy, by the directory cage or because its provider is not
2386
2470
  // connected is refused BY NAME with the way out, never run. Only the SIZE hold-out is bypassed, because size was
2387
2471
  // never a reason to refuse a call — only a reason not to carry the schema.
2388
- const toolHoldReason = (name, ctx) => {
2389
- if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost) return 'host_policy';
2390
- if (toolHeldBackByConnectors(name, ctx.conn)) return 'not_connected';
2391
- if (toolHeldBackByDirectory(name, ctx.groupOf[name], ctx)) return 'directory';
2392
- return null;
2393
- };
2472
+ const toolHoldReason = holdReasonFor; // module-level: the SAME function answers a direct call to a disabled tool
2394
2473
  // One line per parameter, from the tool's own zod schema — enough for a model to write the call, a fraction
2395
2474
  // of the JSON Schema a tools/list would carry for the same tool.
2396
2475
  const compactParams = (h) => {
@@ -2405,12 +2484,99 @@ function buildTools(rawServer, opts = {}, sink = null) {
2405
2484
  }));
2406
2485
  } catch { return {}; }
2407
2486
  };
2487
+ // ── QUERY SELF-HEAL (2026-09-03) ───────────────────────────────────────────────────────────────────────────────
2488
+ // The ledger's `no_match` dead ends are what users ask for IN THEIR OWN WORDS, and the first real one was a user
2489
+ // saying "instant forms" for what our catalog calls create_meta_lead_form. A search that only matches literal
2490
+ // substrings turns every such phrasing into a dead end, so the query is widened BEFORE it is scored: marketing
2491
+ // synonyms (fb → facebook/meta, leadgen → lead form, tweet → x), plural/-ing/-ed stems (campaigns → campaign),
2492
+ // and a one-edit typo tolerance on tool-name tokens (campain → campaign). The miss is still recorded when it
2493
+ // happens — that row is how this table grows — but the common phrasings never reach it. Deterministic and free
2494
+ // on purpose: it runs on stdio and the CLI with no server round trip and no model in the loop.
2495
+ const TOOL_SYNONYMS = Object.freeze({
2496
+ fb: ['facebook', 'meta'], facebook: ['meta'], ig: ['instagram'], insta: ['instagram'], gram: ['instagram'], instagram: ['meta'],
2497
+ wa: ['whatsapp'], ctwa: ['whatsapp'], whatsapp: ['whatsapp'], messenger: ['meta', 'message'],
2498
+ leadgen: ['lead', 'form'], lead: ['lead', 'form'], instant: ['lead', 'form'], form: ['lead', 'form'], signup: ['lead', 'form'],
2499
+ analytics: ['insights', 'report'], stats: ['insights', 'metrics', 'report'], stat: ['insights', 'metrics'], metric: ['insights', 'report'],
2500
+ performance: ['insights', 'report', 'performance'], report: ['insights', 'report'], insight: ['insights', 'report'], result: ['insights', 'report', 'performance'],
2501
+ budget: ['budget', 'spend'], spend: ['budget', 'spend', 'insights'], cost: ['credits', 'cost', 'budget', 'capabilities'], ad: ['ad', 'ads'], ads: ['ads', 'ad'], price: ['credits', 'cost', 'capabilities'], pricing: ['credits', 'capabilities'],
2502
+ publish: ['post'], share: ['post'], tweet: ['x', 'post'], twitter: ['x'], yt: ['youtube'], gbp: ['business'], gmb: ['business'], maps: ['business'],
2503
+ remove: ['delete'], trash: ['delete'], cancel: ['delete', 'cancel'], pause: ['status'], unpause: ['status'], resume: ['status'], activate: ['status'], deactivate: ['status'], stop: ['status', 'cancel'], start: ['status'], turn: ['status'],
2504
+ edit: ['update', 'edit'], change: ['update', 'set'], modify: ['update'], rename: ['update'], adjust: ['update', 'set'],
2505
+ campaign: ['campaign', 'ads'], adset: ['adset'], advert: ['ad', 'ads'], advertising: ['ads'], advertise: ['ads', 'campaign'], ppc: ['ads', 'google'], sem: ['google', 'ads'], promote: ['ads', 'campaign', 'promoted'],
2506
+ audience: ['audience', 'targeting'], targeting: ['targeting', 'audience'], retarget: ['audience'], lookalike: ['audience'], demographic: ['targeting', 'insights'],
2507
+ clip: ['video', 'clip'], reel: ['video', 'instagram'], short: ['video', 'youtube'], film: ['video'], movie: ['video'], footage: ['video'], thumb: ['thumbnail'], cover: ['thumbnail'],
2508
+ picture: ['image'], photo: ['image', 'product'], pic: ['image'], creative: ['image', 'video', 'ad', 'render'], banner: ['image'], visual: ['image'], graphic: ['image'], packshot: ['product', 'image'],
2509
+ voiceover: ['voice'], narration: ['voice'], dub: ['dub', 'voice'], translate: ['dub'], language: ['dub', 'settings'], spanish: ['dub'], french: ['dub'], german: ['dub'], subtitle: ['caption', 'dub'],
2510
+ competitor: ['competitor'], rival: ['competitor'], spy: ['competitor', 'research', 'search'], research: ['research', 'competitor', 'search'], benchmark: ['competitor', 'insights'],
2511
+ schedule: ['schedule'], queue: ['schedule'], calendar: ['schedule'], later: ['schedule'], slot: ['schedule'],
2512
+ comment: ['comment'], reply: ['reply', 'comment'], respond: ['reply'], dm: ['message', 'inbox', 'convo'], message: ['message', 'inbox'], inbox: ['inbox'], mention: ['mention'],
2513
+ review: ['review'], rating: ['review'], testimonial: ['review'],
2514
+ team: ['team', 'member'], teammate: ['member'], seat: ['member'], colleague: ['member'], invite: ['invite', 'member'],
2515
+ workspace: ['brand'], client: ['brand'], company: ['brand'], business: ['brand', 'business'], account: ['account', 'brand', 'connector'],
2516
+ connect: ['connector'], connection: ['connector'], integration: ['connector'], integrate: ['connector'], link: ['connector'], oauth: ['connector'], login: ['connector'],
2517
+ credit: ['credits'], billing: ['billing', 'credits'], plan: ['plan', 'billing'], subscription: ['plan', 'billing'], upgrade: ['plan', 'upgrade'], invoice: ['billing'], receipt: ['billing'], topup: ['credits', 'buy'], refill: ['credits', 'refill'],
2518
+ memory: ['memory', 'remember'], note: ['memory', 'remember'], skill: ['skill'], playbook: ['playbook'], swipe: ['swipefile'], inspiration: ['swipefile', 'research'], saved: ['swipefile', 'library'], library: ['library'],
2519
+ pin: ['pinterest'], board: ['pinterest', 'board'], subreddit: ['reddit'], sub: ['reddit'],
2520
+ merchant: ['merchant', 'shopify'], product: ['product'], catalog: ['product', 'merchant', 'capabilities'], store: ['shopify', 'merchant'], shop: ['shopify', 'merchant'], ecommerce: ['shopify', 'merchant'],
2521
+ keyword: ['keyword'], seo: ['keyword', 'search'], hashtag: ['hashtag', 'search'], trend: ['search', 'research'], viral: ['search', 'research'],
2522
+ avatar: ['avatar', 'creator'], actor: ['creator', 'avatar'], spokesperson: ['creator', 'avatar'], ugc: ['avatar', 'creator', 'ad'], influencer: ['creator', 'search'],
2523
+ explainer: ['explainer'], tutorial: ['explainer'], faceless: ['explainer'], sizzle: ['sizzle', 'product'], mockup: ['template'], template: ['template'],
2524
+ job: ['job'], status: ['job', 'status'], progress: ['job'], download: ['fetch', 'asset'], file: ['upload', 'asset', 'drive'], asset: ['asset', 'upload'],
2525
+ sheet: ['sheet'], spreadsheet: ['sheet'], excel: ['sheet'], doc: ['doc'], document: ['doc'], drive: ['drive'], onedrive: ['onedrive'], sharepoint: ['onedrive'],
2526
+ error: ['error'], bug: ['bug', 'error'], broken: ['error', 'bug'], issue: ['error', 'bug'], feedback: ['feature', 'bug'], suggestion: ['feature'],
2527
+ policy: ['policy'], compliance: ['policy'], approved: ['policy'], rejected: ['policy'],
2528
+ telegram: ['telegram'], bluesky: ['bluesky'], threads: ['threads'], tiktok: ['tiktok'], youtube: ['youtube'], linkedin: ['linkedin'], reddit: ['reddit'], pinterest: ['pinterest'], microsoft: ['microsoft'], bing: ['microsoft'], openai: ['openai'], chatgpt: ['openai'],
2529
+ setting: ['settings'], preference: ['settings'], theme: ['settings'], dark: ['settings'],
2530
+ });
2531
+ const stemWord = (w) => {
2532
+ if (w.length <= 3) return w;
2533
+ if (w.endsWith('ies')) return w.slice(0, -3) + 'y';
2534
+ if (w.endsWith('sses') || w.endsWith('shes') || w.endsWith('ches') || w.endsWith('xes')) return w.slice(0, -2);
2535
+ if (w.endsWith('ing') && w.length > 5) return w.slice(0, -3);
2536
+ if (w.endsWith('ed') && w.length > 4) return w.slice(0, -2);
2537
+ if (w.endsWith('s') && !w.endsWith('ss')) return w.slice(0, -1);
2538
+ return w;
2539
+ };
2540
+ // One insertion, deletion or substitution — enough for "campain", "budjet", "instgram"; never for short words,
2541
+ // where one edit is a different word ("ad" vs "ads" is handled by the stem, not by this).
2542
+ const withinOneEdit = (a, b) => {
2543
+ if (a === b) return true;
2544
+ if (Math.abs(a.length - b.length) > 1 || a.length < 5) return false;
2545
+ let i = 0, j = 0, edits = 0;
2546
+ while (i < a.length && j < b.length) {
2547
+ if (a[i] === b[j]) { i++; j++; continue; }
2548
+ if (++edits > 1) return false;
2549
+ if (a.length > b.length) i++; else if (b.length > a.length) j++; else { i++; j++; }
2550
+ }
2551
+ return edits + (a.length - i) + (b.length - j) <= 1;
2552
+ };
2553
+ const QUERY_STOPWORDS = new Set(['a', 'an', 'the', 'my', 'our', 'your', 'to', 'for', 'of', 'in', 'on', 'at', 'and', 'or', 'how', 'much', 'many', 'does', 'do', 'i', 'me', 'we', 'us', 'with', 'from', 'is', 'are', 'it', 'this', 'that', 'please', 'can', 'could', 'you', 'want', 'need', 'some', 'any', 'all', 'up', 'out', 'into', 'about', 'what', 'which', 'who', 'when', 'where', 'tool', 'tools', 'hermoso', 'using', 'use', 'via']);
2554
+ // A token that is in hundreds of names (ad, ads, meta, list, create) tells the search almost nothing, so a hit on
2555
+ // it is worth less than a hit on a rare one (dub, whatsapp, thumbnail). Document frequency, computed once per roster.
2556
+ const tokenWeightFor = (ctx) => {
2557
+ if (ctx._tokenDf === undefined) { const df = new Map(); for (const n of Object.keys(ctx.handleOf)) for (const t of new Set(n.split('_'))) df.set(t, (df.get(t) || 0) + 1); ctx._tokenDf = df; }
2558
+ return (tok) => { const df = ctx._tokenDf.get(tok) || 0; return df > 60 ? 0.4 : df > 25 ? 0.6 : df > 10 ? 0.8 : 1; };
2559
+ };
2560
+ // A plural or -ed token (posts, languages) is weighed as its STEM when the stem is itself a common token, or
2561
+ // "tweet" lands on backfill_posts (posts: rare) above post_to_x (post: everywhere; x: rare).
2562
+ const stemAwareWeight = (ctx, weight, t, a) => {
2563
+ const df = ctx._tokenDf; return Math.min(weight(t), df && df.has(a) ? weight(a) : 1);
2564
+ };
2565
+ const expandQueryWord = (raw) => {
2566
+ const w = raw.toLowerCase().replace(/[^a-z0-9_]/g, '');
2567
+ if (!w || QUERY_STOPWORDS.has(w)) return null;
2568
+ if (w.includes('_')) return { w, st: w, alts: [w], literal: true }; // a tool name (or part of one) typed as-is
2569
+ const st = stemWord(w);
2570
+ const alts = new Set([w, st, ...(TOOL_SYNONYMS[w] || []), ...(TOOL_SYNONYMS[st] || [])]);
2571
+ return { w, st, alts: [...alts] };
2572
+ };
2408
2573
  const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12 } = {}) => {
2409
2574
  const q = String(query || '').toLowerCase().trim();
2410
2575
  const g = String(group || '').toLowerCase().trim();
2411
2576
  const cap = Math.max(1, Math.min(40, Number(limit) || 12));
2412
2577
  if (g && !TOOL_GROUP_NAMES.includes(g)) return { content: [{ type: 'text', text: `Unknown group "${g}". Groups: ${TOOL_GROUP_NAMES.join(', ')}.` }], isError: true };
2413
2578
  const rows = [];
2579
+ const tokenWeight = tokenWeightFor(ctx);
2414
2580
  for (const [name, h] of Object.entries(ctx.handleOf)) {
2415
2581
  if (!h) continue;
2416
2582
  const grp = ctx.groupOf[name] || 'core';
@@ -2418,15 +2584,37 @@ function buildTools(rawServer, opts = {}, sink = null) {
2418
2584
  const desc = String(h.description || '');
2419
2585
  let score = 0;
2420
2586
  if (q) {
2421
- const words = q.split(/[\s,]+/).filter(Boolean);
2422
- for (const w of words) { if (name.includes(w)) score += 3; else if (desc.toLowerCase().includes(w)) score += 1; else if (grp.includes(w)) score += 1; }
2587
+ const words = q.split(/[\s,]+/).map(expandQueryWord).filter(Boolean);
2588
+ const descLc = desc.toLowerCase();
2589
+ const nameTokens = name.split('_');
2590
+ let nameHits = 0, covered = 0;
2591
+ for (const { w, alts, literal } of words) {
2592
+ if (literal) { if (name.includes(w)) { score += 5; nameHits++; covered++; } continue; } // "find_tools" typed as-is
2593
+ const exactTok = nameTokens.find((t) => t === w);
2594
+ if (exactTok) { score += 4 * tokenWeight(exactTok); nameHits++; covered++; continue; } // the word IS a name token
2595
+ // the best-weighted synonym/stem that is a name token — "tweet" must land on post_to_x's `x` (rare), not its `post` (everywhere)
2596
+ let synBest = 0;
2597
+ for (const t of nameTokens) for (const a of alts) if (a !== w && (t === a || (a.length >= 4 && t.startsWith(a) && t.length - a.length <= 2))) synBest = Math.max(synBest, 3 * stemAwareWeight(ctx, tokenWeight, t, a));
2598
+ if (synBest) { score += synBest; nameHits++; covered++; continue; }
2599
+ if (w.length >= 4 && name.includes(w)) { score += 2; nameHits++; covered++; continue; } // the literal word inside a name token
2600
+ const typoTok = nameTokens.find((t) => withinOneEdit(w, t));
2601
+ if (typoTok) { score += 2 * tokenWeight(typoTok); nameHits++; covered++; continue; } // a typo of a name token
2602
+ if (alts.some((a) => a.length >= 3 && descLc.includes(a))) { score += 1; covered++; continue; } // any form in the description
2603
+ if (alts.some((a) => grp.includes(a))) { score += 1; covered++; }
2604
+ }
2423
2605
  if (!score) continue;
2606
+ if (words.length > 1) score += covered; // coverage: a tool that answers MORE of the words outranks one that answers one of them loudly
2607
+ if (words.length > 1 && nameHits === words.length) score += 2; // every word landed in the NAME: a phrase hit
2424
2608
  }
2425
2609
  const hold = toolHoldReason(name, ctx);
2426
2610
  rows.push({ name, group: grp, score, inRoster: !!h.enabled, callable: !hold, hold, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
2427
2611
  }
2428
- rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
2612
+ 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
2429
2613
  const total = rows.length, top = rows.slice(0, cap);
2614
+ // THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
2615
+ // not name. Unquoted and lowercased on purpose — the ledger collapses quoted strings to <q>, and one group per
2616
+ // distinct ask is exactly what we want to read.
2617
+ 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 });
2430
2618
  for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
2431
2619
  const lines = top.map((r) => `• ${r.name} [${r.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)'}`);
2432
2620
  const text = total
@@ -2439,10 +2627,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
2439
2627
  const h = ctx.handleOf[n];
2440
2628
  if (!n || !h) {
2441
2629
  const near = Object.keys(ctx.handleOf).filter((k) => n && (k.includes(n) || n.includes(k.split('_')[1] || ' '))).slice(0, 6);
2630
+ reportDeadEnd('unknown_tool', 'call_tool', `call_tool asked for a tool that does not exist: ${n.replace(/["'`]/g, '').slice(0, 60) || '(empty)'}`, { name: n });
2442
2631
  return { content: [{ type: 'text', text: `No tool named "${n}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} find_tools({query}) searches every tool by name or task.` }], isError: true };
2443
2632
  }
2444
2633
  if (n === 'call_tool' || n === 'find_tools' || n === 'enable_tools') return { content: [{ type: 'text', text: `${n} is a roster tool; call it directly.` }], isError: true };
2445
2634
  const why = toolHoldReason(n, ctx);
2635
+ if (why) reportDeadEnd(why, n, holdReasonText(n, why, ctx));
2636
+ if (why === 'not_offered') return { content: [{ type: 'text', text: holdReasonText(n, why, ctx) }], isError: true };
2446
2637
  if (why === 'host_policy') return { content: [{ type: 'text', text: `${n} is not offered on this host (the host's own commerce policy). Use the Hermoso app or another client for it.` }], isError: true };
2447
2638
  if (why === 'not_connected') return { content: [{ type: 'text', text: `${n} needs the "${toolProvider(n)}" connection and this workspace has not made it. Connect it under Settings ▸ Connectors in the Hermoso app, then call again.` }], isError: true };
2448
2639
  if (why === 'directory') return { content: [{ type: 'text', text: `${n} is outside what this Claude directory connection may run. Use the Hermoso app, or connect the unscoped server URL.` }], isError: true };
@@ -4430,123 +4621,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
4430
4621
  // subreddits", and an agent told only "you can post to Reddit" will happily fan one ad out to eight communities
4431
4622
  // and get the user's account banned.
4432
4623
  server.group('channels');
4433
- server.registerTool('post_to_reddit', {
4434
- title: 'Post to a subreddit',
4435
- description: 'Submit a post to ONE named subreddit as the user’s connected Reddit account — a text post, a link post, or a native image post (pass a Hermoso render URL as imageUrl). This PUBLISHES immediately and PUBLICLY under their username, so show the user the exact subreddit, title and body and get an explicit yes BEFORE calling. REDDIT IS NOT A BROADCAST CHANNEL: it punishes undisclosed self-promotion harder than any other platform, and posting the same or near-identical content to several subreddits breaks Reddit’s own developer policy and gets accounts banned. Post to ONE subreddit, written for that specific community — if the user asks to blast several, tell them this instead of doing it. Subreddits that require post flair are detected before anything is posted and the error lists the valid flairs to pass as flairId. Needs Reddit connected (Settings ▸ Connectors ▸ Reddit).',
4436
- inputSchema: {
4437
- account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one reddit account connected (several and none named is refused by name, never guessed); omit when there is one."),
4438
- ...HOOK_ATTR,
4439
- subreddit: z.string().describe('the ONE subreddit to post to, e.g. "SideProject" (an r/ prefix is fine)'),
4440
- title: z.string().describe('post title, max 300 characters'),
4441
- kind: z.enum(['self', 'link', 'image']).optional().describe('"self" = text post (default), "link" = share a url, "image" = native image upload. Inferred from what you pass if omitted.'),
4442
- text: z.string().optional().describe('body markdown for a text post'),
4443
- url: z.string().optional().describe('the destination url for a link post'),
4444
- imageUrl: z.string().optional().describe('a Hermoso render image URL for a native image post (or an upload_file url)'),
4445
- flairId: z.string().optional().describe('flair template id — required by some subreddits; the error names the valid ones'),
4446
- flairText: z.string().optional().describe('flair text, only where that flair is editable'),
4447
- nsfw: z.boolean().optional(),
4448
- spoiler: z.boolean().optional(),
4449
- resubmit: z.boolean().optional().describe('post a link Reddit says was already submitted — usually reads as spam, so confirm first'),
4450
- },
4451
- outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), subreddit: z.string().optional(), id: z.string().nullable().optional(), fullname: z.string().nullable().optional(), url: z.string().nullable().optional(), pending: z.boolean().optional() },
4452
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4453
- }, wrap(async (a) => {
4454
- const d = await apiPost('/api/reddit/post', a);
4455
- if (d.pending) return ok(`Reddit accepted the image post to r/${d.subreddit} and is still finishing it — no link came back inside the wait. It is almost certainly up: check the profile rather than posting it again.`, d);
4456
- return ok(`Posted to r/${d.subreddit}${d.url ? ` — ${d.url}` : ''}.`, d);
4457
- }));
4458
4624
  server.group('channel_admin');
4459
- server.registerTool('reddit_post_stats', {
4460
- title: 'How a Reddit post did',
4461
- description: 'Read one of the connected account’s Reddit posts back — score (net upvotes), comment count, upvote ratio, flair, and whether the subreddit removed it. Use it for "how did that post do" or to judge which framing a community actually rewarded before writing the next one. Read-only, 0 credits. Needs Reddit connected.',
4462
- inputSchema: { postId: z.string().describe('the id returned by post_to_reddit, its t3_… fullname, or the full reddit.com permalink') },
4463
- outputSchema: { id: z.string().optional(), fullname: z.string().optional(), title: z.string().optional(), subreddit: z.string().nullable().optional(), score: z.number().nullable().optional(), comments: z.number().nullable().optional(), upvoteRatio: z.number().nullable().optional(), url: z.string().nullable().optional(), flair: z.string().nullable().optional(), removed: z.boolean().optional(), postedAt: z.string().nullable().optional() },
4464
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4465
- }, wrap(async (a) => {
4466
- const d = await apiGet('/api/reddit/post-stats', { postId: a.postId });
4467
- return ok(`“${d.title}” in ${d.subreddit || 'that subreddit'}: ${d.score ?? '?'} score, ${d.comments ?? '?'} comments${d.upvoteRatio != null ? `, ${Math.round(d.upvoteRatio * 100)}% upvoted` : ''}${d.removed ? ' — REMOVED by the subreddit' : ''}.`, d);
4468
- }));
4469
4625
  // ── OPERATING A REDDIT POST AFTER IT IS SUBMITTED (2026-08-05). We could submit and read the score, and nothing
4470
4626
  // else — no list, no edit, no delete, and not one comment. On the platform whose entire value IS the thread that
4471
4627
  // is the worst version of the gap. Every endpoint behind these uses a scope this connector ALREADY requests
4472
4628
  // (`edit`, `submit`, `read`, `history`), so nobody reconnects.
4473
- server.registerTool('list_reddit_posts', {
4474
- title: 'The connected Reddit account’s own posts',
4475
- description: 'The connected Reddit account’s OWN submissions — id, title, subreddit, score, comment count, whether the subreddit removed it, and whether its body can be edited at all. THIS IS WHERE THE postId EVERY OTHER REDDIT TOOL NEEDS COMES FROM: post_to_reddit returns an id only at the instant it publishes, so an agent that did not itself just post had no way to name a post and had to ask the user for a link. Read-only, 0 credits. Needs Reddit connected.',
4476
- inputSchema: {
4477
- limit: z.number().optional().describe('1–100, default 25'),
4478
- sort: z.enum(['new', 'hot', 'top', 'controversial']).optional().describe('default new'),
4479
- cursor: z.string().optional().describe('the cursor a previous call returned'),
4480
- },
4481
- outputSchema: { username: z.string().optional(), count: z.number().optional(), posts: z.array(z.any()).optional(), cursor: z.string().nullable().optional() },
4482
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4483
- }, wrap(async (a) => {
4484
- const d = await apiGet('/api/reddit/posts', { ...(a.limit ? { limit: a.limit } : {}), ...(a.sort ? { sort: a.sort } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) });
4485
- if (!d.posts?.length) return ok(`u/${d.username} has no submissions Reddit will return.`, d);
4486
- return ok(`${d.count} post(s) by u/${d.username}:\n${d.posts.map(p => `• “${p.title}” (id ${p.id}) in ${p.subreddit} — ${p.score ?? '?'} score, ${p.comments ?? '?'} comments${p.editable ? '' : ' — LINK post, body not editable'}${p.removed ? ' — REMOVED' : ''}`).join('\n')}`, d);
4487
- }));
4488
4629
  // EDIT — the body, on a self post, and nothing else. Reddit's own endpoint is documented as editing "the body
4489
4630
  // text of a comment or self-post"; a LINK post is refused outright, and `title` appears on exactly one endpoint in
4490
4631
  // Reddit's entire API (creation), so a published title is frozen for everyone. Both refusals are stated up front
4491
4632
  // here rather than discovered as a 403, because an agent told an edit is possible will promise it to a user.
4492
- server.registerTool('edit_reddit_post', {
4493
- title: 'Edit a Reddit text post’s body',
4494
- description: 'Rewrite the BODY of one of the connected account’s Reddit TEXT posts — the fix for a dead link, a wrong price or a correction the comments are asking for. THREE THINGS REDDIT DOES NOT ALLOW, and you must not offer them: (1) a post’s TITLE can never be changed by any API — `title` exists only on Reddit’s submit endpoint, so a published title is frozen for every client, not just this one; (2) a LINK post cannot be edited at all — Reddit documents this endpoint as editing "the body text of a comment or self-post" and refuses a link post; (3) a post that has already been deleted cannot be edited. In each case the only remedy is to delete and submit again, which loses the score, the age and the whole comment thread — say that plainly instead of implying an edit is possible. The result is READ BACK from Reddit, so an accepted edit that did not apply is reported as NOT confirmed rather than narrated as done. 0 credits. Needs Reddit connected.',
4495
- inputSchema: {
4496
- postId: z.string().describe('the post id, its t3_… fullname, or the permalink (list_reddit_posts returns them)'),
4497
- text: z.string().describe('the new body markdown — this REPLACES the existing body'),
4498
- },
4499
- outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), subreddit: z.string().nullable().optional(), title: z.string().optional(), url: z.string().nullable().optional(), applied: z.boolean().nullable().optional(), note: z.string().optional() },
4500
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
4501
- }, wrap(async (a) => {
4502
- const d = await apiPost('/api/reddit/edit', { postId: a.postId, text: a.text });
4503
- return ok(d.note, d);
4504
- }));
4505
4633
  // DELETE. THE REASON THE READ-BACK IS NOT OPTIONAL HERE: Reddit's /api/del answers `{}` with HTTP 200 no matter
4506
4634
  // what — a wrong id, someone ELSE'S post and a real delete are byte-identical responses, and there is no error to
4507
4635
  // catch. So ownership is resolved before the gate (server-side) and the verdict comes from re-reading the post.
4508
- server.registerTool('delete_reddit_post', {
4509
- title: 'Delete a Reddit post',
4510
- description: 'PERMANENTLY delete one of the connected account’s Reddit posts. Reddit has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real title, subreddit, score and comment count read back from Reddit. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (its exact title) once it has comments or a real score, because confirming that you meant to delete SOMETHING does not prove you aimed at the right post. TELL THE USER THIS BEFORE THEY AGREE: deleting a Reddit post does NOT delete the comments under it — Reddit keeps the thread and shows the post as [deleted], so the conversation stays public with only their side removed. Reddit’s delete endpoint returns an empty success for every call, including one aimed at a post the account did not write, so the verdict here comes from re-reading the post afterwards and never from that response. 0 credits. Needs Reddit connected.',
4511
- inputSchema: {
4512
- postId: z.string().describe('the post id, its t3_… fullname, or the permalink'),
4513
- confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent'),
4514
- confirmName: z.string().optional().describe('the post’s EXACT title as the unconfirmed call reported it — required once it has comments or a real score'),
4515
- },
4516
- outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), title: z.string().optional(), subreddit: z.string().nullable().optional(), deleted: z.boolean().optional(), verdict: z.string().optional(), url: z.string().nullable().optional(), note: z.string().optional() },
4517
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
4518
- }, wrap(async (a) => {
4519
- const d = await apiPost('/api/reddit/delete', { postId: a.postId, confirm: a.confirm === true, ...(a.confirmName != null ? { confirmName: a.confirmName } : {}) });
4520
- return ok(d.note, d);
4521
- }));
4522
- server.registerTool('list_reddit_comments', {
4523
- title: 'Comments on a Reddit post',
4524
- description: 'Read the comments under one of the connected account’s Reddit posts — author, text, score, whether it is the poster’s own reply, and when. On Reddit the thread IS the value of a post, and this is where the questions, objections and exact customer wording live: the same raw material for ad copy that list_meta_comments and list_youtube_comments give you on the other channels, from the audience that argues back hardest. Each row carries the fullname to pass to reply_to_reddit_comment. Read-only, 0 credits. Needs Reddit connected.',
4525
- inputSchema: {
4526
- postId: z.string().describe('the post id, its t3_… fullname, or the permalink'),
4527
- limit: z.number().optional().describe('1–100, default 25'),
4528
- sort: z.enum(['top', 'new', 'confidence', 'controversial', 'old', 'qa']).optional().describe('default top'),
4529
- },
4530
- outputSchema: { postId: z.string().optional(), title: z.string().optional(), subreddit: z.string().nullable().optional(), total: z.number().nullable().optional(), count: z.number().optional(), comments: z.array(z.any()).optional() },
4531
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4532
- }, wrap(async (a) => {
4533
- const d = await apiGet('/api/reddit/comments', { postId: a.postId, ...(a.limit ? { limit: a.limit } : {}), ...(a.sort ? { sort: a.sort } : {}) });
4534
- if (!d.comments?.length) return ok(`No comments on “${d.title || d.postId}” yet.`, d);
4535
- return ok(`${d.count} of ${d.total ?? d.count} comment(s) on “${d.title}”:\n${d.comments.map(c => `• u/${c.author}${c.isOp ? ' (the poster)' : ''} — ${c.score ?? '?'} — ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 220)} [reply with parentId ${c.fullname}]`).join('\n')}`, d);
4536
- }));
4537
- server.registerTool('reply_to_reddit_comment', {
4538
- title: 'Reply on Reddit',
4539
- description: 'Reply on Reddit as the connected account — either a top-level comment on a post, or a reply to somebody’s comment. This publishes PUBLICLY under their username immediately, so show the user the exact wording and get an explicit yes BEFORE calling. Reddit judges brands harder on how they behave in comments than on what they post: answer the actual question, in plain language, and do not paste marketing copy — an account that does gets buried and can get the whole domain banned from the subreddit. parentId is a FULLNAME, not a bare id: t3_… replies to a POST (a new top-level comment), t1_… replies to a COMMENT. list_reddit_comments returns the right one on every row. 0 credits. Needs Reddit connected.',
4540
- inputSchema: {
4541
- parentId: z.string().describe('t3_… fullname of a post (top-level comment) or t1_… fullname of a comment (a reply to it)'),
4542
- text: z.string().describe('the reply markdown'),
4543
- },
4544
- outputSchema: { ok: z.boolean().optional(), id: z.string().nullable().optional(), fullname: z.string().nullable().optional(), parentId: z.string().optional(), url: z.string().nullable().optional(), text: z.string().optional() },
4545
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4546
- }, wrap(async (a) => {
4547
- const d = await apiPost('/api/reddit/reply', { parentId: a.parentId, text: a.text });
4548
- return ok(`Replied on Reddit${d.url ? ` — ${d.url}` : ''}.`, d);
4549
- }));
4550
4636
  // ── PINTEREST (2026-07-30). Two tools on purpose: the board is a REQUIRED, user-owned choice, and a Pin on the
4551
4637
  // wrong board is a public mistake that cannot be quietly undone.
4552
4638
  server.group('channels');
@@ -16838,7 +16924,11 @@ function memoryNoteVerdict(text) {
16838
16924
  },
16839
16925
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
16840
16926
  }, wrap(async ({ query, limit }) => {
16841
- const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'top' });
16927
+ // `sort: 'relevance'` IS THE QUERY (measured live 2026-09-03): the ad-data provider's Reddit search honours the
16928
+ // query ONLY under relevance — `top` and `new` return the site-wide top/new feeds and ignore it entirely, so this
16929
+ // tool had answered "seedance prompt help" with a 2020 election megathread since it was written. The server's
16930
+ // own three call sites always said relevance; this one said top. Pinned by tools/reddit-search-sort-check.mjs.
16931
+ const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'relevance' });
16842
16932
  const all = (d.posts || d.results || []).map((p) => qp({
16843
16933
  desc: trunc([p.title, p.selftext].filter(Boolean).join(' — '), 260),
16844
16934
  subreddit: p.subreddit ? `r/${p.subreddit}` : '', upvotes: p.ups ?? p.score, comments: p.num_comments,
@@ -17565,4 +17655,5 @@ function memoryNoteVerdict(text) {
17565
17655
  const d = await apiPost('/api/posts/backfill', { channel: a.channel, ...(a.confirm ? { confirm: true } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.accountRef ? { accountRef: a.accountRef } : {}) });
17566
17656
  return ok(d.note || `${d.dryRun ? 'Dry run' : 'Imported'} on ${a.channel}.`, d);
17567
17657
  }));
17658
+ installHeldToolCalls(rawServer, ctx);
17568
17659
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.194",
3
+ "version": "0.1.195",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 748 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.",
5
+ "description": "AI ad studio and marketing MCP server with 741 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"