hermoso 0.1.249 → 0.1.251

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
- **840 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 840 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
@@ -260,7 +260,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
260
260
  // scope to and nothing honest to read — and a registry crawler or an agent deciding whether to connect MUST
261
261
  // see the real catalog, not a zero-connector one. registerTools treats an absent `connectors` exactly like a
262
262
  // failed read: full roster. Do not "fix" this by reading the workspace off the request; it is forgeable.
263
- registerTools(server, { only: scope?.groups, directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) }); // 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
264
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 {} }
265
265
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
266
266
  res.on('close', () => { try { transport.close(); server.close(); } catch {} });
@@ -333,7 +333,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
333
333
  // connectedProviders() ([[failed-read-is-not-empty]]).
334
334
  const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
335
335
  const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
336
- 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
337
337
  const transport = new StreamableHTTPServerTransport({
338
338
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
339
339
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
@@ -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
@@ -551,6 +551,14 @@ const refWatchedLine = (w) => {
551
551
  // never. Declared ONCE and spread into every publish tool's inputSchema — hand-listing the pair at ten seams is
552
552
  // exactly how SCHED_ID_FIELDS drifted on four of them.
553
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.'),
554
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.'),
555
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.'),
556
564
  };
@@ -1831,7 +1839,9 @@ export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
1831
1839
  // the old figure divided in the ratio the two halves actually weigh (32.9% / 67.1%, measured on the wire schema
1832
1840
  // `z.toJSONSchema` emits), rather than re-measured by a different method — every other row here came from the
1833
1841
  // 2026-08-20 run, and mixing methodologies inside one table would make the rows incomparable.
1834
- 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 };
1835
1845
 
1836
1846
  // THE DEFAULT ROSTER IS EVERYTHING EXCEPT `ads` AND `analytics`. Paid-campaign management across eleven platforms
1837
1847
  // is ~236K tokens on its own — more than everything else put together — because each platform carries a full
@@ -2061,6 +2071,7 @@ export function defForHost(name, def, widgetHost) {
2061
2071
  // declares itself with `sessionBound()` and `tools/mcp-tool-canon-check.mjs` fails any handler that reaches for
2062
2072
  // session state without it.
2063
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
2064
2075
 
2065
2076
  // Declare a handler that MUST be rebuilt per session. `factory(ctx)` receives {enabledGroups, groupOf, handleOf}.
2066
2077
  // ── SWITCHING BRAND MUST RE-GATE THE ROSTER (2026-09-01) ────────────────────────────────────────────────────────
@@ -2426,12 +2437,18 @@ const LI_TARGETING_FACETS = ['LANGUAGE','LOCATION','AUDIENCE','AGE','GENDER','CO
2426
2437
 
2427
2438
  export function registerTools(rawServer, opts = {}) {
2428
2439
  const server = wellFormedServer(rawServer); // the ONE crossing — see above
2429
- // The RETURN VALUE stays exactly what it was (the raw server on replay, buildTools' own answer on a build): the
2430
- // 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;
2431
2448
  if (TOOL_CANON) { replayTools(server, opts, TOOL_CANON); return rawServer; }
2432
2449
  const sink = [];
2433
2450
  const r = buildTools(server, opts, sink);
2434
- 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
2435
2452
  return r;
2436
2453
  }
2437
2454
 
@@ -2694,6 +2711,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
2694
2711
  const alts = new Set([w, st, ...(TOOL_SYNONYMS[w] || []), ...(TOOL_SYNONYMS[st] || [])]);
2695
2712
  return { w, st, alts: [...alts] };
2696
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
+ };
2697
2727
  const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12 } = {}) => {
2698
2728
  const q = String(query || '').toLowerCase().trim();
2699
2729
  const g = String(group || '').toLowerCase().trim();
@@ -2717,6 +2747,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
2717
2747
  // update_meta_ad, edit_meta): kept as one literal token it matched nothing and filed a dead end, while its parts
2718
2748
  // (meta + campaigns, update + meta) name real tools. The literal still scores first when it exists.
2719
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;
2720
2753
  const descLc = desc.toLowerCase();
2721
2754
  const nameTokens = name.split('_');
2722
2755
  let nameHits = 0, covered = 0;
@@ -3119,7 +3152,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3119
3152
  _meta: openaiMeta(CAPABILITIES_URI, 'Loading the model catalog…', 'Model catalog ready'),
3120
3153
  }, wrap(async () => {
3121
3154
  const d = await apiGet('/api/generate/status');
3122
- const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
3155
+ const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.creditsBySize && Object.keys(m.creditsBySize).length > 1 ? `, imageSize ${Object.entries(m.creditsBySize).map(([s, c]) => `${s}=${c}cr`).join(' ')}` : m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
3123
3156
  // durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
3124
3157
  // prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
3125
3158
  const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${Array.isArray(m.cameraMoves) && m.cameraMoves.length ? `, camera moves ${m.cameraMoves.map(c => c.id).join('/')} (generate_video cameraMove, or your own cameraTrajectory keyframes)` : ''}${m.best ? ', best' : ''})`).join('; ');
@@ -3996,16 +4029,34 @@ function buildTools(rawServer, opts = {}, sink = null) {
3996
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' };
3997
4030
  server.registerTool('upload_file', {
3998
4031
  title: 'Upload a local file → durable public URL',
3999
- 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}.',
4000
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.'),
4001
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.'),
4002
- 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)') }),
4003
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'),
4004
4047
  name: z.string().optional().describe('original file name — helps pick the right extension'),
4005
4048
  },
4006
- 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() },
4007
4050
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4008
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
+ }
4009
4060
  // EXACTLY ONE SOURCE. Two is an ERROR: a caller who passes both has two different files in mind, and quietly
4010
4061
  // preferring one of them ingests the wrong file and reports success.
4011
4062
  const given = ['url', 'path', 'dataUri'].filter(k => String(a[k] || '').trim());
@@ -4270,8 +4321,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
4270
4321
  target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account'),
4271
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.'),
4272
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.'),
4273
- 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.'),
4274
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.'),
4275
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.'),
4276
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.)'),
4277
4350
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
@@ -4301,7 +4374,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4301
4374
  // THE COLLAB LINE IS THE READ-BACK, NEVER THE ASK. `collaboratorNote` is built from what Instagram said
4302
4375
  // about each invite; printing "posted with @x" off the request would tell the user their post is live on
4303
4376
  // an account that has not accepted it — and may never.
4304
- 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);
4305
4378
  }));
4306
4379
  // ── SCHEDULING (2026-07-30). ONE mechanism for every channel — our durable queue, not a per-platform special case.
4307
4380
  // Dave: "if only Facebook can do scheduling, then maybe we just do all the scheduling ourselves. There's probably
@@ -4382,12 +4455,35 @@ function buildTools(rawServer, opts = {}, sink = null) {
4382
4455
  // reachable when you publish NOW, unreachable when you schedule, and invisible until the parity sweep named it.
4383
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.'),
4384
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.'),
4385
- 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.'),
4386
4459
  // AN X ARTICLE, SCHEDULED (2026-09-12). A mode of the x channel, not a channel: the X text is the markdown body
4387
4460
  // and the image is the cover, so the only new fields are the two the Article itself adds.
4388
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.'),
4389
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.'),
4390
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.'),
4391
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.'),
4392
4488
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
4393
4489
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
@@ -4410,6 +4506,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4410
4506
  title: 'List scheduled and past posts',
4411
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.',
4412
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.'),
4413
4510
  id: z.string().optional().describe('one post id from this list: returns that post in full, every caption and setting included'),
4414
4511
  channel: z.string().optional().describe('only posts that include this channel, e.g. "pinterest" or "x"'),
4415
4512
  upcoming: z.number().optional().describe('how many queued posts to list, soonest first (default 25, max 200)'),
@@ -4420,8 +4517,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
4420
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(),
4421
4518
  },
4422
4519
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4423
- }, wrap(async ({ id, channel, upcoming, fired } = {}) => {
4424
- const d = await apiGet('/api/schedule', {});
4520
+ }, wrap(async ({ id, channel, upcoming, fired, brand } = {}) => {
4521
+ const d = await apiGet('/api/schedule', brand ? { brandId: brand } : {});
4425
4522
  // ONE POST IN FULL (2026-09-13). The compact list below shortens captions, and reschedule_post's `captions`
4426
4523
  // replaces the WHOLE per-channel map, so an agent changing one caption needs the full row first.
4427
4524
  if (id) {
@@ -4480,6 +4577,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4480
4577
  title: 'Change a scheduled post',
4481
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.',
4482
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.'),
4483
4581
  id: z.string().describe('the scheduled post id from list_scheduled'),
4484
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.'),
4485
4583
  message: z.string().optional().describe('replace the caption used for every channel that has no override'),
@@ -4528,10 +4626,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
4528
4626
  madeWithAi: z.boolean().optional().describe('X — the AI-media label; false turns it off.'),
4529
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.'),
4530
4628
  communityId: z.string().optional().describe('X — the community to publish into; an empty string goes back to the main timeline.'),
4531
- 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.'),
4532
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.'),
4533
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.'),
4534
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.'),
4535
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.'),
4536
4649
  boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
4537
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.'),
@@ -4551,11 +4664,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
4551
4664
  server.registerTool('cancel_scheduled', {
4552
4665
  title: 'Cancel a scheduled post',
4553
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).',
4554
- 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.') },
4555
4672
  outputSchema: { cancelled: z.string().optional() },
4556
4673
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
4557
4674
  }, wrap(async (a) => {
4558
- 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)}` : ''}`);
4559
4676
  return ok(`Cancelled ${d.cancelled}.`, d);
4560
4677
  }));
4561
4678
  // ── RETRY + DUPLICATE (2026-08-05) ────────────────────────────────────────────────────────────────────────────
@@ -4567,6 +4684,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4567
4684
  title: 'Retry a failed scheduled post',
4568
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.',
4569
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.'),
4570
4688
  id: z.string().describe('the scheduled post id from list_scheduled'),
4571
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)'),
4572
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.'),
@@ -4590,6 +4708,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4590
4708
  title: 'Duplicate a scheduled post',
4591
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.',
4592
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.'),
4593
4712
  id: z.string().describe('the post to copy, from list_scheduled'),
4594
4713
  at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
4595
4714
  useQueue: z.boolean().optional().describe('instead of naming a time, take the brand’s next free posting slot'),
@@ -10046,7 +10165,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10046
10165
  });
10047
10166
  server.registerTool('list_openai_ads_campaigns', {
10048
10167
  title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
10049
- 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.',
10050
10169
  inputSchema: {
10051
10170
  campaignId: z.string().optional().describe('list this campaign’s ad groups'),
10052
10171
  adGroupId: z.string().optional().describe('list this ad group’s ads'),
@@ -10057,23 +10176,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
10057
10176
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
10058
10177
  }, wrap(async (a) => {
10059
10178
  const d = await apiGet('/api/openai-ads/campaigns', a);
10060
- 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);
10061
- 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);
10062
10182
  const acc = d.account;
10063
- 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);
10064
10184
  }));
10065
10185
  server.registerTool('openai_ads_report', {
10066
10186
  title: 'ChatGPT Ads performance report',
10067
- 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.',
10068
10188
  inputSchema: {
10069
10189
  campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
10070
10190
  since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
10071
10191
  granularity: z.enum(['hourly', 'daily', 'monthly', 'none']).optional().describe('default daily'),
10072
10192
  level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('roll rows up to this level'),
10073
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'),
10074
- 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`'),
10075
10201
  },
10076
- 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() },
10077
10203
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
10078
10204
  }, wrap(async (a) => {
10079
10205
  const d = await apiPost('/api/openai-ads/report', a);
@@ -11174,17 +11300,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
11174
11300
  inputSchema: {
11175
11301
  name: z.string(),
11176
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'),
11177
- 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)'),
11178
11304
  customEventName: z.string().optional().describe('for a non-standard event'),
11179
- attributionWindowDays: z.number().optional().describe('1-90'),
11305
+ attributionWindowDays: z.number().optional().describe('default 30, which is what OpenAI recommends; 1-90'),
11180
11306
  },
11181
11307
  outputSchema: { conversionEventSettingId: z.string().optional(), name: z.string().optional(), eventType: z.string().optional(), note: z.string().optional() },
11182
11308
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
11183
11309
  }, wrap(async (a) => { const d = await apiPost('/api/openai-ads/conversion-event', a); return ok(d.note, d); }));
11184
11310
  server.registerTool('list_openai_ads_audiences', {
11185
11311
  title: 'List ChatGPT Ads custom audiences',
11186
- description: 'List the custom audiences on the connected ChatGPT Ads account. Read-only, free.',
11187
- 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') },
11188
11314
  outputSchema: { count: z.number().optional(), audiences: z.array(z.any()).optional(), note: z.string().optional() },
11189
11315
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
11190
11316
  }, wrap(async (a) => {
@@ -11193,13 +11319,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
11193
11319
  }));
11194
11320
  server.registerTool('create_openai_ads_audience', {
11195
11321
  title: 'Create a ChatGPT Ads custom audience',
11196
- 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.',
11197
11323
  inputSchema: {
11198
11324
  name: z.string(),
11199
- 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'),
11200
11330
  description: z.string().optional(),
11201
11331
  },
11202
- 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() },
11203
11333
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
11204
11334
  }, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience', a); return ok(d.note, d); }));
11205
11335
  server.registerTool('get_openai_ads_audience', {
@@ -11310,6 +11440,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
11310
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.'),
11311
11441
  billingEvent: z.enum(['click', 'impression']).optional().describe('default click'),
11312
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.'),
11313
11445
  status: z.enum(['active', 'paused']).optional().describe('default paused'),
11314
11446
  confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE under a live campaign (real spend)'),
11315
11447
  },
@@ -11325,6 +11457,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11325
11457
  inputSchema: {
11326
11458
  adGroupId: z.string(), name: z.string().optional().describe('internal name — defaults to the title'),
11327
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.'),
11328
11461
  status: z.enum(['active', 'paused']).optional().describe('default paused'),
11329
11462
  confirm: z.boolean().optional().describe('REQUIRED true to create this ACTIVE in a live ad group (real spend)'),
11330
11463
  },
@@ -11384,6 +11517,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
11384
11517
  maxBid: z.number().optional(), billingEvent: z.enum(['click', 'impression']).optional().describe('required alongside maxBid — bidding is replaced wholesale'),
11385
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.'),
11386
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.'),
11387
11522
  confirm: z.boolean().optional().describe('REQUIRED true to change budget / bid / creative on a LIVE object'),
11388
11523
  },
11389
11524
  outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), object: z.any().optional(), note: z.string().optional() },
@@ -11404,7 +11539,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11404
11539
  }));
11405
11540
  server.registerTool('openai_ads_conversions', {
11406
11541
  title: 'ChatGPT Ads attributed conversions',
11407
- 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.',
11408
11543
  inputSchema: {
11409
11544
  level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('inferred from which id you pass — default ad_account'),
11410
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'),
@@ -13179,7 +13314,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
13179
13314
  scheduleType: z.enum(['SCHEDULE_FROM_NOW', 'SCHEDULE_START_END']),
13180
13315
  scheduleStartTime: z.string().describe('YYYY-MM-DD HH:MM:SS in the advertiser timezone'),
13181
13316
  scheduleEndTime: z.string().optional(),
13182
- 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)'),
13183
13325
  },
13184
13326
  outputSchema: { adGroupId: z.string().optional(), summary: z.string().optional() },
13185
13327
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -13190,7 +13332,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
13190
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.',
13191
13333
  inputSchema: {
13192
13334
  adGroupId: z.string().describe('from create_tiktok_smart_ad_group'),
13193
- 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, …)'),
13194
13341
  advertiserId: z.string().optional(),
13195
13342
  },
13196
13343
  outputSchema: { adIds: z.array(z.string()).optional(), summary: z.string().optional() },
@@ -13200,7 +13347,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13200
13347
  server.registerTool('list_tiktok_smart_campaigns', {
13201
13348
  title: 'List TikTok Smart+ campaigns',
13202
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.',
13203
- 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') },
13204
13351
  outputSchema: { count: z.number().optional(), summary: z.string().optional() },
13205
13352
  annotations: { destructiveHint: false, readOnlyHint: true, openWorldHint: true },
13206
13353
  }, wrap(async (a) => { const d = await apiGet('/api/tiktok-ads/smart/campaigns', a); return ok(d.summary, d); }));
@@ -13221,12 +13368,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
13221
13368
 
13222
13369
  server.registerTool('set_tiktok_smart_status', {
13223
13370
  title: 'Enable, pause or delete a Smart+ object',
13224
- 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.',
13225
13372
  inputSchema: {
13226
13373
  level: z.enum(['campaign', 'adgroup', 'ad']),
13227
13374
  ids: z.union([z.string(), z.array(z.string())]),
13228
13375
  status: z.enum(['ENABLE', 'DISABLE', 'DELETE']),
13229
- confirm: z.boolean().optional().describe('required for ENABLE — real money'),
13376
+ confirm: z.boolean().optional().describe('required for ENABLE (real money) and DELETE (permanent)'),
13230
13377
  advertiserId: z.string().optional(),
13231
13378
  },
13232
13379
  outputSchema: { summary: z.string().optional() },
@@ -15075,6 +15222,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15075
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.'),
15076
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.'),
15077
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.'),
15078
15226
  },
15079
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() },
15080
15228
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -15615,6 +15763,52 @@ function buildTools(rawServer, opts = {}, sink = null) {
15615
15763
  const d = await apiPost('/api/drive/save', a);
15616
15764
  return ok(d.note || `Saved ${(d.files || []).length} file(s) to Drive.`, d);
15617
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
+
15618
15812
  server.registerTool('list_drive_files', {
15619
15813
  title: 'List Google Drive files',
15620
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.',
@@ -16136,7 +16330,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16136
16330
  raw: z.boolean().optional().describe('RAW MODEL ACCESS: run the caller’s prompt on the named model with no Hermoso adjustments at all — the prompt reaches the provider byte-identical (no hex-to-colour-name rewrite, no prepended fidelity preamble) and NO saved-brand product photos are attached, so the model you name is the model that renders. Use it to drive the raw catalog; leave it off for an on-brand ad. Billing, the durable Library landing and per-model validation are unchanged.'),
16137
16331
  aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
16138
16332
  model: z.string().optional().describe('image model id from hermoso_capabilities'),
16139
- imageSize: z.string().optional().describe('pixel-size preset for models that support it (e.g. 1K/2K) — omit for the default'),
16333
+ imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
16140
16334
  },
16141
16335
  outputSchema: {
16142
16336
  image: z.string().optional().describe('the served absolute URL of the finished image'),
@@ -16150,7 +16344,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16150
16344
  // silently turns off the brand pipeline on an on-brand ad (the same rule lib/raw-passthrough.mjs's predicate uses).
16151
16345
  const d = await apiPost('/api/generate/image', { prompt, refImages: refs, useBrand: useBrand !== false, aspectRatio, model, imageSize, ...(raw === true ? { raw: true } : {}) }); // explicit boolean so the server's saved-brand hydration default is unambiguous
16152
16346
  const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
16153
- return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}${switchNote({ raw: d })}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
16347
+ return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}${switchNote({ raw: d })}${d.productNote ? `\n${d.productNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
16154
16348
  }));
16155
16349
 
16156
16350
  // ---------- YouTube / social thumbnails + video covers ----------
@@ -16571,12 +16765,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
16571
16765
  prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
16572
16766
  raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence. Billing, durable delivery and per-model validation are unchanged.'),
16573
16767
  refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
16574
- refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the omni engine accepts a raw clip and transforms it per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit to generate a fresh clip."),
16768
+ refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the clip is transformed per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit `model` for the default editor, or name a model whose videoEdit is true in hermoso_capabilities (a named model that cannot edit is refused, nothing charged); refImage rides along as the look of what the edit adds. With extend:true this is instead the clip to EXTEND. Omit to generate a fresh clip."),
16769
+ endImage: z.string().optional().describe('local path or URL of the LAST frame: the clip travels from refImage (required with it) to this image. Only models with endFrame true in hermoso_capabilities take it; any other named model is refused by name, nothing charged.'),
16770
+ loop: z.boolean().optional().describe('true = a seamless loop whose last frame flows back into its first. Only models with loop true in hermoso_capabilities; needs refImage and cannot be combined with endImage.'),
16771
+ shots: z.array(z.object({ prompt: z.string().describe('what happens in this shot'), seconds: z.number().int().min(1).max(15).describe('this shot’s length in whole seconds') })).optional().describe('MULTI-SHOT: one clip cut into these shots, in order. The seconds must add up to a length the model renders; replaces `prompt` (send either). Only models with multiShot true in hermoso_capabilities.'),
16772
+ extend: z.boolean().optional().describe('true = EXTEND refVideo: the same clip continues per your prompt for durationSeconds more (each model’s extend.minSeconds..maxSeconds in hermoso_capabilities), delivered as ONE clip, source then continuation. Needs model named (a model with extend in hermoso_capabilities).'),
16773
+ interactionId: z.string().optional().describe('with extend:true on an Omni model: the interactionId returned by an earlier render on that model — continues it from its own stored context instead of re-uploading refVideo.'),
16575
16774
  durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the CHOSEN model’s listed durations from hermoso_capabilities (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 40s = 15+15+10).'),
16576
16775
  aspectRatio: z.string().optional().describe("default '9:16'"),
16577
16776
  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'),
16578
16777
  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."),
16579
- 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.'),
16778
+ 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.'),
16580
16779
  cameraTrajectory: z.array(z.object({
16581
16780
  time: z.number().min(0).max(1).describe('when this pose is reached, 0 = start of the clip, 1 = end'),
16582
16781
  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)'),
@@ -16629,10 +16828,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
16629
16828
  }
16630
16829
  }
16631
16830
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
16831
+ if (a.endImage) a = { ...a, endImage: await toRef(a.endImage) };
16632
16832
  // an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
16633
16833
  // treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
16634
16834
  const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model, ...(a.cameraTrajectory ? { cameraTrajectory: a.cameraTrajectory } : a.cameraMove ? { cameraTrajectory: a.cameraMove } : {}) }, 'MCP video');
16635
- return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${switchNote(r)}`, r);
16835
+ const _iid = renderPayload(r)?.interactionId;
16836
+ return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_iid ? `\ninteractionId: ${_iid} (pass it with extend:true on the same model to continue this clip)` : ''}${switchNote(r)}`, r);
16636
16837
  }));
16637
16838
 
16638
16839
  server.registerTool('generate_avatar', {
@@ -16661,7 +16862,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16661
16862
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
16662
16863
  voiceover: z.string().optional().describe('full voiceover script spoken across the scenes'),
16663
16864
  voice: z.string().optional().describe('voiceover voice name, e.g. Rachel / George'),
16664
- resolution: z.string().optional().describe('1080p (default), or 480p/720p for a cheaper draft'),
16865
+ resolution: z.string().optional().describe('720p (default), 1080p for full detail, or 480p for a cheaper draft'),
16665
16866
  model: z.string().optional().describe('video model id from hermoso_capabilities — omit to let the router pick'),
16666
16867
  durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
16667
16868
  },
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.249",
3
+ "version": "0.1.251",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 840 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"