hermoso 0.1.248 → 0.1.250

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
- **839 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **841 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,
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
171
171
 
172
172
  Then ask your agent: *“Generate an image ad with Hermoso.”*
173
173
 
174
- ### What the 839 tools cover
174
+ ### What the 841 tools cover
175
175
 
176
176
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
177
177
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/http.mjs CHANGED
@@ -106,6 +106,8 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
106
106
  // past that a GET is answered 503 + Retry-After, which costs the client a reconnect and costs tool calls nothing
107
107
  // (they are POSTs and never touch this counter). Pinned by tools/mcp-get-stream-cap-check.mjs.
108
108
  const GET_STREAM_MS = Math.max(1000, Number(process.env.MCP_GET_STREAM_MS || 5 * 60e3));
109
+ const MCP_CALL_WALL_MS = Number(process.env.MCP_CALL_WALL_MS) || 10 * 60 * 1000; // a tools/call still open after this is ended, so a ghost cannot pin a deploy or a slot
110
+ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [body]; return msgs.map(m => m?.method === 'tools/call' ? String(m.params?.name || '?').slice(0, 60) : '').filter(Boolean).join(',') || '?'; };
109
111
  const GET_STREAM_MAX = Math.max(1, Number(process.env.MCP_GET_STREAM_MAX || 64));
110
112
  let openGetStreams = 0;
111
113
  const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS });
@@ -258,7 +260,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
258
260
  // scope to and nothing honest to read — and a registry crawler or an agent deciding whether to connect MUST
259
261
  // see the real catalog, not a zero-connector one. registerTools treats an absent `connectors` exactly like a
260
262
  // failed read: full roster. Do not "fix" this by reading the workspace off the request; it is forgeable.
261
- registerTools(server, { only: scope?.groups, directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
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
262
264
  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 {} }
263
265
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
264
266
  res.on('close', () => { try { transport.close(); server.close(); } catch {} });
@@ -331,7 +333,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
331
333
  // connectedProviders() ([[failed-read-is-not-empty]]).
332
334
  const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
333
335
  const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
334
- registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
336
+ registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
335
337
  const transport = new StreamableHTTPServerTransport({
336
338
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
337
339
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
@@ -386,7 +388,18 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
386
388
  // forgeable value is exactly the hole resolveWs exists to close. The workspace a hosted connector acts in is
387
389
  // pinned SERVER-SIDE on the agent key (use_brand → /api/keys/brand, membership-checked) and re-authorized by
388
390
  // resolveWs on every request, so it resolves identically here and over stdio without this transport naming it.
389
- await mcpCtx.run({ token, remote: true, client: entry.client || '' }, () => entry.transport.handleRequest(req, res, req.body));
391
+ // A TOOLS/CALL HAS A WALL CLOCK (2026-09-15). A POST /mcp tools/call:search_meta_ads sat "open" for 20 minutes —
392
+ // long past every upstream timeout in the tool — and blocked deploy-safe-check for as long, because the inflight
393
+ // row is released only when THIS response finishes. Whatever left it hanging (a client that vanished mid-stream,
394
+ // a promise that never settled), the response must end. MCP_CALL_WALL_MS ends it; the SDK's own error path has
395
+ // already had every chance by then, so the client sees a closed stream, which is what it would have concluded
396
+ // anyway. Never armed for initialize/list, which answer in milliseconds; cleared the moment the call settles.
397
+ const wall = methodsOf(req.body).includes('tools/call')
398
+ ? setTimeout(() => { if (res.writableEnded) return; console.error(`[mcp] tools/call open past ${MCP_CALL_WALL_MS}ms — ending the response (${inflightNameOf(req.body)})`); try { res.end(); } catch {} }, MCP_CALL_WALL_MS)
399
+ : null;
400
+ if (wall && typeof wall.unref === 'function') wall.unref();
401
+ try { await mcpCtx.run({ token, remote: true, client: entry.client || '' }, () => entry.transport.handleRequest(req, res, req.body)); }
402
+ finally { if (wall) clearTimeout(wall); }
390
403
  });
391
404
 
392
405
  console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
@@ -64,7 +64,7 @@ export const NEVER_GATE = new Set([
64
64
  // the whole ads family they are paying for.
65
65
  export const TOOL_PROVIDER_RULES = [
66
66
  // ── ads platforms that are their OWN connection (must precede the posting rules below) ──
67
- [/_tiktok_ads_|^tiktok_ads_/, 'tiktok_ads'],
67
+ [/_tiktok_ads_|^tiktok_ads_|_tiktok_smart_|^tiktok_bid_protection$/, 'tiktok_ads'], // Smart+ is the ads connection too (2026-09-17)
68
68
  [/_x_ads_|^x_ads_/, 'x_ads'],
69
69
  [/_pinterest_ads_|^pinterest_ads_/, 'pinterest_ads'],
70
70
  [/_reddit_ads_|^reddit_ads_/, 'reddit_ads'],
package/mcp/tools.mjs CHANGED
@@ -230,6 +230,8 @@ export const MCP_INSTRUCTIONS = [
230
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.',
231
231
  '• 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
232
  '• 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
+ // 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.
234
+ 'NOTHING SET UP YET? research_ads on any domain, or generate_image with useBrand:false, need no brand, account or upload.',
233
235
  // ── YOUR ROSTER IS NOT THE PRODUCT (2026-08-26) ──────────────────────────────────────────────────────────────
234
236
  // The roster is scoped to the accounts this workspace has connected, because a tool for an unconnected provider
235
237
  // can only answer 401. That is a saving, and it has ONE failure mode, which this line exists to prevent: an
@@ -549,6 +551,14 @@ const refWatchedLine = (w) => {
549
551
  // never. Declared ONCE and spread into every publish tool's inputSchema — hand-listing the pair at ten seams is
550
552
  // exactly how SCHED_ID_FIELDS drifted on four of them.
551
553
  const HOOK_ATTR = {
554
+ // WHICH BRAND THIS POST BELONGS TO, NAMED ON THE CALL (2026-09-16). It rides in HOOK_ATTR because that bundle is
555
+ // spread into exactly the ten publish/schedule tools and nothing else, so one edit reaches every one of them and a
556
+ // new publish tool inherits it. First real-user feedback on the connector was that the connection had been pinned
557
+ // to the WRONG brand at the start — a pin is connection state, set once and then invisible, so everything after it
558
+ // is silently attributed to it. Naming the brand per call is the fix, and it is the most specific statement of
559
+ // intent there is, so the server lets it beat the key pin, the workspace header and the active-brand default — for
560
+ // that one request only, which is what makes a one-off post to a second client safe.
561
+ brand: z.string().optional().describe('WHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.'),
552
562
  hook: z.string().optional().describe('WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.'),
553
563
  subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.'),
554
564
  };
@@ -1829,7 +1839,9 @@ export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
1829
1839
  // the old figure divided in the ratio the two halves actually weigh (32.9% / 67.1%, measured on the wire schema
1830
1840
  // `z.toJSONSchema` emits), rather than re-measured by a different method — every other row here came from the
1831
1841
  // 2026-08-20 run, and mixing methodologies inside one table would make the rows incomparable.
1832
- export const TOOL_GROUP_TOKENS = { core: 4300, research: 5400, create: 19700, channels: 29500, channel_admin: 61700, analytics: 32600, files: 10300, workspace: 7500, ads: 221100 };
1842
+ // RE-MEASURED 2026-09-17, every row at once, by tools/tool-group-truth-check.mjs §8's own method (name + description +
1843
+ // inputSchema JSON at 4 chars/token, net of core) — `create` had reached 1.32× its row and four more sat past 1.2×.
1844
+ export const TOOL_GROUP_TOKENS = { core: 4300, research: 6600, create: 26000, channels: 37100, channel_admin: 66300, analytics: 39100, files: 10600, workspace: 9200, ads: 246700 };
1833
1845
 
1834
1846
  // THE DEFAULT ROSTER IS EVERYTHING EXCEPT `ads` AND `analytics`. Paid-campaign management across eleven platforms
1835
1847
  // is ~236K tokens on its own — more than everything else put together — because each platform carries a full
@@ -2059,6 +2071,7 @@ export function defForHost(name, def, widgetHost) {
2059
2071
  // declares itself with `sessionBound()` and `tools/mcp-tool-canon-check.mjs` fails any handler that reaches for
2060
2072
  // session state without it.
2061
2073
  let TOOL_CANON = null; // [{ name, group, def, handler, factory }] — the one canonical roster, built on first use
2074
+ let CANON_HOSTED = false; // which surface TOOL_CANON was built for — see registerTools
2062
2075
 
2063
2076
  // Declare a handler that MUST be rebuilt per session. `factory(ctx)` receives {enabledGroups, groupOf, handleOf}.
2064
2077
  // ── SWITCHING BRAND MUST RE-GATE THE ROSTER (2026-09-01) ────────────────────────────────────────────────────────
@@ -2424,12 +2437,18 @@ const LI_TARGETING_FACETS = ['LANGUAGE','LOCATION','AUDIENCE','AGE','GENDER','CO
2424
2437
 
2425
2438
  export function registerTools(rawServer, opts = {}) {
2426
2439
  const server = wellFormedServer(rawServer); // the ONE crossing — see above
2427
- // The RETURN VALUE stays exactly what it was (the raw server on replay, buildTools' own answer on a build): the
2428
- // proxy is a registration-time device and must never leak out as the thing a caller holds.
2440
+ // THE CACHE HOLDS SCHEMAS, SO A FLAG THAT CHANGES A SCHEMA MUST INVALIDATE IT (2026-09-16). `hosted` decides
2441
+ // whether upload_file offers a local `path` at all, and the canon is built once and replayed for every later
2442
+ // registration — so a canon built on one surface would hand the other surface the wrong schema for the rest of
2443
+ // the process. Today the flag is constant per process (the HTTP server always passes it, the stdio twin never
2444
+ // does), which is exactly the kind of "true for now" that a future per-request flag breaks silently. Rebuild
2445
+ // rather than replay when it differs; `only`/`directory`/`widgetHost` do NOT need this, because they filter and
2446
+ // decorate a roster rather than changing the definitions the canon stores.
2447
+ if (TOOL_CANON && CANON_HOSTED !== !!opts.hosted) TOOL_CANON = null;
2429
2448
  if (TOOL_CANON) { replayTools(server, opts, TOOL_CANON); return rawServer; }
2430
2449
  const sink = [];
2431
2450
  const r = buildTools(server, opts, sink);
2432
- TOOL_CANON = sink; // published only after a COMPLETE run — a throw mid-build must not cache a truncated roster
2451
+ TOOL_CANON = sink; CANON_HOSTED = !!opts.hosted; // published only after a COMPLETE run — a throw mid-build must not cache a truncated roster
2433
2452
  return r;
2434
2453
  }
2435
2454
 
@@ -2692,6 +2711,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
2692
2711
  const alts = new Set([w, st, ...(TOOL_SYNONYMS[w] || []), ...(TOOL_SYNONYMS[st] || [])]);
2693
2712
  return { w, st, alts: [...alts] };
2694
2713
  };
2714
+ // A FIELD NAME IS AN ASK TOO (2026-09-17, from the defect board). Agents search the parameter they are looking for,
2715
+ // flattened: "primaryforgoal" (create_google_ads_conversion_action's primaryForGoal) and "targetcontentnetwork"
2716
+ // (Google's target_content_network, named in set_google_ads_networks) both filed no_match. Every tool's parameter
2717
+ // names and the snake_case identifiers its description quotes are indexed with case and underscores squashed.
2718
+ const _squashIdx = new WeakMap();
2719
+ const squashedIdentifiers = (h) => {
2720
+ let set = _squashIdx.get(h); if (set) return set;
2721
+ set = new Set();
2722
+ const sq = (x) => String(x).toLowerCase().replace(/[^a-z0-9]/g, '');
2723
+ try { for (const k of Object.keys(h.inputSchema?.shape || {})) if (k.length >= 6) set.add(sq(k)); } catch {}
2724
+ 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
+ _squashIdx.set(h, set); return set;
2726
+ };
2695
2727
  const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12 } = {}) => {
2696
2728
  const q = String(query || '').toLowerCase().trim();
2697
2729
  const g = String(group || '').toLowerCase().trim();
@@ -2715,6 +2747,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
2715
2747
  // update_meta_ad, edit_meta): kept as one literal token it matched nothing and filed a dead end, while its parts
2716
2748
  // (meta + campaigns, update + meta) name real tools. The literal still scores first when it exists.
2717
2749
  const words = q.split(/[\s,]+/).flatMap((r) => (r.includes('_') ? [r, ...r.split('_').filter((p) => p.length >= 2)] : [r])).map(expandQueryWord).filter(Boolean);
2750
+ const _sqIds = squashedIdentifiers(h);
2751
+ const _fieldHits = q.split(/[\s,]+/).map((r) => r.toLowerCase().replace(/[^a-z0-9]/g, '')).filter((r) => r.length >= 6 && _sqIds.has(r)).length;
2752
+ if (_fieldHits) score += 4 * _fieldHits;
2718
2753
  const descLc = desc.toLowerCase();
2719
2754
  const nameTokens = name.split('_');
2720
2755
  let nameHits = 0, covered = 0;
@@ -3994,16 +4029,34 @@ function buildTools(rawServer, opts = {}, sink = null) {
3994
4029
  const EXT_MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4' };
3995
4030
  server.registerTool('upload_file', {
3996
4031
  title: 'Upload a local file → durable public URL',
3997
- description: 'Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user\'s desktop, an agency\'s finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), `path` (a local file — ONLY when Hermoso runs on the user\'s own machine over stdio/CLI; the hosted connector cannot see their disk), or `dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file\'s RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.',
4032
+ description: 'Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user\'s desktop, an agency\'s finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), ' + (opts.hosted ? '' : '`path` (a local file, ONLY when Hermoso runs on the user\'s own machine over stdio/CLI; the hosted connector cannot see their disk), or ') + '`dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file\'s RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.',
3998
4033
  inputSchema: {
4034
+ // THE THIRD SOURCE, AND THE ONLY ONE THAT CARRIES BYTES WITHOUT SPENDING THEM AS TOKENS (2026-09-16). Asked
4035
+ // for by the first real user of the connector: `path` cannot work on a hosted surface and a data: URI puts
4036
+ // the whole file through the model's context. This asks for a one-time PUT url instead — the bytes go
4037
+ // straight to Hermoso over ordinary HTTP and never touch this conversation.
4038
+ getUploadUrl: z.boolean().optional().describe('ASK FOR A ONE-TIME UPLOAD URL instead of uploading now — use this whenever the file is on the user\u2019s machine and you can run a shell or an HTTP request. Returns a uploadUrl you PUT the raw bytes to (any HTTP client), which answers with the durable Hermoso url. It beats `dataUri` for anything but a small image: a data: URI spends the whole file as tokens in this conversation. One file per url, and it expires.'),
3999
4039
  url: z.string().optional().describe('a PUBLIC http(s) URL Hermoso fetches server-side (private/internal addresses are refused, and every redirect hop is re-checked). Works on every surface including the hosted connector, and the bytes never cross this connection — prefer this whenever the file is reachable on the web.'),
4000
- path: z.string().optional().describe('local filesystem path (stdio/CLI only — refused on the hosted connector)'),
4040
+ // NOT OFFERED ON THE HOSTED CONNECTOR (2026-09-16, a customer's agent after scheduling a month of posts: "Hide
4041
+ // the local file path option on the hosted connector. It's advertised in the tool description but always
4042
+ // refused there, which sends the agent down a dead end before it finds the real answer"). The prose said
4043
+ // stdio-only and the PARAMETER was still in the schema, so a model reasonably tried it, got refused, and only
4044
+ // then went looking. A surface that can never honour an argument should not publish it.
4045
+ ...(opts.hosted ? {} : { path: z.string().optional().describe('local filesystem path (stdio/CLI only — refused on the hosted connector)') }),
4001
4046
  dataUri: z.string().optional().describe('base64 data: URI of the file bytes (data:<mime>;base64,<…>) — bytes travel over this connection, so keep it small'),
4002
4047
  name: z.string().optional().describe('original file name — helps pick the right extension'),
4003
4048
  },
4004
- outputSchema: { url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional() },
4049
+ outputSchema: { url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional(), uploadUrl: z.string().optional().describe('the one-time PUT url, when getUploadUrl was asked for'), expiresAt: z.string().optional(), maxBytes: z.number().optional() },
4005
4050
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4006
4051
  }, wrap(async (a) => {
4052
+ // THE TICKET BRANCH RUNS FIRST AND ALONE: it is a request for a url, not an upload, so a source passed beside
4053
+ // it is a caller with two different intentions and picking one would upload a file they did not mean to send.
4054
+ if (a.getUploadUrl) {
4055
+ const conflict = ['url', 'path', 'dataUri'].filter(k => String(a[k] || '').trim());
4056
+ if (conflict.length) throw new Error(`\`getUploadUrl\` asks for a link to send bytes to; \`${conflict.join('` and `')}\` is a file to upload right now. Do one or the other.`);
4057
+ const t = await apiPost('/api/upload/ticket', {});
4058
+ return ok(`PUT the file's raw bytes to this url and it answers with the durable Hermoso url:\n\n${t.uploadUrl}\n\n${t.howto}`, { uploadUrl: t.uploadUrl, expiresAt: t.expiresAt, maxBytes: t.maxBytes });
4059
+ }
4007
4060
  // EXACTLY ONE SOURCE. Two is an ERROR: a caller who passes both has two different files in mind, and quietly
4008
4061
  // preferring one of them ingests the wrong file and reports success.
4009
4062
  const given = ['url', 'path', 'dataUri'].filter(k => String(a[k] || '').trim());
@@ -4268,8 +4321,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
4268
4321
  target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account'),
4269
4322
  account: z.string().optional().describe('WHICH Instagram account when target is instagram and the brand has several — Page-linked and Instagram Login accounts alike; an @username or id from list_connector_accounts("instagram"). Several and none named is refused by name; omit when there is one.'),
4270
4323
  scheduleAt: z.string().optional().describe('FACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately.'),
4271
- locationId: z.string().optional().describe('Threads only — a place id from search_threads_locations, to geotag the post to a physical location (restaurant, storefront)'),
4324
+ locationId: z.string().optional().describe('TAG A PLACE. THREADS: a place id from search_threads_locations. INSTAGRAM: the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.'),
4325
+ // ── THE FACEBOOK PAGE FEED, SAME 2026-09-16 AUDIT ──────────────────────────────────────────────────────────
4326
+ // Meta's /{page-id}/feed table lists ~24 parameters and we sent four. `call_to_action` was the instructive
4327
+ // one: its value builder already existed in this codebase, inside the ADS creative path, and the organic
4328
+ // post next door could not use it.
4329
+ audience: z.object({ countries: z.array(z.string()).optional().describe('two-letter codes, e.g. ["CA","US"]'), regions: z.array(z.string()).optional().describe('Meta location keys for regions/states'), cities: z.array(z.string()).optional().describe('Meta location keys for cities'), minAge: z.union([z.literal(13), z.literal(15), z.literal(18), z.literal(21), z.literal(25)]).optional() }).optional().describe('FACEBOOK PAGE POST ONLY — limit who can see this organic post to people in these places and/or over this age (Facebook allows 13, 15, 18, 21 or 25). Not on Instagram, Threads or a Facebook Reel (a vertical clip becomes a Reel): those are refused. Region and city keys come from the Meta ads location search.'),
4330
+ place: z.string().optional().describe('FACEBOOK — tag a location on the Page post. The NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.'),
4331
+ callToAction: z.enum(['BOOK_TRAVEL', 'BUY_NOW', 'CALL_NOW', 'DOWNLOAD', 'GET_DIRECTIONS', 'LEARN_MORE', 'LIKE_PAGE', 'MESSAGE_PAGE', 'NO_BUTTON', 'OPEN_LINK', 'SHOP_NOW', 'SIGN_UP', 'WATCH_MORE']).optional().describe('FACEBOOK — a real call-to-action BUTTON on the Page post. The types that open a destination (SHOP_NOW, LEARN_MORE, SIGN_UP, BUY_NOW, DOWNLOAD, OPEN_LINK, WATCH_MORE, BOOK_TRAVEL) need somewhere to go: the post’s own `link`, or `callToActionLink`. CALL_NOW, MESSAGE_PAGE, LIKE_PAGE and GET_DIRECTIONS act on the Page itself and take none.'),
4332
+ callToActionLink: z.string().optional().describe('FACEBOOK — where the button goes, when that is not the post’s own `link`.'),
4333
+ linkName: z.string().optional().describe('FACEBOOK — override the HEADLINE of the link preview. Without it the post shows whatever Open Graph title the destination carries, which on a bare landing page is often nothing. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.'),
4334
+ linkDescription: z.string().optional().describe('FACEBOOK — override the DESCRIPTION of the link preview. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.'),
4335
+ linkPicture: z.string().optional().describe('FACEBOOK — override the IMAGE of the link preview: a public http(s) url Facebook fetches itself. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.'),
4336
+ // ── THE SEVEN FROM THE 2026-09-16 PARAMETER AUDIT ──────────────────────────────────────────────────────────
4337
+ // Meta's table for POST /{ig-user-id}/media lists 21 parameters; we sent 13 and had never looked at these,
4338
+ // because every sweep we ran diffed PATHS or TOOLS and not one of them is a missing endpoint. Each is refused
4339
+ // BY NAME when it cannot apply (a cover on an image post, any of them on a story) rather than dropped.
4340
+ coverUrl: z.string().optional().describe('INSTAGRAM REEL COVER — a public image url Instagram fetches and uses as the cover in the Reels tab. REELS ONLY, and the alternative to `thumbOffset`: passing both is refused, since they are two answers to the same question. Run a local file through upload_file first.'),
4341
+ thumbOffset: z.number().optional().describe('INSTAGRAM REEL COVER, the other way — which frame becomes the cover, in MILLISECONDS from the start of the video. REELS ONLY. Use it instead of `coverUrl` when the right cover is already a frame of the clip.'),
4342
+ shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.'),
4343
+ audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
4344
+ paidPartnership: z.boolean().optional().describe('INSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION, the same kind Hermoso already carries for TikTok and X: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship.'),
4345
+ brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
4272
4346
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.'),
4347
+ story: z.boolean().optional().describe('INSTAGRAM STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.'),
4273
4348
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
4274
4349
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order \u2014 that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta\u2019s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY \u2014 Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta\u2019s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user\u2019s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.)'),
4275
4350
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
@@ -4299,7 +4374,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4299
4374
  // THE COLLAB LINE IS THE READ-BACK, NEVER THE ASK. `collaboratorNote` is built from what Instagram said
4300
4375
  // about each invite; printing "posted with @x" off the request would tell the user their post is live on
4301
4376
  // an account that has not accepted it — and may never.
4302
- return ok(`Published ${d.carousel ? `a ${d.slides}-slide CAROUSEL` : ''} to ${d.account || d.page || d.target}${d.url ? ` — ${d.url}` : ''} (post ${d.postId}).${d.collaboratorNote ? ` ${d.collaboratorNote}` : ''}`, d);
4377
+ return ok(`Published ${d.carousel ? `a ${d.slides}-slide CAROUSEL ` : a.story ? 'a 24-hour STORY ' : ''}to ${d.account || d.page || d.target}${d.url ? ` — ${d.url}` : ''} (post ${d.postId}).${d.collaboratorNote ? ` ${d.collaboratorNote}` : ''}`, d);
4303
4378
  }));
4304
4379
  // ── SCHEDULING (2026-07-30). ONE mechanism for every channel — our durable queue, not a per-platform special case.
4305
4380
  // Dave: "if only Facebook can do scheduling, then maybe we just do all the scheduling ourselves. There's probably
@@ -4380,12 +4455,35 @@ function buildTools(rawServer, opts = {}, sink = null) {
4380
4455
  // reachable when you publish NOW, unreachable when you schedule, and invisible until the parity sweep named it.
4381
4456
  xQuotePostId: z.string().optional().describe('X — the numeric id of an X post this one QUOTES: the last part of its URL. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS. Billed at X’s higher LINK rate.'),
4382
4457
  communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it.'),
4383
- paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.'),
4458
+ paidPartnership: z.boolean().optional().describe('INSTAGRAM AND X — the PAID PARTNERSHIP label, a compliance declaration: set it when the post is sponsored, gifted or otherwise paid for. OPT-IN ONLY, never assume it on the user’s behalf. On Instagram, brandedContentSponsorIds names the brands behind it.'),
4384
4459
  // AN X ARTICLE, SCHEDULED (2026-09-12). A mode of the x channel, not a channel: the X text is the markdown body
4385
4460
  // and the image is the cover, so the only new fields are the two the Article itself adds.
4386
4461
  xArticle: z.object({ title: z.string(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried.'),
4387
4462
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
4463
+ // ── THE FACEBOOK PAGE FEED, SAME 2026-09-16 AUDIT ──────────────────────────────────────────────────────────
4464
+ // Meta's /{page-id}/feed table lists ~24 parameters and we sent four. `call_to_action` was the instructive
4465
+ // one: its value builder already existed in this codebase, inside the ADS creative path, and the organic
4466
+ // post next door could not use it.
4467
+ audience: z.object({ countries: z.array(z.string()).optional().describe('two-letter codes, e.g. ["CA","US"]'), regions: z.array(z.string()).optional().describe('Meta location keys for regions/states'), cities: z.array(z.string()).optional().describe('Meta location keys for cities'), minAge: z.union([z.literal(13), z.literal(15), z.literal(18), z.literal(21), z.literal(25)]).optional() }).optional().describe('FACEBOOK PAGE POST ONLY — limit who can see this organic post to people in these places and/or over this age (Facebook allows 13, 15, 18, 21 or 25). Not on Instagram, Threads or a Facebook Reel (a vertical clip becomes a Reel): those are refused. Region and city keys come from the Meta ads location search.'),
4468
+ targetAudience: z.object({ geoLocations: z.array(z.string()).optional(), industries: z.array(z.string()).optional(), seniorities: z.array(z.string()).optional(), jobFunctions: z.array(z.string()).optional(), staffCountRanges: z.array(z.enum(['SIZE_1', 'SIZE_2_TO_10', 'SIZE_11_TO_50', 'SIZE_51_TO_200', 'SIZE_201_TO_500', 'SIZE_501_TO_1000', 'SIZE_1001_TO_5000', 'SIZE_5001_TO_10000', 'SIZE_10001_OR_MORE'])).optional(), degrees: z.array(z.string()).optional(), fieldsOfStudy: z.array(z.string()).optional(), organizations: z.array(z.string()).optional() }).optional().describe('LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets (URNs or bare numeric ids; search_linkedin_ads_targeting finds them). LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted.'),
4469
+ place: z.string().optional().describe('FACEBOOK — tag a location on the Page post. The NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.'),
4470
+ callToAction: z.enum(['BOOK_TRAVEL', 'BUY_NOW', 'CALL_NOW', 'DOWNLOAD', 'GET_DIRECTIONS', 'LEARN_MORE', 'LIKE_PAGE', 'MESSAGE_PAGE', 'NO_BUTTON', 'OPEN_LINK', 'SHOP_NOW', 'SIGN_UP', 'WATCH_MORE']).optional().describe('FACEBOOK — a real call-to-action BUTTON on the Page post. The types that open a destination (SHOP_NOW, LEARN_MORE, SIGN_UP, BUY_NOW, DOWNLOAD, OPEN_LINK, WATCH_MORE, BOOK_TRAVEL) need somewhere to go: the post’s own `link`, or `callToActionLink`. CALL_NOW, MESSAGE_PAGE, LIKE_PAGE and GET_DIRECTIONS act on the Page itself and take none.'),
4471
+ callToActionLink: z.string().optional().describe('FACEBOOK — where the button goes, when that is not the post’s own `link`.'),
4472
+ linkName: z.string().optional().describe('FACEBOOK — override the HEADLINE of the link preview. Without it the post shows whatever Open Graph title the destination carries, which on a bare landing page is often nothing. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.'),
4473
+ linkDescription: z.string().optional().describe('FACEBOOK — override the DESCRIPTION of the link preview. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.'),
4474
+ linkPicture: z.string().optional().describe('FACEBOOK — override the IMAGE of the link preview: a public http(s) url Facebook fetches itself. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.'),
4475
+ // ── THE SEVEN FROM THE 2026-09-16 PARAMETER AUDIT ──────────────────────────────────────────────────────────
4476
+ // Meta's table for POST /{ig-user-id}/media lists 21 parameters; we sent 13 and had never looked at these,
4477
+ // because every sweep we ran diffed PATHS or TOOLS and not one of them is a missing endpoint. Each is refused
4478
+ // BY NAME when it cannot apply (a cover on an image post, any of them on a story) rather than dropped.
4479
+ coverUrl: z.string().optional().describe('INSTAGRAM REEL COVER — a public image url Instagram fetches and uses as the cover in the Reels tab. REELS ONLY, and the alternative to `thumbOffset`: passing both is refused, since they are two answers to the same question. Run a local file through upload_file first.'),
4480
+ thumbOffset: z.number().optional().describe('INSTAGRAM REEL COVER, the other way — which frame becomes the cover, in MILLISECONDS from the start of the video. REELS ONLY. Use it instead of `coverUrl` when the right cover is already a frame of the clip.'),
4481
+ shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.'),
4482
+ audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
4483
+ instagramLocationId: z.string().optional().describe('INSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). It is the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.'),
4484
+ brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
4388
4485
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
4486
+ story: z.boolean().optional().describe('INSTAGRAM STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.'),
4389
4487
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
4390
4488
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
4391
4489
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
@@ -4408,6 +4506,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4408
4506
  title: 'List scheduled and past posts',
4409
4507
  description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). THE LIST IS COMPACT so it fits in one reply: the next 25 queued and the last 15 fired, captions shortened. Pass `id` for ONE post in full (every caption and setting, which you need before reschedule_post replaces a caption map), `channel` to filter, or `upcoming` / `fired` for more rows. Read-only, 0 credits.',
4410
4508
  inputSchema: {
4509
+ brand: z.string().optional().describe('WHICH BRAND to list — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
4411
4510
  id: z.string().optional().describe('one post id from this list: returns that post in full, every caption and setting included'),
4412
4511
  channel: z.string().optional().describe('only posts that include this channel, e.g. "pinterest" or "x"'),
4413
4512
  upcoming: z.number().optional().describe('how many queued posts to list, soonest first (default 25, max 200)'),
@@ -4418,8 +4517,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
4418
4517
  history: z.array(z.object({ id: z.string().optional(), at: z.string().nullable().optional(), channels: z.array(z.string()).optional(), status: z.string().optional(), results: z.array(z.object({ channel: z.string().optional(), ok: z.boolean().optional(), id: z.string().nullable().optional(), url: z.string().nullable().optional(), error: z.string().optional() })).nullable().optional(), error: z.string().nullable().optional() })).optional(),
4419
4518
  },
4420
4519
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4421
- }, wrap(async ({ id, channel, upcoming, fired } = {}) => {
4422
- const d = await apiGet('/api/schedule', {});
4520
+ }, wrap(async ({ id, channel, upcoming, fired, brand } = {}) => {
4521
+ const d = await apiGet('/api/schedule', brand ? { brandId: brand } : {});
4423
4522
  // ONE POST IN FULL (2026-09-13). The compact list below shortens captions, and reschedule_post's `captions`
4424
4523
  // replaces the WHOLE per-channel map, so an agent changing one caption needs the full row first.
4425
4524
  if (id) {
@@ -4478,6 +4577,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4478
4577
  title: 'Change a scheduled post',
4479
4578
  description: 'Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:"" moves a company-Page post back to the person\u2019s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.',
4480
4579
  inputSchema: {
4580
+ brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
4481
4581
  id: z.string().describe('the scheduled post id from list_scheduled'),
4482
4582
  at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
4483
4583
  message: z.string().optional().describe('replace the caption used for every channel that has no override'),
@@ -4526,10 +4626,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
4526
4626
  madeWithAi: z.boolean().optional().describe('X — the AI-media label; false turns it off.'),
4527
4627
  xQuotePostId: z.string().optional().describe('X — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule.'),
4528
4628
  communityId: z.string().optional().describe('X — the community to publish into; an empty string goes back to the main timeline.'),
4529
- paidPartnership: z.boolean().optional().describe('X — the paid-partnership label; false turns it off.'),
4629
+ paidPartnership: z.boolean().optional().describe('INSTAGRAM AND X — the paid-partnership label; false turns it off.'),
4530
4630
  xArticle: z.object({ title: z.string().optional(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: replaces the X Article (title, headings); {} makes it an ordinary X post again.'),
4531
4631
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet \u2014 an invite already sent cannot be withdrawn from here.'),
4532
4632
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE', '']).optional().describe('INSTAGRAM \u2014 replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued \u2014 a Reel already published cannot be converted into a trial.'),
4633
+ story: z.boolean().optional().describe('INSTAGRAM — true makes it a 24-hour Story, false an ordinary feed post. One image or one video, no carousel.'),
4634
+ coverUrl: z.string().optional().describe('INSTAGRAM REEL — replaces the cover image url; an empty string removes it.'),
4635
+ thumbOffset: z.number().optional().describe('INSTAGRAM REEL — replaces the cover frame, in milliseconds; 0 removes it. Never together with coverUrl.'),
4636
+ shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — whether the Reel also shows in the Feed grid.'),
4637
+ audioName: z.string().optional().describe('INSTAGRAM REEL — replaces the audio track name; an empty string removes it.'),
4638
+ instagramLocationId: z.string().optional().describe('INSTAGRAM — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it. Not locationId, which is the Google Business listing.'),
4639
+ brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — replaces the sponsor user ids behind the paid-partnership label (at most 2); [] removes them.'),
4640
+ place: z.string().optional().describe('FACEBOOK — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it.'),
4641
+ callToAction: z.enum(['BOOK_TRAVEL', 'BUY_NOW', 'CALL_NOW', 'DOWNLOAD', 'GET_DIRECTIONS', 'LEARN_MORE', 'LIKE_PAGE', 'MESSAGE_PAGE', 'NO_BUTTON', 'OPEN_LINK', 'SHOP_NOW', 'SIGN_UP', 'WATCH_MORE', '']).optional().describe('FACEBOOK — replaces the button on the Page post; "" removes it.'),
4642
+ callToActionLink: z.string().optional().describe('FACEBOOK — replaces where the button goes; an empty string falls back to the post link.'),
4643
+ linkName: z.string().optional().describe('FACEBOOK — replaces the link preview headline; an empty string removes the override.'),
4644
+ linkDescription: z.string().optional().describe('FACEBOOK — replaces the link preview description; an empty string removes the override.'),
4645
+ linkPicture: z.string().optional().describe('FACEBOOK — replaces the link preview image url; an empty string removes the override.'),
4646
+ audience: z.object({ countries: z.array(z.string()).optional(), regions: z.array(z.string()).optional(), cities: z.array(z.string()).optional(), minAge: z.number().optional() }).optional().describe('FACEBOOK — replaces who can see the Page post {countries, regions, cities, minAge}; {} removes the limit.'),
4647
+ targetAudience: z.object({ geoLocations: z.array(z.string()).optional(), industries: z.array(z.string()).optional(), seniorities: z.array(z.string()).optional(), jobFunctions: z.array(z.string()).optional(), staffCountRanges: z.array(z.string()).optional(), degrees: z.array(z.string()).optional(), fieldsOfStudy: z.array(z.string()).optional(), organizations: z.array(z.string()).optional() }).optional().describe('LINKEDIN COMPANY PAGE — replaces who sees the post; {} removes the limit. The matching audience must be over 300 followers.'),
4533
4648
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
4534
4649
  boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
4535
4650
  chatId: z.string().optional().describe('TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.'),
@@ -4549,11 +4664,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
4549
4664
  server.registerTool('cancel_scheduled', {
4550
4665
  title: 'Cancel a scheduled post',
4551
4666
  description: 'Remove a queued post before it goes out. Get the id from list_scheduled. Only works while it is still queued — something already published cannot be unsent (use manage_meta_post to delete a Facebook/Instagram post after the fact).',
4552
- inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled') },
4667
+ // A POST YOU CAN CREATE IN A BRAND MUST BE MANAGEABLE THERE (2026-09-16). Found by hitting it: `brand` on
4668
+ // schedule_post put a post in another brand, and then cancelling it needed use_brand — i.e. changing the whole
4669
+ // connection to undo one call. The wire spelling is `brandId` because these are GET/DELETE calls and `?brand=`
4670
+ // already means a brand NAME on /api/product/find.
4671
+ inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled'), brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.') },
4553
4672
  outputSchema: { cancelled: z.string().optional() },
4554
4673
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
4555
4674
  }, wrap(async (a) => {
4556
- const d = await apiDelete(`/api/schedule/${encodeURIComponent(a.id)}`);
4675
+ const d = await apiDelete(`/api/schedule/${encodeURIComponent(a.id)}${a.brand ? `?brandId=${encodeURIComponent(a.brand)}` : ''}`);
4557
4676
  return ok(`Cancelled ${d.cancelled}.`, d);
4558
4677
  }));
4559
4678
  // ── RETRY + DUPLICATE (2026-08-05) ────────────────────────────────────────────────────────────────────────────
@@ -4565,6 +4684,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4565
4684
  title: 'Retry a failed scheduled post',
4566
4685
  description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
4567
4686
  inputSchema: {
4687
+ brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
4568
4688
  id: z.string().describe('the scheduled post id from list_scheduled'),
4569
4689
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
4570
4690
  at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
@@ -4588,6 +4708,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4588
4708
  title: 'Duplicate a scheduled post',
4589
4709
  description: 'Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.',
4590
4710
  inputSchema: {
4711
+ brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
4591
4712
  id: z.string().describe('the post to copy, from list_scheduled'),
4592
4713
  at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
4593
4714
  useQueue: z.boolean().optional().describe('instead of naming a time, take the brand’s next free posting slot'),
@@ -10044,7 +10165,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10044
10165
  });
10045
10166
  server.registerTool('list_openai_ads_campaigns', {
10046
10167
  title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
10047
- description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Call with NO ids to get the ad account itself (name, currency, status, review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected (Settings ▸ Connectors ▸ ChatGPT Ads, or connect_connector): the user pastes an Advertiser API key from ChatGPT Ads Manager ▸ Settings — there is no OAuth and no manager account, and one key is scoped to one ad account.',
10168
+ description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (name, currency, status, review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected (Settings ▸ Connectors ▸ ChatGPT Ads, or connect_connector): the user pastes an Advertiser API key from ChatGPT Ads Manager ▸ Settings — there is no OAuth and no manager account, and one key is scoped to one ad account.',
10048
10169
  inputSchema: {
10049
10170
  campaignId: z.string().optional().describe('list this campaign’s ad groups'),
10050
10171
  adGroupId: z.string().optional().describe('list this ad group’s ads'),
@@ -10055,23 +10176,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
10055
10176
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
10056
10177
  }, wrap(async (a) => {
10057
10178
  const d = await apiGet('/api/openai-ads/campaigns', a);
10058
- if (d.level === 'ad') return ok(`${d.count} ad(s) in ChatGPT Ads ad group ${d.adGroupId}:\n${(d.ads || []).map(x => `• ${x.title || x.name} (${x.id}) — ${x.status}, review ${x.reviewStatus || 'unknown'}${x.targetUrl ? ` → ${x.targetUrl}` : ''}`).join('\n') || '(none)'}`, d);
10059
- if (d.level === 'adGroup') return ok(`${d.count} ad group(s) in ChatGPT Ads campaign ${d.campaignId}:\n${(d.adGroups || []).map(g => `• ${g.name} (${g.id}) — ${g.status}, ${g.contextHints} context hint(s)${g.maxBid != null ? `, max bid ${g.maxBid}` : ''}`).join('\n') || '(none)'}`, d);
10179
+ const blk = (x) => (x.servingIssues && x.servingIssues.length ? ` ⚠ not serving: ${x.servingIssues.map(i => i.meaning).join('; ')}` : '');
10180
+ if (d.level === 'ad') return ok(`${d.count} ad(s) in ChatGPT Ads ad group ${d.adGroupId}:\n${(d.ads || []).map(x => `• ${x.title || x.name} (${x.id}) — ${x.status}, review ${x.reviewStatus || 'unknown'}${x.appeal ? `, appeal ${x.appeal.status}` : ''}${x.targetUrl ? ` → ${x.targetUrl}` : ''}${blk(x)}`).join('\n') || '(none)'}`, d);
10181
+ if (d.level === 'adGroup') return ok(`${d.count} ad group(s) in ChatGPT Ads campaign ${d.campaignId}:\n${(d.adGroups || []).map(g => `• ${g.name} (${g.id}) — ${g.status}, ${g.contextHints} context hint(s)${g.maxBid != null ? `, max bid ${g.maxBid}` : ''}${(g.audienceBidMultipliers || []).length ? `, ${g.audienceBidMultipliers.length} audience bid multiplier(s)` : ''}${blk(g)}`).join('\n') || '(none)'}`, d);
10060
10182
  const acc = d.account;
10061
- return ok(`ChatGPT Ads account${acc ? ` "${acc.name}" (${acc.id})${acc.currency ? `, ${acc.currency}` : ''}${acc.status ? ` · ${acc.status}` : ''}` : ''}\n${d.count} campaign(s):\n${(d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day` : ''}${c.lifetimeBudget != null ? `, ${c.lifetimeBudget} lifetime` : ''}${c.biddingType ? `, ${c.biddingType}` : ''}`).join('\n') || '(none)'}`, d);
10183
+ return ok(`ChatGPT Ads account${acc ? ` "${acc.name}" (${acc.id})${acc.currency ? `, ${acc.currency}` : ''}${acc.status ? ` · ${acc.status}` : ''}` : ''}\n${d.count} campaign(s):\n${(d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day` : ''}${c.lifetimeBudget != null ? `, ${c.lifetimeBudget} lifetime` : ''}${c.biddingType ? `, ${c.biddingType}` : ''}${blk(c)}`).join('\n') || '(none)'}`, d);
10062
10184
  }));
10063
10185
  server.registerTool('openai_ads_report', {
10064
10186
  title: 'ChatGPT Ads performance report',
10065
- description: 'Performance for ChatGPT Ads — impressions, clicks, spend, CTR, CPC, CPM. The scope follows the id you pass: none = the whole ad account, or campaignId / adGroupId / adId. PRODUCT-FEED CAMPAIGNS serving in the multi-product CAROUSEL unit also report per-card numbers: ask for them in `fields` — carousel_product_card_impressions, carousel_product_card_clicks, product_impressions, product_clicks, product_spend, product_ctr, product_cpc, product_cpm plus product_title / product_price / product_feed_id and the other product_* fields (complete from 2026-08-20 on a rolling 30-day basis). A card impression counts when a product card becomes viewable and is NOT a billable impression, so never add it to spend math. granularity is hourly, daily, monthly or none (default daily); the default window is the last 30 days; segment by country or device for a breakdown, and level rolls the rows up by campaign / ad group / ad. A report with NO rows genuinely means there was NO delivery in that window — say exactly that; never present zeros as measured performance. Read-only and free, so run it FIRST after connecting: it proves the key works with zero spend risk.',
10187
+ description: 'Performance for ChatGPT Ads — impressions, clicks, spend, CTR, CPC, CPM, and CONVERSIONS with CPA, post-click conversion rate and attributed order sales / ROAS, in the ad account currency. OpenAI returns conversions only with granularity none or daily and with no segment or a country/device segment (never platform or product), and CPA, conversion rate and sales only with no segment at all; the report adds every column OpenAI allows for the shape you asked and its note names any it left out, so a missing column is their limit, not a zero. These conversions are CLICK-THROUGH (the ones CPA and bidding use); view-through lives only in openai_ads_conversions and is never added to them. The scope follows the id you pass: none = the whole ad account, or campaignId / adGroupId / adId. PRODUCT-FEED CAMPAIGNS serving in the multi-product CAROUSEL unit also report per-card numbers: ask for them in `fields` — carousel_product_card_impressions, carousel_product_card_clicks, product_impressions, product_clicks, product_spend, product_ctr, product_cpc, product_cpm plus product_title / product_price / product_feed_id and the other product_* fields (complete from 2026-08-20 on a rolling 30-day basis); they come back under each row’s `fields`. A card impression counts when a product card becomes viewable and is NOT a billable impression, so never add it to spend math. granularity is hourly, daily, monthly or none (default daily); the default window is the last 30 days; segment by country or device for a breakdown, and level rolls the rows up by campaign / ad group / ad. A report with NO rows genuinely means there was NO delivery in that window — say exactly that; never present zeros as measured performance. Read-only and free, so run it FIRST after connecting: it proves the key works with zero spend risk.',
10066
10188
  inputSchema: {
10067
10189
  campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
10068
10190
  since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
10069
10191
  granularity: z.enum(['hourly', 'daily', 'monthly', 'none']).optional().describe('default daily'),
10070
10192
  level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('roll rows up to this level'),
10071
10193
  segment: z.enum(['product', 'country', 'device', 'platform']).optional().describe('extra group-by dimension (at most one). platform splits rows by ChatGPT app or browser (ios_app, android_app, desktop_web, ios_web, android_web, and web for rows from before 2026-09-10, which OpenAI does not split retroactively); it reports delivery metrics only, not conversions'),
10072
- limit: z.number().optional(),
10194
+ limit: z.number().optional().describe('rows per page, up to 2000'),
10195
+ filters: z.array(z.object({ field: z.string(), operator: z.enum(['IN', 'GREATER_THAN', 'LESS_THAN']), value: z.any() })).optional().describe('keep only matching rows, e.g. {field:"campaign.status",operator:"IN",value:["active"]} or {field:"ad.clicks",operator:"GREATER_THAN",value:100}'),
10196
+ sort: z.array(z.object({ field: z.string(), direction: z.enum(['asc', 'desc']).optional() })).optional().describe('order rows, e.g. {field:"campaign.spend",direction:"desc"}'),
10197
+ includeZeroImpressions: z.boolean().optional().describe('also list campaigns / ad groups / ads with no impressions (unsegmented reports only)'),
10198
+ after: z.string().optional().describe('nextAfter from the previous page; limit goes up to 2000 rows'),
10199
+ conversions: z.boolean().optional().describe('default true: add conversions, CPA, conversion rate and sales where OpenAI allows them. false leaves them out.'),
10200
+ fields: z.array(z.string()).optional().describe('extra insight fields by name, e.g. product_title, product_price, carousel_product_card_impressions; each row returns them under `fields`'),
10073
10201
  },
10074
- outputSchema: { ok: z.boolean().optional(), scope: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), note: z.string().optional() },
10202
+ outputSchema: { ok: z.boolean().optional(), scope: z.string().optional(), currency: z.string().optional(), timezone: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), hasMore: z.boolean().optional(), nextAfter: z.string().optional(), note: z.string().optional() },
10075
10203
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
10076
10204
  }, wrap(async (a) => {
10077
10205
  const d = await apiPost('/api/openai-ads/report', a);
@@ -11172,17 +11300,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
11172
11300
  inputSchema: {
11173
11301
  name: z.string(),
11174
11302
  eventType: z.string().describe('e.g. order_created, lead_created, registration_completed — the plausible words "purchase", "lead" and "signup" are all REFUSED by ChatGPT Ads'),
11175
- sourceIds: z.array(z.string()).describe('pixel id(s) this event is measured from — from create_openai_ads_pixel'),
11303
+ sourceIds: z.array(z.string()).describe('exactly ONE source id from create_openai_ads_pixel (ChatGPT Ads takes one source per event; create one event per pixel)'),
11176
11304
  customEventName: z.string().optional().describe('for a non-standard event'),
11177
- attributionWindowDays: z.number().optional().describe('1-90'),
11305
+ attributionWindowDays: z.number().optional().describe('default 30, which is what OpenAI recommends; 1-90'),
11178
11306
  },
11179
11307
  outputSchema: { conversionEventSettingId: z.string().optional(), name: z.string().optional(), eventType: z.string().optional(), note: z.string().optional() },
11180
11308
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
11181
11309
  }, wrap(async (a) => { const d = await apiPost('/api/openai-ads/conversion-event', a); return ok(d.note, d); }));
11182
11310
  server.registerTool('list_openai_ads_audiences', {
11183
11311
  title: 'List ChatGPT Ads custom audiences',
11184
- description: 'List the custom audiences on the connected ChatGPT Ads account. Read-only, free.',
11185
- inputSchema: { limit: z.number().optional() },
11312
+ description: 'List the custom audiences on the connected ChatGPT Ads account. Pass intendedUse to see only the ones eligible for exclusion, inclusion or a bid multiplier (inclusion and bid multipliers need roughly 25,000 matched users). Read-only, free.',
11313
+ inputSchema: { limit: z.number().optional(), intendedUse: z.enum(['exclusion', 'inclusion', 'bid_multiplier']).optional().describe('only audiences eligible for this use') },
11186
11314
  outputSchema: { count: z.number().optional(), audiences: z.array(z.any()).optional(), note: z.string().optional() },
11187
11315
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
11188
11316
  }, wrap(async (a) => {
@@ -11191,13 +11319,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
11191
11319
  }));
11192
11320
  server.registerTool('create_openai_ads_audience', {
11193
11321
  title: 'Create a ChatGPT Ads custom audience',
11194
- description: 'Create a ChatGPT Ads custom audience from a customer list. Pass plain emails and/or phone numbers: Hermoso NORMALISES AND SHA-256 HASHES THEM LOCALLY and uploads only the digests, so no plaintext personal data leaves Hermoso. MEASURED 2026-08-09: OpenAI’s ads file endpoint only accepts IMAGE mimetypes (gif/jpeg/png/webp) and rejects a customer-list CSV under every upload purpose, so audiences are UI-only for now — this tool reports OpenAI’s verbatim refusal and points the user at ChatGPT Ads Manager, and will start working unchanged the day a data-file path opens. Values that are neither an email nor a phone number are skipped and counted, never silently dropped. An audience is a definition and cannot spend.',
11322
+ description: 'Create a ChatGPT Ads custom audience from a customer list. Pass plain emails and/or phone numbers: Hermoso NORMALISES AND SHA-256 HASHES THEM LOCALLY and uploads only the digests, so no plaintext personal data leaves Hermoso. OR pass fileUrl: a public link to a UTF-8 .csv or .txt customer list (a CSV needs a header naming its column: email, phone_number, email_sha256, phone_number_sha256 or gaid; identifierResolution "auto" reads several columns at once). A file is uploaded to ChatGPT Ads as it is and OpenAI hashes raw emails and phones itself; the members list is hashed here first. Matching is asynchronous, so read the audience back for its matched count. Values that are neither an email nor a phone number are skipped and counted, never silently dropped. An audience is a definition and cannot spend.',
11195
11323
  inputSchema: {
11196
11324
  name: z.string(),
11197
- members: z.array(z.string()).describe('emails and/or phone numbers (already-SHA256-hashed emails are passed through as-is)'),
11325
+ members: z.array(z.string()).optional().describe('emails and/or phone numbers (already-SHA256-hashed emails are passed through as-is); or use fileUrl'),
11326
+ fileUrl: z.string().optional().describe('public link to a UTF-8 .csv or .txt customer list; upload_file turns a local file into one'),
11327
+ fileName: z.string().optional().describe('the file name with .csv or .txt, when the link hides it'),
11328
+ identifierType: z.enum(['email', 'phone', 'email_sha256', 'phone_number_sha256', 'gaid']).optional().describe('the one identifier the file holds (inferred from a CSV header when omitted)'),
11329
+ identifierResolution: z.enum(['auto']).optional().describe('auto = read every identifier column of a mixed CSV'),
11198
11330
  description: z.string().optional(),
11199
11331
  },
11200
- outputSchema: { audienceId: z.string().optional(), name: z.string().optional(), membersSent: z.number().optional(), rejected: z.number().optional(), note: z.string().optional() },
11332
+ outputSchema: { audienceId: z.string().optional(), name: z.string().optional(), membersSent: z.number().optional(), rejected: z.number().optional(), status: z.string().optional(), fileId: z.string().optional(), fileBytes: z.number().optional(), note: z.string().optional() },
11201
11333
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
11202
11334
  }, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience', a); return ok(d.note, d); }));
11203
11335
  server.registerTool('get_openai_ads_audience', {
@@ -11308,6 +11440,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
11308
11440
  bidStrategy: z.enum(['fixed_bid', 'maximize_clicks', 'maximize_conversions']).optional().describe('HOW THIS AD GROUP BIDS — OpenAI’s “Maximize results”. fixed_bid (default) uses your maxBid as a hard cap; maximize_clicks and maximize_conversions let ChatGPT Ads set the bid to get the most of that outcome for the budget, and with either of those maxBid is OPTIONAL. maximize_conversions additionally needs the CAMPAIGN on biddingType "conversions" with a conversion event setting attached.'),
11309
11441
  billingEvent: z.enum(['click', 'impression']).optional().describe('default click'),
11310
11442
  contextHints: z.array(z.string()).optional().describe('up to 2,000, deduplicated server-side'),
11443
+ landingPageQueryTemplate: z.string().optional().describe('query string added to every landing page click, e.g. utm_source=chatgpt&utm_content={ad_id}. Placeholders: {campaign_id} {ad_group_id} {ad_id} {ad_account_id} {oppref}; oppref and olref cannot be parameter names. On an update, an empty string clears it.'),
11444
+ audienceBidMultipliers: z.array(z.object({ customAudienceId: z.string(), multiplier: z.number().describe('0.1 to 10; 2 bids twice as much for this audience') })).optional().describe('bid more or less for people in a custom audience (eligible ids: list_openai_ads_audiences with intendedUse bid_multiplier, about 25,000 matched users). On an update this REPLACES the list; [] removes them all, and a bid change keeps the existing multipliers.'),
11311
11445
  status: z.enum(['active', 'paused']).optional().describe('default paused'),
11312
11446
  confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE under a live campaign (real spend)'),
11313
11447
  },
@@ -11323,6 +11457,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11323
11457
  inputSchema: {
11324
11458
  adGroupId: z.string(), name: z.string().optional().describe('internal name — defaults to the title'),
11325
11459
  creative: oaiCreativeShape,
11460
+ landingPageQueryTemplate: z.string().optional().describe('query string added to every landing page click, e.g. utm_source=chatgpt&utm_content={ad_id}. Placeholders: {campaign_id} {ad_group_id} {ad_id} {ad_account_id} {oppref}; oppref and olref cannot be parameter names. On an update, an empty string clears it.'),
11326
11461
  status: z.enum(['active', 'paused']).optional().describe('default paused'),
11327
11462
  confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE in a live ad group (real spend)'),
11328
11463
  },
@@ -11382,6 +11517,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
11382
11517
  maxBid: z.number().optional(), billingEvent: z.enum(['click', 'impression']).optional().describe('required alongside maxBid — bidding is replaced wholesale'),
11383
11518
  bidStrategy: z.enum(['fixed_bid', 'maximize_clicks', 'maximize_conversions']).optional().describe('CHANGE HOW THIS AD GROUP BIDS (OpenAI’s “Maximize results”). BIDDING IS REPLACED WHOLESALE, so a patch that moves the bid without restating the strategy would DEMOTE a maximize_* ad group to a fixed bid, and one that sets a strategy without restating maxBid DELETES the cap — both are refused by name with what would have been lost.'),
11384
11519
  creative: oaiCreativeShape.optional().describe('REPLACES the ad’s creative (text + image card only)'),
11520
+ landingPageQueryTemplate: z.string().optional().describe('query string added to every landing page click, e.g. utm_source=chatgpt&utm_content={ad_id}. Placeholders: {campaign_id} {ad_group_id} {ad_id} {ad_account_id} {oppref}; oppref and olref cannot be parameter names. On an update, an empty string clears it.'),
11521
+ audienceBidMultipliers: z.array(z.object({ customAudienceId: z.string(), multiplier: z.number().describe('0.1 to 10; 2 bids twice as much for this audience') })).optional().describe('bid more or less for people in a custom audience (eligible ids: list_openai_ads_audiences with intendedUse bid_multiplier, about 25,000 matched users). On an update this REPLACES the list; [] removes them all, and a bid change keeps the existing multipliers.'),
11385
11522
  confirm: z.boolean().optional().describe('REQUIRED true to change budget / bid / creative on a LIVE object'),
11386
11523
  },
11387
11524
  outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), object: z.any().optional(), note: z.string().optional() },
@@ -11402,7 +11539,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11402
11539
  }));
11403
11540
  server.registerTool('openai_ads_conversions', {
11404
11541
  title: 'ChatGPT Ads attributed conversions',
11405
- description: 'ATTRIBUTED CONVERSIONS for ChatGPT Ads — the number the whole pixel + conversion-event setup exists to produce, and the one openai_ads_report structurally cannot give you (that endpoint family carries impressions, clicks, spend, CTR, CPC and CPM and no conversions at all). Pass entityIds — the campaign / ad group / ad ids to report on — with a matching level; the default window is the last 30 days. NEVER ADD conversions AND viewThroughConversions TOGETHER: OpenAI states that "conversions is always equal to click_through_conversions" and that view-through is "a separate, supplemental metric" NOT added to that total, and that view-through is reporting-only because CPA, post-click CVR, bidding, billing and conversion optimization all remain click-through-based. NO ROWS means no attributed conversion was recorded, not that data is missing — say exactly that, and check that an event setting exists (list_openai_ads_conversion_events) and that its pixel snippet is actually live on the site. RECEIVED EVENTS: pass recentEvents:true (no ids needed) to read a recent SAMPLE of the events OpenAI actually received on the pixel — type, event time, receive time, API channel and the event id — which answers "did OpenAI get the signup at all?" when a conversion is missing. It is a sample, not a complete log, and receiving an event is not the same as attributing it. Read-only, free.',
11542
+ description: 'ATTRIBUTED CONVERSIONS for ChatGPT Ads — the number the whole pixel + conversion-event setup exists to produce, beyond what openai_ads_report shows: that report carries click-through conversions and CPA for the account, a campaign, ad group or ad, while this tool adds VIEW-THROUGH conversions, totals per id across many ids, and the received-event sample. Pass entityIds — the campaign / ad group / ad ids to report on — with a matching level; the default window is the last 30 days. NEVER ADD conversions AND viewThroughConversions TOGETHER: OpenAI states that "conversions is always equal to click_through_conversions" and that view-through is "a separate, supplemental metric" NOT added to that total, and that view-through is reporting-only because CPA, post-click CVR, bidding, billing and conversion optimization all remain click-through-based. NO ROWS means no attributed conversion was recorded, not that data is missing — say exactly that, and check that an event setting exists (list_openai_ads_conversion_events) and that its pixel snippet is actually live on the site. RECEIVED EVENTS: pass recentEvents:true (no ids needed) to read a recent SAMPLE of the events OpenAI actually received on the pixel — type, event time, receive time, API channel and the event id — which answers "did OpenAI get the signup at all?" when a conversion is missing. It is a sample, not a complete log, and receiving an event is not the same as attributing it. Read-only, free.',
11406
11543
  inputSchema: {
11407
11544
  level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('inferred from which id you pass — default ad_account'),
11408
11545
  entityIds: z.array(z.string()).optional().describe('the campaign, ad group or ad ids to report on, required below the account level; omit for level ad_account, which sums every campaign'),
@@ -13177,7 +13314,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
13177
13314
  scheduleType: z.enum(['SCHEDULE_FROM_NOW', 'SCHEDULE_START_END']),
13178
13315
  scheduleStartTime: z.string().describe('YYYY-MM-DD HH:MM:SS in the advertiser timezone'),
13179
13316
  scheduleEndTime: z.string().optional(),
13180
- advertiserId: z.string().optional(), budget: z.number().optional(), bid: z.number().optional(),
13317
+ advertiserId: z.string().optional(),
13318
+ budget: z.number().optional().describe('ad group budget in the advertiser currency. Required when the campaign has budgetOptimizeOn false, ignored when CBO is on'),
13319
+ budgetMode: z.enum(['BUDGET_MODE_TOTAL', 'BUDGET_MODE_DYNAMIC_DAILY_BUDGET']).optional().describe('required with budget when the campaign has CBO off'),
13320
+ bid: z.number().optional().describe('the Cost Cap target, required with bidType BID_TYPE_CUSTOM. Sent as bid_price for CLICK and conversion_bid_price for CONVERT, TRAFFIC_LANDING_PAGE_VIEW, INSTALL and IN_APP_EVENT; VALUE takes deepBidType and roasBid instead'),
13321
+ deepBidType: z.enum(['DEFAULT', 'AEO', 'VO_MIN_ROAS', 'VO_HIGHEST_VALUE']).optional().describe('required when optimizationGoal is VALUE'),
13322
+ roasBid: z.number().optional().describe('target ROAS (0.01-1000), required when deepBidType is VO_MIN_ROAS'),
13323
+ pixelId: z.string().optional().describe('required for WEB_CONVERSIONS / LEAD_GENERATION when optimizationGoal is CONVERT or VALUE'),
13324
+ optimizationEvent: z.string().optional().describe('the conversion event, required whenever pixelId is set (e.g. SHOPPING, ON_WEB_ORDER)'),
13181
13325
  },
13182
13326
  outputSchema: { adGroupId: z.string().optional(), summary: z.string().optional() },
13183
13327
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -13188,7 +13332,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
13188
13332
  description: 'Add the creative to a Smart+ ad group. Smart+ combines the materials you give it rather than running one fixed cut, so this is where the videos and copy go. Created PAUSED. If TikTok accepts the request but returns no ad id, the reply says nothing is confirmed to exist rather than claiming success — check Ads Manager before retrying, or a second call can create a twin.',
13189
13333
  inputSchema: {
13190
13334
  adGroupId: z.string().describe('from create_tiktok_smart_ad_group'),
13191
- creatives: z.array(z.record(z.any())).optional().describe('creative objects — video ids, ad texts, call to action'),
13335
+ name: z.string().optional().describe('ad name; "" lets TikTok name it after the ad id'),
13336
+ creatives: z.array(z.record(z.any())).optional().describe('up to 50 TikTok creative_info objects: {ad_format: "SINGLE_VIDEO" | "CAROUSEL_ADS", video_info: {video_id}, image_info: [{web_uri}], tiktok_item_id, identity_type, identity_id}. Ids come from upload_tiktok_ads_creative and list_tiktok_ads_identities'),
13337
+ adTexts: z.array(z.string()).optional().describe('up to 5 ad texts; required unless every creative is a tiktok_item_id Spark post'),
13338
+ landingPageUrl: z.string().optional().describe('the destination URL'),
13339
+ callToActions: z.array(z.string()).optional().describe('up to 3 TikTok call-to-action enums, e.g. LEARN_MORE, SHOP_NOW'),
13340
+ adConfiguration: z.record(z.any()).optional().describe('TikTok ad_configuration for Spark or catalog ads (identity_type, identity_id, product_set_id, …)'),
13192
13341
  advertiserId: z.string().optional(),
13193
13342
  },
13194
13343
  outputSchema: { adIds: z.array(z.string()).optional(), summary: z.string().optional() },
@@ -13198,7 +13347,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13198
13347
  server.registerTool('list_tiktok_smart_campaigns', {
13199
13348
  title: 'List TikTok Smart+ campaigns',
13200
13349
  description: 'Read the Smart+ campaigns on a TikTok advertiser. TikTok’s reads LAG its writes, so a campaign created seconds ago can be missing here and still exist — the reply says so rather than reporting an empty account.',
13201
- inputSchema: { advertiserId: z.string().optional(), limit: z.number().optional() },
13350
+ inputSchema: { advertiserId: z.string().optional(), limit: z.number().optional().describe('page size, 1-1000, default 20'), page: z.number().optional().describe('page number, from 1; the reply says when there are more') },
13202
13351
  outputSchema: { count: z.number().optional(), summary: z.string().optional() },
13203
13352
  annotations: { destructiveHint: false, readOnlyHint: true, openWorldHint: true },
13204
13353
  }, wrap(async (a) => { const d = await apiGet('/api/tiktok-ads/smart/campaigns', a); return ok(d.summary, d); }));
@@ -13219,12 +13368,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
13219
13368
 
13220
13369
  server.registerTool('set_tiktok_smart_status', {
13221
13370
  title: 'Enable, pause or delete a Smart+ object',
13222
- description: 'THE ONLY SWITCH THAT ARMS REAL MONEY on the Smart+ lane. ENABLE requires confirm:true and starts spend on the next auction; DISABLE and DELETE only ever reduce spend and are never gated. Works at campaign, adgroup or ad level.',
13371
+ description: 'Enable, pause or delete Smart+ campaigns, ad groups or ads (up to 20 ids per call). ENABLE starts REAL SPEND on the next auction and DELETE is permanent (a deleted campaign takes its ad groups and ads with it), so both require confirm:true after the user says yes. DISABLE (pause) is never gated. The reply is read back from TikTok and names any id whose new state it could not confirm.',
13223
13372
  inputSchema: {
13224
13373
  level: z.enum(['campaign', 'adgroup', 'ad']),
13225
13374
  ids: z.union([z.string(), z.array(z.string())]),
13226
13375
  status: z.enum(['ENABLE', 'DISABLE', 'DELETE']),
13227
- confirm: z.boolean().optional().describe('required for ENABLE — real money'),
13376
+ confirm: z.boolean().optional().describe('required for ENABLE (real money) and DELETE (permanent)'),
13228
13377
  advertiserId: z.string().optional(),
13229
13378
  },
13230
13379
  outputSchema: { summary: z.string().optional() },
@@ -15073,6 +15222,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15073
15222
  captionsSrt: z.string().optional().describe('CLOSED CAPTIONS for a videoUrl post — the SubRip (.srt) CONTENT itself, cue numbers and `00:00:00,000 --> 00:00:02,000` timing lines included, NOT a URL and NOT the plain script (a file with no timings is refused, because LinkedIn would accept it and then silently never show it). Most of LinkedIn is watched with the sound off, so an uncaptioned video is one most of the feed never hears. LinkedIn allows ONE caption file per video and ENGLISH ONLY; it can be attached only WHILE the video is uploaded, never added to a published post; and it is processed asynchronously, so the reply confirms it was UPLOADED and never that it is visible yet. Requires videoUrl — passing it on an image, carousel or link post is refused by name.'),
15074
15223
  videoThumbnailUrl: z.string().optional().describe('the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user\u2019s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card.'),
15075
15224
  visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
15225
+ targetAudience: z.object({ geoLocations: z.array(z.string()).optional(), industries: z.array(z.string()).optional(), seniorities: z.array(z.string()).optional(), jobFunctions: z.array(z.string()).optional(), staffCountRanges: z.array(z.enum(['SIZE_1', 'SIZE_2_TO_10', 'SIZE_11_TO_50', 'SIZE_51_TO_200', 'SIZE_201_TO_500', 'SIZE_501_TO_1000', 'SIZE_1001_TO_5000', 'SIZE_5001_TO_10000', 'SIZE_10001_OR_MORE'])).optional(), degrees: z.array(z.string()).optional(), fieldsOfStudy: z.array(z.string()).optional(), organizations: z.array(z.string()).optional() }).optional().describe('LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets (URNs or bare numeric ids; search_linkedin_ads_targeting finds them). LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted.'),
15076
15226
  },
15077
15227
  outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), organizationId: z.string().optional(), videoExtras: z.object({ captions: z.boolean().optional(), thumbnail: z.boolean().optional() }).optional(), note: z.string().optional() },
15078
15228
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -15613,6 +15763,52 @@ function buildTools(rawServer, opts = {}, sink = null) {
15613
15763
  const d = await apiPost('/api/drive/save', a);
15614
15764
  return ok(d.note || `Saved ${(d.files || []).length} file(s) to Drive.`, d);
15615
15765
  }));
15766
+ // ── PULL A WHOLE CLOUD FOLDER INTO THE LIBRARY (2026-09-16) ─────────────────────────────────────────────────────
15767
+ // Asked for by the first real user of the connector: "let us import from Google Drive or OneDrive — both are
15768
+ // already connectable; a 'pull this folder into my Library' tool is the practical fix." ONE tool for both clouds
15769
+ // rather than two, because the roster is the billed prefix of every Studio turn and `provider` is one enum value
15770
+ // against a whole second tool's schema and description.
15771
+ //
15772
+ // THE SERVER DOES THE FETCH, and that is the whole reason this could not be assembled from existing tools: a
15773
+ // Drive or OneDrive file is NOT public, so its download needs the customer's connector token, which only the
15774
+ // server holds. The Library write happens HERE, through the same merge-aware store path every other client uses,
15775
+ // so an import lands in the same list the app renders and syncs across devices.
15776
+ server.registerTool('import_from_cloud', {
15777
+ title: 'Import a Drive / OneDrive folder into the Library',
15778
+ description: "Pull the files in a Google Drive or OneDrive FOLDER into this brand's Library, so they can be used like anything rendered here — published, scheduled, cloned, used as a product photo or a reference. Hermoso downloads each file with the user's own connected account (a Drive/OneDrive file is not public, so this is the only way in) and stores a durable Hermoso url for each. Give `folderId` from list_drive_files / list_onedrive_files with onlyFolders — omit it for the root. GOOGLE DRIVE ONLY SHOWS WHAT THE USER HANDED OVER: our Drive scope is `drive.file`, so Hermoso can see the files and folders it created plus the ones the user picked with the Google picker in the app, and NEVER their whole Drive — if a folder comes back empty, that is the answer, and the user picks it in the app once to make it reachable. OneDrive has no such limit. SUBFOLDERS ARE NOT WALKED and Google-native docs (Docs/Sheets/Slides) have no file to download: both are reported back BY NAME rather than silently dropped, along with anything too large or unreadable, so you can tell the user exactly what did and did not come across.",
15779
+ inputSchema: {
15780
+ provider: z.enum(['drive', 'onedrive']).describe('which cloud — `drive` is Google Drive, `onedrive` is Microsoft OneDrive'),
15781
+ folderId: z.string().optional().describe('the folder to import, from list_drive_files / list_onedrive_files (onlyFolders:true). Omit for the root of the drive.'),
15782
+ limit: z.number().optional().describe('how many files to bring across in this call (default 10, max 25). Anything over the limit is listed as skipped so you know what is left.'),
15783
+ },
15784
+ outputSchema: {
15785
+ imported: z.array(z.object({ name: z.string().optional(), url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional() })).optional(),
15786
+ skipped: z.array(z.object({ name: z.string().optional(), why: z.string().optional() })).optional(),
15787
+ },
15788
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
15789
+ }, wrap(async (a) => {
15790
+ const d = await apiPost('/api/library/import', { provider: a.provider, folderId: a.folderId || '', limit: a.limit });
15791
+ const imported = d.imported || [], skipped = d.skipped || [];
15792
+ // THE LIBRARY WRITE IS A MERGE, NEVER A REPLACE: the app and other devices write this same key, and an import
15793
+ // that clobbered it would delete renders. Dedup on url, newest first, the same shape and 300-row cap the app's
15794
+ // own Assets store keeps.
15795
+ if (imported.length) {
15796
+ let list = await readStore('heist.assets.v1');
15797
+ if (!Array.isArray(list)) list = [];
15798
+ const have = new Set(list.map((x) => x && x.url).filter(Boolean));
15799
+ const rows = imported.filter((x) => x.url && !have.has(x.url))
15800
+ .map((x) => ({ url: x.url, kind: x.kind || '', model: a.provider === 'drive' ? 'Imported from Google Drive' : 'Imported from OneDrive', name: x.name || '', at: Date.now() }));
15801
+ if (rows.length) await writeStore('heist.assets.v1', [...rows, ...list].slice(0, 300));
15802
+ }
15803
+ if (!imported.length && !skipped.length) return ok('That folder is empty — nothing to import.', { imported: [], skipped: [] });
15804
+ const lines = [
15805
+ imported.length ? `Imported ${imported.length} file${imported.length === 1 ? '' : 's'} into the Library:` : 'Nothing was imported.',
15806
+ ...imported.map((x) => ` • ${x.name || x.url} → ${x.url}`),
15807
+ ...(skipped.length ? ['', `Skipped ${skipped.length}:`, ...skipped.map((x) => ` • ${x.name}: ${x.why}`)] : []),
15808
+ ];
15809
+ return ok(lines.join('\n'), { imported, skipped });
15810
+ }));
15811
+
15616
15812
  server.registerTool('list_drive_files', {
15617
15813
  title: 'List Google Drive files',
15618
15814
  description: 'List the Google Drive files & folders Hermoso can reach — the ones it created, plus any the user handed over with the Google file picker in the app (the drive.file scope exposes nothing else, never their entire Drive). This is how you find the id of a file the user picked. Filter by query (name contains …), folderId (contents of a folder), or onlyFolders:true. Paginate with pageToken. Read-only.',
@@ -16574,7 +16770,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16574
16770
  aspectRatio: z.string().optional().describe("default '9:16'"),
16575
16771
  model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick'),
16576
16772
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render. Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
16577
- cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage — orbit (quarter turn, the default), orbit_left, orbit_half, orbit_full (turntable), rise, crane_up, push_in, pull_back, reveal. Only the camera-controls model renders one (minimax-h3-max-camera, listed with its moves in hermoso_capabilities): pass it with model omitted and that model is picked, or with that model named; any other named model is refused by name with nothing charged. Needs refImage.'),
16773
+ cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage, spelled exactly as the enum gives it: an orbit (a quarter turn, the default), orbiting left, a half turn or a full turntable, a rise, a crane up, a push in, a pull back, or a reveal. Only the camera-controls model renders one (minimax-h3-max-camera, listed with its moves in hermoso_capabilities): pass it with model omitted and that model is picked, or with that model named; any other named model is refused by name with nothing charged. Needs refImage.'),
16578
16774
  cameraTrajectory: z.array(z.object({
16579
16775
  time: z.number().min(0).max(1).describe('when this pose is reached, 0 = start of the clip, 1 = end'),
16580
16776
  azimuth: z.number().describe('horizontal angle around the subject in degrees (0 = where the still was taken; the sign turns the camera the other way; at most 32 full turns of total travel)'),
@@ -16659,7 +16855,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16659
16855
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
16660
16856
  voiceover: z.string().optional().describe('full voiceover script spoken across the scenes'),
16661
16857
  voice: z.string().optional().describe('voiceover voice name, e.g. Rachel / George'),
16662
- resolution: z.string().optional().describe('1080p (default), or 480p/720p for a cheaper draft'),
16858
+ resolution: z.string().optional().describe('720p (default), 1080p for full detail, or 480p for a cheaper draft'),
16663
16859
  model: z.string().optional().describe('video model id from hermoso_capabilities — omit to let the router pick'),
16664
16860
  durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
16665
16861
  },
@@ -18239,6 +18435,28 @@ function memoryNoteVerdict(text) {
18239
18435
  const lines = d.creators.map(c => `• @${c.handle} (${c.platform})${c.name && c.name !== c.handle ? ` — ${c.name}` : ''}: ${c.posts} post${c.posts === 1 ? '' : 's'} in this niche, median ${fmt(c.medianPlays)} views, ${c.engagementRate == null ? 'engagement unknown' : `${(100 * c.engagementRate).toFixed(1)}% engagement`}${c.followers != null ? `, ${fmt(c.followers)} followers` : ''}, score ${c.score}${c.top?.link ? ` — top: ${c.top.link}` : ''}${c.profileUrl ? ` — ${c.profileUrl}` : ''}`);
18240
18436
  return ok(`${d.note}\n${lines.join('\n')}`, d);
18241
18437
  }));
18438
+ // TOPIC SEARCH (2026-09-15, Dave: "search higgsfield, but not their ads themselves, just posts about them … similarly
18439
+ // just broad things like coffee"): the posts ABOUT a subject from anyone, all three organic platforms in one call.
18440
+ // find_creators is this same search one step later (posts folded into people); the per-platform search_* tools are
18441
+ // it one platform at a time.
18442
+ server.registerTool('search_posts', {
18443
+ title: 'Top posts about any topic, brand or product',
18444
+ description: 'The POSTS people make ABOUT a subject — a brand ("higgsfield"), a product, a hobby ("coffee"), a hashtag ("#homecafe") — from whoever posted them, across organic TikTok, Instagram Reels and YouTube in ONE call, ranked by views. Not the brand\'s own ads (search_meta_ads / research_ads) and not the people (find_creators folds these same posts into creators): use it to see what is actually being posted and watched about a subject, to find clips worth cloning (clone_video), and to read the hooks and angles an audience already responds to. About one credit per platform searched (one query each by default; `queries` adds "best X" / "X review" / #tag variants, each a paid call); repeats inside 20 minutes are free.',
18445
+ inputSchema: {
18446
+ topic: z.string().describe('subject, brand, product or hashtag — "higgsfield", "coffee", "#homecafe"'),
18447
+ platforms: z.array(z.enum(['tiktok', 'instagram', 'youtube'])).optional().describe('default all three'),
18448
+ limit: z.number().optional().describe('posts per platform, 1–60 (default 24)'),
18449
+ queries: z.number().optional().describe('query variants per platform, 1–4 (default 1); each is a paid search call'),
18450
+ },
18451
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18452
+ }, wrap(async (a) => {
18453
+ const d = await apiPost('/api/posts/search', a);
18454
+ if (!d.shown) return ok(`${d.summary} Nothing matched — try broader words or add platforms.`, d);
18455
+ const fmt = (v) => v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(v >= 1e5 ? 0 : 1)}K` : String(v);
18456
+ const lines = [];
18457
+ for (const p of d.platforms) for (const t of (d.byPlatform?.[p] || [])) lines.push(`• [${p}] ${t.handle ? '@' + t.handle : ''}${t.plays ? ` ${fmt(t.plays)} views` : t.likes ? ` ${fmt(t.likes)} likes` : ''}: ${String(t.desc || '').slice(0, 120)}${t.link ? ` — ${t.link}` : ''}`);
18458
+ return ok(`${d.note}\n${lines.join('\n')}`, d);
18459
+ }));
18242
18460
  server.registerTool('search_tiktok', {
18243
18461
  _meta: openaiMeta(AD_SPY_URI, 'Searching TikTok videos…', 'Found TikTok videos'),
18244
18462
  title: 'Search TikTok',
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.248",
3
+ "version": "0.1.250",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 839 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 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"