hermoso 0.1.366 → 0.1.372
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 +15 -2
- package/mcp/client.mjs +36 -8
- package/mcp/roster-scope.mjs +1 -1
- package/mcp/tools.mjs +452 -170
- package/package.json +2 -2
package/mcp/tools.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
|
|
8
|
-
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT } from './client.mjs';
|
|
8
|
+
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT, brandScope } from './client.mjs';
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -748,6 +748,17 @@ const MANAGE_BRAND = {
|
|
|
748
748
|
brand: z.string().optional().describe('WHICH BRAND the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a brand this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no brand, or two, is REFUSED and nothing is done.'),
|
|
749
749
|
};
|
|
750
750
|
const namedBrand = (a) => (a && typeof a.brand === 'string' && a.brand.trim() ? a.brand.trim() : '');
|
|
751
|
+
// A BRAND NAMED ON A WORKSPACE-STORE TOOL (2026-09-30). `update_brand {brand:'pmuojpxnfrx0', voice}` reached the server
|
|
752
|
+
// through call_tool, `brand` was stripped as undeclared, and the voice was written onto the connection's PINNED brand —
|
|
753
|
+
// another brand's voice, overwritten silently. The store tools (brand profile, memory, skills, cast, playbooks,
|
|
754
|
+
// swipefile) now take the same per-call brand as the publish tools, and `inBrand` runs the whole handler inside the
|
|
755
|
+
// client's brandScope, so EVERY request it makes — resolving the store namespace, the read, the write, the tombstone —
|
|
756
|
+
// carries `?brandId=` and lands in that brand. tools/brand-write-honoured-check.mjs runs every write tool that declares
|
|
757
|
+
// a brand and fails when any request it makes goes out without it.
|
|
758
|
+
const STORE_BRAND = {
|
|
759
|
+
brand: z.string().optional().describe('brand id/name from list_brands, this call only'),
|
|
760
|
+
};
|
|
761
|
+
const inBrand = (fn) => async (a, extra) => (namedBrand(a) ? brandScope.run({ brand: namedBrand(a) }, () => fn(a, extra)) : fn(a, extra));
|
|
751
762
|
// POST body: the belt reads a string `brand` and deletes it before the route sees the body.
|
|
752
763
|
const bodyBrand = (a, body = {}) => (namedBrand(a) ? { ...body, brand: namedBrand(a) } : body);
|
|
753
764
|
// GET params: the query spelling is `brandId` only.
|
|
@@ -800,22 +811,45 @@ const UI_MIME = 'text/html+skybridge';
|
|
|
800
811
|
// (adapters/blob/gcs.js -> storage.googleapis.com/<GEN_BUCKET>, adapters/blob/r2.js -> <bucket>.r2.dev). Those
|
|
801
812
|
// defaults are what a config flip lands on, so they are covered too. The npm twin ships with none of this env
|
|
802
813
|
// set, so the production origins ride as a static floor rather than vanishing off a stdio install.
|
|
803
|
-
|
|
814
|
+
// THE WIDGET CSP NAMES OUR OWN ORIGINS AND NOTHING ELSE (2026-09-30). OpenAI rejected the plugin: "a template where
|
|
815
|
+
// a CSP is either missing or too broad". Their rule (developers.openai.com/apps-sdk/build/chatgpt-ui, CSP section)
|
|
816
|
+
// is "declare the exact domains the component connects to or loads resources from", and the plugin guidelines add
|
|
817
|
+
// that "separate tenants on a shared hosting service count as different domains". The old list named
|
|
818
|
+
// `https://*.r2.dev` and bare `https://storage.googleapis.com`: a wildcard over every R2 bucket on the internet and
|
|
819
|
+
// a host every Google Cloud customer serves from. Both are gone. Production assets are on assets.hermoso.ai (the R2
|
|
820
|
+
// public base) and app.hermoso.ai (/generated, /api/img, /api/video/poster), so those two are the floor; an env base
|
|
821
|
+
// is added only when it is a dedicated https origin, never a shared-hosting host or a loopback. The ad card routes
|
|
822
|
+
// any picture that is NOT on these origins through app.hermoso.ai/api/img (see ownSrc in AD_RESULT_HTML), so
|
|
823
|
+
// narrowing the list cannot paint an empty frame. Pinned by tools/tool-annotations-check.mjs + apps-widget-delivery.
|
|
824
|
+
const WIDGET_MEDIA_FLOOR = ['https://app.hermoso.ai', 'https://assets.hermoso.ai'];
|
|
825
|
+
const SHARED_HOSTING_RE = /(^|\.)(storage\.googleapis\.com|googleusercontent\.com|r2\.dev|r2\.cloudflarestorage\.com|amazonaws\.com|cloudfront\.net|blob\.core\.windows\.net|run\.app|pages\.dev|workers\.dev|vercel\.app|netlify\.app)$/i;
|
|
804
826
|
export const widgetMediaOrigins = (env = process.env, apiBase = API_BASE) => {
|
|
805
827
|
const out = [];
|
|
806
828
|
const add = (u) => {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
if (
|
|
810
|
-
if (
|
|
829
|
+
let o; try { o = new URL(String(u || '')); } catch { return; }
|
|
830
|
+
if (o.protocol !== 'https:' || o.hostname.includes('*')) return;
|
|
831
|
+
if (/^(localhost|127\.|0\.0\.0\.0|\[::1\])/i.test(o.hostname)) return; // no remote iframe reaches a dev box
|
|
832
|
+
if (SHARED_HOSTING_RE.test(o.hostname)) return; // a shared host is everyone's, not ours
|
|
833
|
+
if (!out.includes(o.origin)) out.push(o.origin);
|
|
811
834
|
};
|
|
812
835
|
WIDGET_MEDIA_FLOOR.forEach(add);
|
|
813
|
-
add(env.GEN_PUBLIC_BASE); add(env.
|
|
814
|
-
add(env.R2_PUBLIC_BASE); add(env.R2_BUCKET && 'https://' + env.R2_BUCKET + '.r2.dev');
|
|
815
|
-
add(env.APP_URL); add(env.PUBLIC_BASE); add(apiBase);
|
|
836
|
+
add(env.GEN_PUBLIC_BASE); add(env.R2_PUBLIC_BASE); add(env.APP_URL); add(env.PUBLIC_BASE); add(apiBase);
|
|
816
837
|
return out;
|
|
817
838
|
};
|
|
818
|
-
|
|
839
|
+
// The dedicated sandbox origin ChatGPT hosts our components on. OpenAI's reference: `_meta.ui.domain` is "required
|
|
840
|
+
// when submitting a plugin with UI; must be unique per plugin". It is the MCP server's own origin.
|
|
841
|
+
export const WIDGET_DOMAIN = 'https://app.hermoso.ai';
|
|
842
|
+
// Every resource carries the CSP in BOTH shapes: the standard `_meta.ui.csp` (camelCase, "preferred") and the legacy
|
|
843
|
+
// `openai/widgetCSP` (snake_case), which alone carries `redirect_domains` — the ad card's Download goes through
|
|
844
|
+
// window.openai.openExternal to an asset URL on these same origins. No widget fetches anything (connectDomains is
|
|
845
|
+
// empty: polling is window.openai.callTool, not a network request) and none embeds a frame.
|
|
846
|
+
export const widgetResourceMeta = (description, origins = widgetMediaOrigins()) => ({
|
|
847
|
+
ui: { prefersBorder: true, domain: WIDGET_DOMAIN, csp: { connectDomains: [], resourceDomains: [...origins] } },
|
|
848
|
+
'openai/widgetDescription': description,
|
|
849
|
+
'openai/widgetPrefersBorder': true,
|
|
850
|
+
'openai/widgetDomain': WIDGET_DOMAIN,
|
|
851
|
+
'openai/widgetCSP': { connect_domains: [], resource_domains: [...origins], redirect_domains: [...origins] },
|
|
852
|
+
});
|
|
819
853
|
const openaiMeta = (template, invoking, invoked) => ({ 'openai/outputTemplate': template, 'openai/toolInvocation/invoking': invoking, 'openai/toolInvocation/invoked': invoked }); // status strings ≤64 chars
|
|
820
854
|
|
|
821
855
|
// String.raw so regex backslashes inside the inline widget JS survive the template literal (no ${} used).
|
|
@@ -910,6 +944,12 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
|
|
|
910
944
|
// Reserve the right box BEFORE the media loads, so the card does not jump. Only when both dimensions were
|
|
911
945
|
// actually probed: a guessed aspect is worse than none, because it would letterbox correctly-shaped media.
|
|
912
946
|
function arAttr(v) { return v.w > 0 && v.h > 0 ? ' data-ar style="--ar:' + v.w + '/' + v.h + '"' : ''; }
|
|
947
|
+
// THE CSP NAMES ONLY OUR OWN ORIGINS (see widgetResourceMeta), so a picture anywhere else -- an older render kept
|
|
948
|
+
// on a shared bucket, a reference someone pasted -- is fetched through our own image proxy instead of painting an
|
|
949
|
+
// empty frame, and a video off our origins (the proxy carries pictures only) becomes a link to open it.
|
|
950
|
+
var OWN_HOST_RE = /(^|\.)hermoso\.ai$/i;
|
|
951
|
+
function isOwn(u) { var m = /^https:\/\/([^\/?#:]+)/i.exec(String(u || '')); return !!(m && OWN_HOST_RE.test(m[1])); }
|
|
952
|
+
function ownSrc(u) { return !u ? '' : isOwn(u) ? u : 'https://app.hermoso.ai/api/img?url=' + encodeURIComponent(u); }
|
|
913
953
|
function looksVideo(u) { return /\.(mp4|webm|mov|m4v)([?#]|$)/i.test(String(u || '')); }
|
|
914
954
|
function urlOf(x) { return typeof x === 'string' ? x : ((x && (x.image || x.url || x.src)) || ''); }
|
|
915
955
|
function picks(out, raw) {
|
|
@@ -1133,9 +1173,10 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
|
|
|
1133
1173
|
: (dlUrl ? '<a class="dl" href="' + esc(dlUrl) + '" download target="_blank" rel="noopener">Download</a>' : '');
|
|
1134
1174
|
var footer = '<div class="meta">' + pills + '<span class="spacer"></span>' + dl + '<span class="wordmark">Hermoso</span></div>';
|
|
1135
1175
|
var body;
|
|
1136
|
-
if (v.slides) body = '<div class="grid">' + v.slides.map(function (u) { return '<img src="' + esc(u) + '" alt="rendered image" loading="lazy">'; }).join('') + '</div>';
|
|
1137
|
-
else if (v.video) body = '<div class="media"' + arAttr(v) + '
|
|
1138
|
-
else if (v.
|
|
1176
|
+
if (v.slides) body = '<div class="grid">' + v.slides.map(function (u) { return '<img src="' + esc(ownSrc(u)) + '" alt="rendered image" loading="lazy">'; }).join('') + '</div>';
|
|
1177
|
+
else if (v.video && !isOwn(v.video)) body = '<div class="media"' + arAttr(v) + '>' + (v.poster ? '<img src="' + esc(ownSrc(v.poster)) + '" alt="video poster">' : '') + '</div><div class="empty"><a href="' + esc(v.video) + '" target="_blank" rel="noopener">Open the video</a></div>';
|
|
1178
|
+
else if (v.video) body = '<div class="media"' + arAttr(v) + '><video controls muted autoplay loop playsinline preload="metadata"' + (v.poster ? ' poster="' + esc(ownSrc(v.poster)) + '"' : '') + ' src="' + esc(v.video) + '"></video></div>';
|
|
1179
|
+
else if (v.image) body = '<div class="media"' + arAttr(v) + '><img src="' + esc(ownSrc(v.image)) + '" alt="generated ad"></div>';
|
|
1139
1180
|
else if (v.notFound) body = '<div class="empty bad">' + (v.jobId ? 'Job ' + esc(v.jobId) + ' does not exist' : 'That job does not exist') + '. Nothing is rendering under that id — ask me to list your recent renders.</div>';
|
|
1140
1181
|
// Testing v.status too matters: a failed job whose message field is empty fell through EVERY branch,
|
|
1141
1182
|
// so body stayed undefined, the card wrote innerHTML='' and reported zero height — a failure rendered as
|
|
@@ -1539,9 +1580,9 @@ const AD_SPY_MAX_CARDS = 24;
|
|
|
1539
1580
|
// FUTURE narrowing of widgetMediaOrigins: today every candidate is one of the values that list is built FROM, so
|
|
1540
1581
|
// no environment can reach it. Buried inside the env reader it was untestable, i.e. a comment in the costume of a
|
|
1541
1582
|
// guard; split out, the check hands it a candidate the list does not contain and watches it refuse. The candidates
|
|
1542
|
-
// are a SEPARATE list from the allowlist on purpose -- the allowlist also holds
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1583
|
+
// are a SEPARATE list from the allowlist on purpose -- the allowlist also holds the asset host (assets.hermoso.ai),
|
|
1584
|
+
// which serves files and does NOT run /api/img, so iterating it blindly would hand the card a proxy origin with no
|
|
1585
|
+
// proxy on it.
|
|
1545
1586
|
export const proxyBaseFrom = (candidates, allowed) => {
|
|
1546
1587
|
for (const cand of candidates) {
|
|
1547
1588
|
let u; try { u = new URL(String(cand || '')); } catch { continue; }
|
|
@@ -1650,12 +1691,12 @@ const AD_SPY_URI = widgetAlias('ad-spy', AD_SPY_HTML);
|
|
|
1650
1691
|
|
|
1651
1692
|
function registerAppResources(server) {
|
|
1652
1693
|
const reg = (name, uri, description, html) => {
|
|
1653
|
-
const meta =
|
|
1694
|
+
const meta = widgetResourceMeta(description);
|
|
1654
1695
|
server.registerResource(name, uri, { description, mimeType: UI_MIME, _meta: meta },
|
|
1655
1696
|
async () => ({ contents: [{ uri, mimeType: UI_MIME, text: html, _meta: meta }] }));
|
|
1656
1697
|
};
|
|
1657
1698
|
const regAnyHash = (name, slug, description, html) => {
|
|
1658
|
-
const meta =
|
|
1699
|
+
const meta = widgetResourceMeta(description);
|
|
1659
1700
|
server.registerResource(name, new ResourceTemplate('ui://widget/' + slug + '-{hash}.html', { list: undefined }),
|
|
1660
1701
|
{ description, mimeType: UI_MIME, _meta: meta },
|
|
1661
1702
|
async (uri) => ({ contents: [{ uri: String(uri), mimeType: UI_MIME, text: html, _meta: meta }] }));
|
|
@@ -2263,6 +2304,34 @@ export const withStaleNote = (out, ctx) => {
|
|
|
2263
2304
|
if (last) last.text = `${last.text}\n\n${STALE_ROSTER_NOTE}`; else out.content.push({ type: 'text', text: STALE_ROSTER_NOTE });
|
|
2264
2305
|
return out;
|
|
2265
2306
|
};
|
|
2307
|
+
// ── AN UNDECLARED ARGUMENT IS REFUSED, NEVER STRIPPED (2026-09-30) ──────────────────────────────────────────────
|
|
2308
|
+
// Measured on the hosted connector: `call_tool {name:'update_brand', args:{brand:'pmuojpxnfrx0', voice:…}}` — update_brand
|
|
2309
|
+
// did not declare `brand`, zod's default object STRIPS unknown keys, so the call validated, `brand` vanished, and the
|
|
2310
|
+
// voice was written onto the connection's pinned brand: somebody else's. call_tool's own description promises "a
|
|
2311
|
+
// mistake is answered with the expected parameters, not a silent default", and a dropped key is exactly a silent
|
|
2312
|
+
// default. So every executor WE own (call_tool, a held tool called directly, a legacy name) refuses a key the tool does
|
|
2313
|
+
// not declare and names the ones it does. A schema that is deliberately loose (a catchall other than never) is left
|
|
2314
|
+
// alone: accepting extra keys is that tool's contract. A host's normal tools/call is untouched — the advertised schema
|
|
2315
|
+
// already says additionalProperties:false there.
|
|
2316
|
+
export const undeclaredArgs = (h, input) => {
|
|
2317
|
+
if (!h || !input || typeof input !== 'object' || Array.isArray(input)) return [];
|
|
2318
|
+
const keys = Object.keys(input);
|
|
2319
|
+
if (!keys.length) return [];
|
|
2320
|
+
const s = h.inputSchema;
|
|
2321
|
+
if (!s) return keys; // the tool takes no arguments at all
|
|
2322
|
+
const shape = typeof s.safeParse === 'function' ? s.shape : null;
|
|
2323
|
+
if (!shape || typeof shape !== 'object') return [];
|
|
2324
|
+
const cat = s._def && s._def.catchall;
|
|
2325
|
+
const catType = cat && cat._def ? String(cat._def.type || cat._def.typeName || '') : '';
|
|
2326
|
+
if (catType && !/never/i.test(catType)) return [];
|
|
2327
|
+
return keys.filter((k) => !Object.prototype.hasOwnProperty.call(shape, k));
|
|
2328
|
+
};
|
|
2329
|
+
export const undeclaredArgsText = (name, extra, h) => {
|
|
2330
|
+
const shape = h && h.inputSchema && h.inputSchema.shape;
|
|
2331
|
+
const takes = shape && typeof shape === 'object' ? Object.keys(shape) : [];
|
|
2332
|
+
const brandNote = extra.includes('brand') ? ` ${name} acts on the brand this connection is on — pin another with use_brand first, then call it again.` : '';
|
|
2333
|
+
return `${name} does not take ${extra.map((k) => `\`${k}\``).join(', ')}, so nothing was run: an argument a tool does not declare would be silently ignored.${brandNote} It takes: ${takes.length ? takes.join(', ') : '(no arguments)'}.`;
|
|
2334
|
+
};
|
|
2266
2335
|
export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
2267
2336
|
const spec = LEGACY_TOOL_NAMES[name];
|
|
2268
2337
|
reportDeadEnd('stale_roster', name, `${name} no longer exists — the host's tool list is an older published snapshot`);
|
|
@@ -2276,6 +2345,7 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
|
2276
2345
|
const fn = h && (h.handler || h.callback);
|
|
2277
2346
|
if (typeof fn !== 'function') return withStaleNote({ content: [{ type: 'text', text: `${name} was renamed to ${spec.to}, which is not part of this session's roster.` }], isError: true }, ctx);
|
|
2278
2347
|
let input = request?.params?.arguments && typeof request.params.arguments === 'object' ? request.params.arguments : {};
|
|
2348
|
+
{ const extraKeys = undeclaredArgs(h, input); if (extraKeys.length) return withStaleNote({ content: [{ type: 'text', text: undeclaredArgsText(spec.to, extraKeys, h) }], isError: true }, ctx); }
|
|
2279
2349
|
if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
|
|
2280
2350
|
const parsed = h.inputSchema.safeParse(input);
|
|
2281
2351
|
if (!parsed.success) {
|
|
@@ -2390,6 +2460,17 @@ const SEND_OUTPUT_SCHEMA = false;
|
|
|
2390
2460
|
|
|
2391
2461
|
// Identity-preserving when there is nothing to strip, which is what keeps the cached canon a single shared
|
|
2392
2462
|
// object rather than a fresh copy per session.
|
|
2463
|
+
// EVERY HINT IS AN EXPLICIT BOOLEAN ON EVERY TOOL (2026-09-30, OpenAI rejected the plugin for annotations that were
|
|
2464
|
+
// "not explicitly set to true or false (not null) for every tool"). 546 defs carried no idempotentHint. A read
|
|
2465
|
+
// repeats with no extra effect, so it is idempotent; a write is NOT unless its own def says so, because a second
|
|
2466
|
+
// render, post or report is a second one. Filled IN PLACE so the shared canon stays one object per tool.
|
|
2467
|
+
// tools/tool-annotations-check.mjs pins the whole roster and the reviewed ChatGPT table.
|
|
2468
|
+
export function explicitHints(def) {
|
|
2469
|
+
const a = def && def.annotations;
|
|
2470
|
+
if (a && typeof a.idempotentHint !== 'boolean') a.idempotentHint = a.readOnlyHint === true;
|
|
2471
|
+
return def;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2393
2474
|
export function stripOutputSchema(def) {
|
|
2394
2475
|
if (SEND_OUTPUT_SCHEMA || !def || !def.outputSchema) return def;
|
|
2395
2476
|
const { outputSchema: _dropSchema, ...rest } = def;
|
|
@@ -2397,6 +2478,7 @@ export function stripOutputSchema(def) {
|
|
|
2397
2478
|
}
|
|
2398
2479
|
|
|
2399
2480
|
export function defForHost(name, def, widgetHost) {
|
|
2481
|
+
explicitHints(def);
|
|
2400
2482
|
const out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def)) : stripOutputSchema(def);
|
|
2401
2483
|
if (!widgetHost || !out || !WIDGET_WITHHELD_TOOLS.has(name)) return out;
|
|
2402
2484
|
const meta = out._meta;
|
|
@@ -2638,6 +2720,7 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2638
2720
|
const fn = h.handler || h.callback;
|
|
2639
2721
|
if (typeof fn === 'function') {
|
|
2640
2722
|
let input = request?.params?.arguments && typeof request.params.arguments === 'object' ? request.params.arguments : {};
|
|
2723
|
+
{ const extraKeys = undeclaredArgs(h, input); if (extraKeys.length) return { content: [{ type: 'text', text: undeclaredArgsText(name, extraKeys, h) }], isError: true }; }
|
|
2641
2724
|
if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
|
|
2642
2725
|
const parsed = h.inputSchema.safeParse(input);
|
|
2643
2726
|
if (!parsed.success) {
|
|
@@ -3370,7 +3453,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3370
3453
|
.map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
3371
3454
|
if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable that group with enable_tools, then call the tool by name):\n${off.join('\n')}`, { query: q, group: g, offGroup: off.length });
|
|
3372
3455
|
}
|
|
3373
|
-
|
|
3456
|
+
// A query with no word of two or more characters ("a", "?") is a probe, not an ask the catalog failed to answer,
|
|
3457
|
+
// so it answers "nothing found" without landing on the defect board.
|
|
3458
|
+
if (!related && /[a-z0-9]{2}/i.test(String(q || ''))) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
|
|
3374
3459
|
for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
|
|
3375
3460
|
const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
|
|
3376
3461
|
const looser = top.length > total ? top.length - total : 0;
|
|
@@ -3452,6 +3537,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3452
3537
|
// only by a host still holding call_tool from an older scan; a tool this session does not list is refused by name.
|
|
3453
3538
|
if (ctx.widgetHost && h.enabled === false) return { content: [{ type: 'text', text: `${n} is not in your Hermoso tool list on this host, so call_tool will not run it. Use the tools in your list.` }], isError: true };
|
|
3454
3539
|
let input = args && typeof args === 'object' ? args : {};
|
|
3540
|
+
const extraKeys = undeclaredArgs(h, input);
|
|
3541
|
+
if (extraKeys.length) {
|
|
3542
|
+
const params = Object.entries(compactParams(h)).map(([k, v]) => `${k}: ${v}`).join(' | ');
|
|
3543
|
+
return { content: [{ type: 'text', text: `${undeclaredArgsText(n, extraKeys, h)}${params ? ` Expected params: ${params}` : ''}` }], isError: true };
|
|
3544
|
+
}
|
|
3455
3545
|
if (h.inputSchema) {
|
|
3456
3546
|
const parsed = h.inputSchema.safeParse(input);
|
|
3457
3547
|
if (!parsed.success) {
|
|
@@ -3516,7 +3606,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3516
3606
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Bluesky has no cover setting and shows the video\u2019s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad\u2019s empty opening card), sends Bluesky a copy with the first frame replaced by the video\u2019s best frame. Your Library file is never changed. true = send the file exactly as it is.'),
|
|
3517
3607
|
},
|
|
3518
3608
|
outputSchema: { url: z.string().optional(), uri: z.string().optional(), handle: z.string().optional(), note: z.string() },
|
|
3519
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
3609
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
3520
3610
|
}, wrap(async (a) => {
|
|
3521
3611
|
const r = await apiPost('/api/bluesky/post', a);
|
|
3522
3612
|
return ok(r.note || `Posted to Bluesky — ${r.url || ''}`, r);
|
|
@@ -3578,7 +3668,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3578
3668
|
coverAtMs: z.number().optional().describe('THE VIDEO COVER in the chat, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Sent as Telegram’s cover image; beats platformCover.'),
|
|
3579
3669
|
},
|
|
3580
3670
|
outputSchema: { ok: z.boolean().optional(), chatId: z.string().optional(), chatTitle: z.string().optional(), messageId: z.number().optional(), url: z.string().nullable().optional(), album: z.boolean().optional(), slides: z.number().optional(), video: z.boolean().optional(), note: z.string().optional() },
|
|
3581
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
3671
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
3582
3672
|
}, wrap(async (a) => {
|
|
3583
3673
|
const r = await apiPost('/api/telegram/post', a);
|
|
3584
3674
|
return ok(r.note || `Posted to Telegram${r.url ? ` — ${r.url}` : ''}`, r);
|
|
@@ -3834,7 +3924,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3834
3924
|
const _motionLine = _mq(d.toolExamples?.motion) ? `\nRecast motion (recast_motion, billed per output second): tier pro (default) ${_mq(d.toolExamples.motion)}${_mq(d.toolExamples.motionStandard) ? `; tier standard ${_mq(d.toolExamples.motionStandard)}` : ''}` : '';
|
|
3835
3925
|
// THE TALKING-AVATAR ENGINES (generate_avatar `engine`), priced live per second of speech — never a number here.
|
|
3836
3926
|
const _avLine = (d.avatarEngines || []).length ? `\nTalking-avatar engines (generate_avatar engine): ${(d.avatarEngines || []).map(e => `${e.id}${e.default ? ' (default)' : ''} = ${e.label}${e.id === 'natural' ? ` · ${e.model}` : ''}, ~${e.creditsPerSecond}cr per second of speech, ${(e.resolutions || []).join('/')}${e.oneAtATime ? ', renders one video at a time (about ' + e.renderSecondsPerAudioSecond + 'x the speech length plus any wait), speech under 60s' : ''}`).join('; ')}` : '';
|
|
3837
|
-
|
|
3927
|
+
// EXTEND + RESTYLE, read off the same status (2026-10-02): which models continue a clip and by how much per call,
|
|
3928
|
+
// and the one-tap looks restyle_video takes. Derived, never a hand list.
|
|
3929
|
+
const _extLine = (d.options?.video?.models || []).filter(m => m.extend).map(m => `${m.id} +${m.extend.minSeconds}${m.extend.maxSeconds !== m.extend.minSeconds ? `-${m.extend.maxSeconds}` : ''}s`).join(', ');
|
|
3930
|
+
const _rsLine = (d.restyleStyles || []).map(x => x.id).join(', ');
|
|
3931
|
+
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}${_extLine ? `\nExtend a clip (generate_video extend:true + refVideo): ${_extLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}\n${_pickLine}\n${_resLine}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`;
|
|
3838
3932
|
return ok(text + connLine, d);
|
|
3839
3933
|
}));
|
|
3840
3934
|
|
|
@@ -4088,6 +4182,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4088
4182
|
`• ${w.name || 'Shared workspace'}${w.ownerName ? ` — ${w.ownerName}` : ''} (${w.role || 'member'})\n use_brand "${w.name || w.profileUuid}" ← switch this connection into it\n profile ${w.profileUuid} · owner ${w.ownerAccountId}`).join('\n')
|
|
4089
4183
|
+ `\n\nSwitch into one with use_brand, exactly like a brand of your own — it stays pinned on this connection until you switch again, and its connected accounts, Library and memory are what every tool then sees.${isRemote() ? '' : ` (Or pin it before startup with ${ENV_PREFIX}_OWNER + ${ENV_PREFIX}_PROFILE set to the two values above — use the profile id exactly as printed, a brand's short slug is refused.)`}`
|
|
4090
4184
|
: '';
|
|
4185
|
+
// ZERO BRANDS IS A REAL STATE (2026-10-01): the owner may delete their last brand. Say so, and say how to make one,
|
|
4186
|
+
// rather than printing an empty list under a heading that implies there should be something in it.
|
|
4187
|
+
if (!(d.brands || []).length) return ok(`This account has no brands yet (the last one was deleted, or none was ever made). create_brand makes one, and draft_brand fills it in from a website, a description or a social handle.${sharedTxt}`, { ...d, sharedWorkspaces: shared });
|
|
4091
4188
|
return ok(`Brands on this account:\n${lines}\n\nSwitch with use_brand.${sharedTxt}`, { ...d, sharedWorkspaces: shared });
|
|
4092
4189
|
}));
|
|
4093
4190
|
|
|
@@ -4139,6 +4236,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4139
4236
|
}
|
|
4140
4237
|
const own = (d.brands || []).map(b => `• ${b.name} (id: ${b.id})`).join('\n');
|
|
4141
4238
|
const shr = shared.map(w => `• ${w.name || 'Shared workspace'}${w.ownerName ? ` — ${w.ownerName}` : ''} (id: ${w.profileUuid}, shared with you)`).join('\n');
|
|
4239
|
+
if (!own && !shr) return { content: [{ type: 'text', text: `No brand matching "${brand}": this account has no brands yet. create_brand makes one.` }], isError: true }; // zero brands is a real state (2026-10-01)
|
|
4142
4240
|
return { content: [{ type: 'text', text: `No brand matching "${brand}". Available:\n${own}${shr ? `\n${shr}` : ''}` }], isError: true };
|
|
4143
4241
|
});
|
|
4144
4242
|
server.registerTool('use_brand', {
|
|
@@ -4227,7 +4325,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4227
4325
|
if (a.confirmConnectors != null) q.set('confirmConnectors', String(a.confirmConnectors));
|
|
4228
4326
|
const d = await apiDelete(`/api/brands/${encodeURIComponent(hit.id)}?${q}`);
|
|
4229
4327
|
const dis = d.connectorsDisconnected || [];
|
|
4230
|
-
|
|
4328
|
+
const nq = +d.scheduledPostsCancelled || 0; // its queued posts go with it (2026-10-01)
|
|
4329
|
+
return ok(`Deleted “${hit.name}” and everything in it.${dis.length ? ` Disconnected: ${dis.join(', ')}. Those connections can be restored for ${d.connectorsRecoverableForDays || 30} days from Settings ▸ Connectors; after that they are gone.` : ''}${nq ? ` Cancelled ${nq} scheduled post${nq === 1 ? '' : 's'} that had not published yet.` : ''}${d.brandsLeft === 0 ? ' That was the last brand on this account: create_brand makes a new one.' : ''}`, d);
|
|
4231
4330
|
}));
|
|
4232
4331
|
|
|
4233
4332
|
|
|
@@ -4332,7 +4431,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4332
4431
|
// brand, and the post reaches the brand's profile only when the brand accepts, which no agent could do before.
|
|
4333
4432
|
server.registerTool('search_instagram_audio', {
|
|
4334
4433
|
title: 'Trending or searched Instagram audio',
|
|
4335
|
-
description: 'Audio the brand may legally put under a Reel — music or original sound — with title, artist, length, whether it is eligible for ads, a preview link and a download link. Omit the query for what is TRENDING right now; pass one to search. Everything returned is audio Instagram has authorized for third-party use. TO USE ONE, pass its `id` as `audioId` on post_to_meta or schedule_post (target instagram, a video): Instagram puts the track under the Reel itself. Download links expire after roughly 1.5 days. Read-only, free.',
|
|
4434
|
+
description: 'Audio the brand may legally put under a Reel — music or original sound — with title, artist, length, whether it is eligible for ads, a preview link and a download link. Omit the query for what is TRENDING right now; pass one to search. Everything returned is audio Instagram has authorized for third-party use. Instagram offers this library only to an account linked to a Facebook Page and connected through Meta; an account connected through Instagram Login alone has none, and this says so. TO USE ONE, pass its `id` as `audioId` on post_to_meta or schedule_post (target instagram, a video): Instagram puts the track under the Reel itself. Download links expire after roughly 1.5 days. Read-only, free.',
|
|
4336
4435
|
inputSchema: {
|
|
4337
4436
|
audioType: z.enum(['music', 'original_sound']).optional().describe('default music'),
|
|
4338
4437
|
query: z.string().optional().describe('omit for trending audio'),
|
|
@@ -5016,7 +5115,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5016
5115
|
server.registerTool('post_to_meta', {
|
|
5017
5116
|
title: 'Post to Facebook, Instagram or Threads',
|
|
5018
5117
|
description: 'Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept, which is the reach play behind every creator partnership. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings > Connectors > Meta) with posting permission; Threads needs its own connection.',
|
|
5019
|
-
|
|
5118
|
+
// LOOSE ON PURPOSE (2026-09-30): an argument this schema does not declare is FORWARDED, not stripped, so a caption
|
|
5119
|
+
// sent as `caption` or a picture as `image_url` reaches the server and is refused BY NAME with the right field
|
|
5120
|
+
// (metaPostAliasRefusal). Stripped, it vanished and the call read as empty: five identical refusals in 35 minutes.
|
|
5121
|
+
inputSchema: z.object({
|
|
5020
5122
|
...HOOK_ATTR,
|
|
5021
5123
|
message: z.string().optional().describe('post text / caption'),
|
|
5022
5124
|
imageUrl: z.string().optional().describe('public https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file)'),
|
|
@@ -5050,6 +5152,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5050
5152
|
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.'),
|
|
5051
5153
|
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.'),
|
|
5052
5154
|
coverAtMs: z.number().optional().describe('THE VIDEO COVER for every target of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram gets it as thumb_offset, Facebook as its uploaded cover (a Reel’s preferred thumbnail). Instagram’s own thumbOffset, or a Hermoso-hosted coverUrl, also becomes the Facebook cover.'),
|
|
5155
|
+
// CAPTION ADD-ONS (2026-10-02). Instagram's Sep 28 changelog: a poll or a comment prompt on the container create,
|
|
5156
|
+
// Facebook-Login accounts only, write-once. Refused by name where it cannot apply, never dropped.
|
|
5157
|
+
instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
|
|
5158
|
+
instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
|
|
5159
|
+
instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
|
|
5053
5160
|
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.'),
|
|
5054
5161
|
audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
|
|
5055
5162
|
audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
|
|
@@ -5076,9 +5183,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5076
5183
|
crossreshareToIg: z.boolean().optional().describe('THREADS ONLY — ALSO share this Threads post to the linked Instagram account AS A STORY (not a feed post), in the same publish. NOT available on a Threads CAROUSEL, which is refused by name rather than silently dropped. THERE IS NO CONFIRMATION: Threads returns no field saying whether the Story was created, so report it as REQUESTED and tell the user to check their Instagram Stories — never that it is live.'),
|
|
5077
5184
|
crossreshareDarkMode: z.boolean().optional().describe('THREADS ONLY — render that Instagram Story in dark mode. Only meaningful alongside crossreshareToIg; on its own it is refused rather than silently ignored, because a parameter that never reaches the wire must not look accepted.'),
|
|
5078
5185
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (Instagram Reel: thumb_offset; Facebook video/Reel: an uploaded cover image) \u2014 and on Threads, which has no cover setting, a blank first frame is replaced on a copy sent to Threads only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
5079
|
-
},
|
|
5080
|
-
outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), url: z.string().optional(), target: z.string().optional(), page: z.string().optional(), account: z.string().optional() },
|
|
5081
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5186
|
+
}).loose(),
|
|
5187
|
+
outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), url: z.string().optional(), target: z.string().optional(), page: z.string().optional(), account: z.string().optional(), publishIssues: z.array(z.object({ field: z.string(), code: z.string(), text: z.string() })).optional(), publishIssuesNote: z.string().optional() },
|
|
5188
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5082
5189
|
}, publishWrap(async (a) => {
|
|
5083
5190
|
const d = await apiPost('/api/meta/post', a);
|
|
5084
5191
|
if (d?.queued) return ok(`${d.note} Nothing is live yet — poll get_job("${d.jobId}") and report the post id and url it returns.`, d);
|
|
@@ -5089,7 +5196,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5089
5196
|
// THE COLLAB LINE IS THE READ-BACK, NEVER THE ASK. `collaboratorNote` is built from what Instagram said
|
|
5090
5197
|
// about each invite; printing "posted with @x" off the request would tell the user their post is live on
|
|
5091
5198
|
// an account that has not accepted it — and may never.
|
|
5092
|
-
|
|
5199
|
+
// A PUBLISH CAN SUCCEED AND STILL DROP PART OF THE POST (Instagram's config_issue / caption add-on status): the note
|
|
5200
|
+
// says what went out without its caption, tags, poll or prompt, so "it's live" is never the whole story told.
|
|
5201
|
+
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.publishIssuesNote ? ` ${d.publishIssuesNote}` : ''}`, d);
|
|
5093
5202
|
}));
|
|
5094
5203
|
// ── SCHEDULING (2026-07-30). ONE mechanism for every channel — our durable queue, not a per-platform special case.
|
|
5095
5204
|
// Product feedback: "if only Facebook can do scheduling, then maybe we just do all the scheduling ourselves. There's probably
|
|
@@ -5195,6 +5304,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5195
5304
|
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.'),
|
|
5196
5305
|
coverAtMs: z.number().optional().describe('THE VIDEO COVER on EVERY channel of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram, TikTok, Facebook, LinkedIn Page, Pinterest, Telegram and YouTube all get that frame; X, Threads and Bluesky have no cover setting. A channel’s own field (thumbOffset, coverTimestampMs, coverUrl) wins there and otherwise counts as this.'),
|
|
5197
5306
|
coverImageUrl: z.string().optional().describe('THE VIDEO COVER as a picture instead of a frame — a Hermoso-hosted image (upload_file). Every channel that takes a cover image gets it (Instagram, Facebook, LinkedIn Page, Pinterest, Telegram, YouTube); TikTok takes only a frame. Never together with coverAtMs.'),
|
|
5307
|
+
// CAPTION ADD-ONS (2026-10-02). Instagram's Sep 28 changelog: a poll or a comment prompt on the container create,
|
|
5308
|
+
// Facebook-Login accounts only, write-once. Refused by name where it cannot apply, never dropped.
|
|
5309
|
+
instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
|
|
5310
|
+
instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
|
|
5311
|
+
instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
|
|
5198
5312
|
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.'),
|
|
5199
5313
|
audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
|
|
5200
5314
|
audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
|
|
@@ -5204,7 +5318,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5204
5318
|
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.'),
|
|
5205
5319
|
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.'),
|
|
5206
5320
|
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.'),
|
|
5207
|
-
aiGenerated: z.boolean().optional().describe('
|
|
5321
|
+
aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
|
|
5208
5322
|
// ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
|
|
5209
5323
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
5210
5324
|
boardId: z.string().optional().describe('PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.'),
|
|
@@ -5218,7 +5332,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5218
5332
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover on every channel that allows one (Instagram, Facebook, TikTok direct posts, LinkedIn Pages, Pinterest, Telegram, YouTube) \u2014 and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
5219
5333
|
},
|
|
5220
5334
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), label: z.string().optional() },
|
|
5221
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5335
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5222
5336
|
}, wrap(async (a) => {
|
|
5223
5337
|
const d = await apiPost('/api/schedule', a);
|
|
5224
5338
|
return ok(`Scheduled for ${d.at} → ${(d.channels || []).join(', ')}. It is NOT posted yet; Hermoso publishes it at that time (id ${d.id}).${a.visibility && a.visibility !== 'public' ? ` Visibility: ${a.visibility} (as asked) — it will NOT be publicly live.` : ' It will go LIVE publicly.'}${d.note ? ` ${d.note}` : ''}`, d);
|
|
@@ -5237,7 +5351,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5237
5351
|
scheduled: z.array(z.object({ id: z.string().optional(), at: z.string().nullable().optional(), channels: z.array(z.string()).optional(), message: z.string().optional(), status: z.string().optional() })).optional(),
|
|
5238
5352
|
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(),
|
|
5239
5353
|
},
|
|
5240
|
-
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5354
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5241
5355
|
}, wrap(async ({ id, channel, upcoming, fired, brand } = {}) => {
|
|
5242
5356
|
const d = await apiGet('/api/schedule', brand ? { brandId: brand } : {});
|
|
5243
5357
|
// ONE POST IN FULL (2026-09-13). The compact list below shortens captions, and reschedule_post's `captions`
|
|
@@ -5357,6 +5471,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5357
5471
|
thumbOffset: z.number().optional().describe('INSTAGRAM REEL — replaces the cover frame, in milliseconds; 0 removes it. Never together with coverUrl.'),
|
|
5358
5472
|
coverAtMs: z.number().optional().describe('THE VIDEO COVER on every channel, as one frame in milliseconds (see schedule_post).'),
|
|
5359
5473
|
coverImageUrl: z.string().optional().describe('THE VIDEO COVER on every channel, as a Hermoso-hosted picture (see schedule_post).'),
|
|
5474
|
+
// CAPTION ADD-ONS (2026-10-02). Instagram's Sep 28 changelog: a poll or a comment prompt on the container create,
|
|
5475
|
+
// Facebook-Login accounts only, write-once. Refused by name where it cannot apply, never dropped.
|
|
5476
|
+
instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
|
|
5477
|
+
instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
|
|
5478
|
+
instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
|
|
5360
5479
|
shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — whether the Reel also shows in the Feed grid.'),
|
|
5361
5480
|
audioName: z.string().optional().describe('INSTAGRAM REEL — replaces the audio track name; an empty string removes it.'),
|
|
5362
5481
|
audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY; an empty string removes it, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
|
|
@@ -5372,7 +5491,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5372
5491
|
linkPicture: z.string().optional().describe('FACEBOOK — replaces the link preview image url; an empty string removes the override.'),
|
|
5373
5492
|
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.'),
|
|
5374
5493
|
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.'),
|
|
5375
|
-
aiGenerated: z.boolean().optional().describe('
|
|
5494
|
+
aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
|
|
5376
5495
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
5377
5496
|
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.'),
|
|
5378
5497
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
|
|
@@ -5383,7 +5502,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5383
5502
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover on every channel that allows one \u2014 and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
5384
5503
|
},
|
|
5385
5504
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), visibility: z.string().optional() },
|
|
5386
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5505
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5387
5506
|
}, wrap(async (a) => {
|
|
5388
5507
|
const { id, ...patch } = a;
|
|
5389
5508
|
const d = await apiPatch(`/api/schedule/${encodeURIComponent(id)}`, patch);
|
|
@@ -5426,7 +5545,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5426
5545
|
captions: z.record(z.string()).optional().describe('CORRECT ONE CHANNEL’S CAPTION on retry, e.g. { "x": "..." } when only that channel refused the text.'),
|
|
5427
5546
|
},
|
|
5428
5547
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), retryOf: z.string().optional(), retrying: z.array(z.string()).optional(), alreadyPublished: z.array(z.string()).optional(), note: z.string().optional() },
|
|
5429
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5548
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5430
5549
|
}, wrap(async (a) => {
|
|
5431
5550
|
const { id, ...rest } = a;
|
|
5432
5551
|
const d = await apiPost(`/api/schedule/${encodeURIComponent(id)}/retry`, rest);
|
|
@@ -5456,7 +5575,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5456
5575
|
visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional(),
|
|
5457
5576
|
},
|
|
5458
5577
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), duplicateOf: z.string().optional(), note: z.string().optional() },
|
|
5459
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5578
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5460
5579
|
}, wrap(async (a) => {
|
|
5461
5580
|
const { id, ...rest } = a;
|
|
5462
5581
|
const d = await apiPost(`/api/schedule/${encodeURIComponent(id)}/duplicate`, rest);
|
|
@@ -5501,7 +5620,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5501
5620
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page.'),
|
|
5502
5621
|
},
|
|
5503
5622
|
outputSchema: { enabled: z.boolean().optional(), dryRun: z.boolean().optional(), running: z.boolean().optional(), daysAhead: z.number().optional() },
|
|
5504
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5623
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5505
5624
|
}, wrap(async (a) => {
|
|
5506
5625
|
const d = await apiPut('/api/schedule/refill', a);
|
|
5507
5626
|
return ok(d.enabled
|
|
@@ -5520,7 +5639,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5520
5639
|
posts: z.array(z.any()).optional(), queued: z.array(z.any()).optional(), skippedSlots: z.array(z.any()).optional(),
|
|
5521
5640
|
notes: z.array(z.string()).optional(), spend: z.any().optional(), perChannel: z.any().optional(),
|
|
5522
5641
|
},
|
|
5523
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5642
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5524
5643
|
}, wrap(async (a) => {
|
|
5525
5644
|
const d = await apiPost('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force });
|
|
5526
5645
|
const lines = (d.posts || []).map((p, i) => ` ${i + 1}. ${p.at} → ${(p.channels || []).map(c => `${c}[${(p.visibilityByChannel || {})[c] || 'public'}]`).join(', ')}\n ${String(p.message || '').replace(/\n+/g, ' / ').slice(0, 220)}\n creative: ${p.kind} · ${p.asset?.model || 'Library render'} (${p.asset?.credits || 0} credits)`);
|
|
@@ -5546,7 +5665,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5546
5665
|
visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
|
|
5547
5666
|
},
|
|
5548
5667
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional() },
|
|
5549
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5668
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5550
5669
|
}, publishWrap(async (a) => {
|
|
5551
5670
|
const d = await apiPost('/api/linkedin/post', a);
|
|
5552
5671
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was posted a second time.)`, d);
|
|
@@ -5582,7 +5701,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5582
5701
|
platformCover: z.boolean().optional().describe('VIDEO COVER. X has no cover setting and shows the video\u2019s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad\u2019s empty opening card), sends X a copy with the first frame replaced by the video\u2019s best frame. Your Library file is never changed. true = send the file exactly as it is.'),
|
|
5583
5702
|
},
|
|
5584
5703
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), thread: z.boolean().optional(), media: z.boolean().optional(), altText: z.boolean().optional(), poll: z.boolean().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), text: z.string().optional(), url: z.string().optional() })).optional(), quotedPostId: z.string().optional(), communityId: z.string().optional(), paidPartnership: z.boolean().optional() },
|
|
5585
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5704
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5586
5705
|
}, wrap(async (a) => {
|
|
5587
5706
|
const d = await apiPost('/api/x/post', a);
|
|
5588
5707
|
const extra = d.media ? (d.altText ? ' with the render + alt text' : ' with the render attached (no alt text was written)') : d.poll ? ' with a poll' : '';
|
|
@@ -5601,7 +5720,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5601
5720
|
publish: z.boolean().optional().describe('default true. Pass false to save it as a DRAFT in the account’s X Articles composer instead — nothing becomes public, the user can review and publish it from X, and it does not spend one of the five daily publishes.'),
|
|
5602
5721
|
},
|
|
5603
5722
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), draftId: z.string().optional(), draft: z.boolean().optional(), url: z.string().optional(), title: z.string().optional(), note: z.string().optional(), costCredits: z.number().optional(), stats: z.record(z.number()).optional(), budget: z.object({ limit: z.number().nullable().optional(), remaining: z.number().nullable().optional(), resetsAt: z.string().nullable().optional() }).optional(), article: z.object({ found: z.boolean().optional(), title: z.string().nullable().optional(), plainText: z.string().nullable().optional(), chars: z.number().nullable().optional(), links: z.array(z.string()).optional(), editable: z.boolean().optional(), note: z.string().optional() }).optional() },
|
|
5604
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5723
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5605
5724
|
}, wrap(async (a) => {
|
|
5606
5725
|
const d = await apiPost('/api/x/article', a);
|
|
5607
5726
|
if (d.draft) return ok(`Saved an X Article DRAFT — “${d.title}”. Nothing is public yet. ${d.note || ''}`.trim(), d);
|
|
@@ -5875,7 +5994,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5875
5994
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (on Pinterest the frame rides as the cover image). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
5876
5995
|
},
|
|
5877
5996
|
outputSchema: { ok: z.boolean().optional(), id: z.string().nullable().optional(), url: z.string().nullable().optional(), boardId: z.string().optional(), title: z.string().optional(), kind: z.string().optional() },
|
|
5878
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5997
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5879
5998
|
}, publishWrap(async (a) => {
|
|
5880
5999
|
const d = await apiPost('/api/pinterest/pin', a);
|
|
5881
6000
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was pinned a second time.)`, d);
|
|
@@ -6098,7 +6217,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6098
6217
|
languageCode: z.string().optional().describe("BCP-47 language of the Post, default 'en'"),
|
|
6099
6218
|
},
|
|
6100
6219
|
outputSchema: { ok: z.boolean().optional(), id: z.string().nullable().optional(), url: z.string().nullable().optional(), state: z.string().nullable().optional(), topicType: z.string().optional(), location: z.string().optional(), locationId: z.string().optional() },
|
|
6101
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6220
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6102
6221
|
}, wrap(async (a) => {
|
|
6103
6222
|
const d = await apiPost('/api/google-business/post', a);
|
|
6104
6223
|
return ok(`Posted to the Google Business Profile for “${d.location}”${d.url ? ` — ${d.url}` : '.'}${d.state && d.state !== 'LIVE' ? ` Google reports state ${d.state}; it goes live once their review finishes.` : ''}`, d);
|
|
@@ -6265,7 +6384,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6265
6384
|
coverAtMs: z.number().optional().describe('THE VIDEO COVER (the custom thumbnail), as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut from the upload and set with thumbnails.set. thumbnailUrl wins; this beats platformCover.'),
|
|
6266
6385
|
},
|
|
6267
6386
|
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), url: z.string().optional(), privacy: z.string().optional(), requestedPrivacy: z.string().optional(), categoryId: z.string().optional(), categoryName: z.string().optional(), publishAt: z.string().optional(), scheduled: z.boolean().optional(), notifySubscribers: z.boolean().optional(), notifyNote: z.string().optional(), warning: z.string().optional(), scheduleWarning: z.string().optional(), thumbnailSet: z.boolean().optional(), thumbnailSource: z.string().nullable().optional(), thumbnailReadBack: z.string().nullable().optional(), thumbnailNote: z.string().optional(), title: z.string().optional() },
|
|
6268
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6387
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6269
6388
|
}, wrap(async (a) => {
|
|
6270
6389
|
const d = await apiPost('/api/youtube/upload', a);
|
|
6271
6390
|
// `d.privacy`, `d.categoryId` and `d.publishAt` are all what YOUTUBE returned on the row, not what we asked — an
|
|
@@ -6482,17 +6601,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6482
6601
|
}));
|
|
6483
6602
|
server.registerTool('list_youtube_comments', {
|
|
6484
6603
|
title: 'Read comments on one of your YouTube videos',
|
|
6485
|
-
description: 'Read the comments under a video on the connected channel — the questions, objections and exact wording real viewers use. Same raw material for ad copy that list_meta_comments gives you on Meta. Returns author, text, like count, timestamp and
|
|
6604
|
+
description: 'Read the comments under a video on the connected channel — the questions, objections and exact wording real viewers use. Same raw material for ad copy that list_meta_comments gives you on Meta. Returns author, text, like count, timestamp, reply count and comment id, newest first — plus `imageUrl` when a viewer attached an image or GIF to the comment (a signed YouTube link that expires six hours after the read, so download it then if it needs keeping). Read-only, 0 credits. Needs a connected YouTube channel.',
|
|
6486
6605
|
inputSchema: { videoId: z.string().describe('the YouTube video id'), limit: z.number().optional().describe('max comments, default 25, cap 100') },
|
|
6487
|
-
outputSchema: { videoId: z.string().optional(), count: z.number().optional(), comments: z.array(z.object({ id: z.string().optional(), author: z.string().optional(), text: z.string().optional(), likes: z.number().optional(), at: z.string().optional(), replies: z.number().optional() })).optional() },
|
|
6606
|
+
outputSchema: { videoId: z.string().optional(), count: z.number().optional(), comments: z.array(z.object({ id: z.string().optional(), author: z.string().optional(), text: z.string().optional(), likes: z.number().optional(), at: z.string().optional(), replies: z.number().optional(), imageUrl: z.string().optional() })).optional(), imageUrlNote: z.string().optional() },
|
|
6488
6607
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6489
6608
|
}, wrap(async (a) => {
|
|
6490
6609
|
const d = await apiGet('/api/youtube/comments', { videoId: a.videoId, ...(a.limit ? { limit: a.limit } : {}) });
|
|
6491
6610
|
// The comments ARE the answer — "12 comments on abc123" is a receipt, not a read. Newlines inside a comment are
|
|
6492
6611
|
// collapsed so one rambling reply cannot swamp the list, and each is capped; the untouched text stays in
|
|
6493
6612
|
// structuredContent for anything that needs it verbatim.
|
|
6494
|
-
const cl = (d.comments || []).map(c => ` • ${c.author || 'someone'}${c.likes ? ` (${c.likes} like${c.likes === 1 ? '' : 's'})` : ''}: ${String(c.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}${c.replies ? ` — ${c.replies} repl${c.replies === 1 ? 'y' : 'ies'}` : ''}`);
|
|
6495
|
-
return ok(`${d.count} comment${d.count === 1 ? '' : 's'} on ${d.videoId}:\n${cl.join('\n') || ' (none)'}`, d);
|
|
6613
|
+
const cl = (d.comments || []).map(c => ` • ${c.author || 'someone'}${c.likes ? ` (${c.likes} like${c.likes === 1 ? '' : 's'})` : ''}: ${String(c.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}${c.imageUrl ? ` [attached image: ${c.imageUrl}]` : ''}${c.replies ? ` — ${c.replies} repl${c.replies === 1 ? 'y' : 'ies'}` : ''}${c.id ? ` (id ${c.id})` : ''}`);
|
|
6614
|
+
return ok(`${d.count} comment${d.count === 1 ? '' : 's'} on ${d.videoId}:\n${cl.join('\n') || ' (none)'}${d.imageUrlNote ? `\n${d.imageUrlNote}` : ''}`, d);
|
|
6496
6615
|
}));
|
|
6497
6616
|
server.registerTool('reply_to_youtube_comment', {
|
|
6498
6617
|
title: 'Reply to a YouTube comment',
|
|
@@ -6783,6 +6902,82 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6783
6902
|
if (d.action === 'mute' || d.action === 'unmute') return ok(`${d.username || d.userId} is ${d.muted ? 'muted' : 'not muted'} in broadcast ${d.broadcastId}.`, d);
|
|
6784
6903
|
return ok(`Chat moderators now: ${(d.moderatorIds || []).join(', ') || '(none)'}.`, d);
|
|
6785
6904
|
}));
|
|
6905
|
+
// X CHAT (2026-10-02) — the /2/chat/* endpoints X's OpenAPI 2.169 added, as four tools; gates and payloads live in
|
|
6906
|
+
// lib/x-chat.mjs on the server, so every refusal below is the server's own sentence and costs nothing. X Chat is
|
|
6907
|
+
// END-TO-END ENCRYPTED: Hermoso holds no conversation key, so nothing here reads or writes message text or group names.
|
|
6908
|
+
const XC_SCOPE_NOTE = ' Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
|
|
6909
|
+
const xcSettingsLine = (s = {}) => `who can message: ${s.allowDmsFrom ?? '?'}${s.alwaysAllowSubscribers ? ' (subscribers always)' : ''}; quality filter ${s.qualityFilter == null ? '?' : s.qualityFilter ? 'on' : 'off'}; read receipts ${s.readReceipts == null ? '?' : s.readReceipts ? 'on' : 'off'}; calls ${s.callsEnabled == null ? '?' : s.callsEnabled ? 'on' : 'off'}`;
|
|
6910
|
+
server.registerTool('x_chat_settings', {
|
|
6911
|
+
title: 'Read the X Chat privacy settings',
|
|
6912
|
+
description: "The connected X account's X Chat (direct message) PRIVACY SETTINGS: who can message the brand (allowDmsFrom: all / verified / following), whether subscribers can always message it, the low-quality request filter, read receipts, and audio/video call permissions. Use it before changing them with update_x_chat_settings, or to answer 'can customers DM us on X'. Read-only." + XC_SCOPE_NOTE,
|
|
6913
|
+
inputSchema: {},
|
|
6914
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
6915
|
+
}, wrap(async () => {
|
|
6916
|
+
const d = await apiGet('/api/x/chat/settings', {});
|
|
6917
|
+
return ok(`X Chat settings: ${xcSettingsLine(d.settings)}. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
6918
|
+
}));
|
|
6919
|
+
server.registerTool('update_x_chat_settings', {
|
|
6920
|
+
title: 'Change the X Chat privacy settings',
|
|
6921
|
+
description: "Change the connected X account's X Chat privacy settings: the account-wide answer to 'open our DMs to everyone' or 'turn off read receipts'. Pass only what changes. allowDmsFrom: 'all' (anyone), 'verified' (verified accounts only) or 'following' (accounts the brand follows); narrowing it stops customers the brand does not follow from messaging it, so say that before changing it. Call settings are merged onto the stored ones (X does not promise a partial call setting keeps the rest). Returns the settings X stored, not what was sent. Reversible." + XC_SCOPE_NOTE,
|
|
6922
|
+
inputSchema: {
|
|
6923
|
+
allowDmsFrom: z.enum(['all', 'verified', 'following']).optional(),
|
|
6924
|
+
alwaysAllowSubscribers: z.boolean().optional().describe('subscribers can always message the brand, whatever allowDmsFrom says'),
|
|
6925
|
+
qualityFilter: z.boolean().optional().describe('true = low-quality message requests go to a separate folder'),
|
|
6926
|
+
readReceipts: z.boolean().optional().describe('send and receive read receipts'),
|
|
6927
|
+
callsEnabled: z.boolean().optional().describe('audio and video calls on or off'),
|
|
6928
|
+
enhancedCallPrivacy: z.boolean().optional(),
|
|
6929
|
+
acceptCallsFrom: z.object({ everyone: z.boolean().optional(), following: z.boolean().optional(), verified: z.boolean().optional(), addressBook: z.boolean().optional() }).optional(),
|
|
6930
|
+
},
|
|
6931
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
6932
|
+
}, wrap(async (a) => {
|
|
6933
|
+
const d = await apiPost('/api/x/chat/settings', a);
|
|
6934
|
+
return ok(`X stored: ${xcSettingsLine(d.settings)}. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
6935
|
+
}));
|
|
6936
|
+
server.registerTool('list_x_chats', {
|
|
6937
|
+
title: 'List X Chat conversations',
|
|
6938
|
+
description: "The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation returned, so keep maxResults small." + XC_SCOPE_NOTE,
|
|
6939
|
+
inputSchema: {
|
|
6940
|
+
view: z.enum(['conversations', 'admin_requests', 'invite']).optional().describe("default 'conversations'"),
|
|
6941
|
+
id: z.string().optional().describe('one conversation; for admin_requests, the group (g + digits)'),
|
|
6942
|
+
maxResults: z.number().optional().describe('1-100, default 10; every conversation returned is billed'),
|
|
6943
|
+
paginationToken: z.string().optional().describe('nextToken from a previous call'),
|
|
6944
|
+
withMembers: z.boolean().optional().describe('also resolve member/admin @handles (each one billed)'),
|
|
6945
|
+
inviteLink: z.string().optional().describe('an x.com/i/chat/group_join/... link, for view invite'),
|
|
6946
|
+
inviteToken: z.string().optional().describe('the 10-character token, with id, for view invite'),
|
|
6947
|
+
},
|
|
6948
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
6949
|
+
}, wrap(async (a = {}) => {
|
|
6950
|
+
const d = await apiGet('/api/x/chats', a);
|
|
6951
|
+
const cost = `Cost ${d.costCredits ?? '?'} credits.`;
|
|
6952
|
+
if (d.view === 'invite') { const i = d.invite || {}; return ok(`Invite ${i.url || '?'}: "${i.groupName || '(no name)'}", ${i.memberCount ?? '?'} members, the brand is ${i.joinState || '?'}${i.expiresAt ? `, expires ${i.expiresAt}` : ''}. ${cost}`, d); }
|
|
6953
|
+
if (d.view === 'admin_requests') return ok(`${d.count} admin request(s) in ${d.id}:\n` + (d.requests || []).map((u) => `- ${u.username || u.id} (${u.id})`).join('\n') + `\n${cost}`, d);
|
|
6954
|
+
const rows = (d.conversations || []).slice(0, 25).map((c) => `- ${c.id} ${c.isGroup ? 'group' : '1:1'}${c.muted ? ', muted' : ''}${c.disappearAfterMs ? `, messages disappear after ${Math.round(c.disappearAfterMs / 3600e3)}h` : ''}${c.memberIds ? `, ${c.memberIds.length} members` : ''}${c.pendingJoinIds && c.pendingJoinIds.length ? `, ${c.pendingJoinIds.length} waiting to join` : ''}${c.invite && c.invite.url ? `, invite ${c.invite.url}` : ''}`);
|
|
6955
|
+
return ok(`${d.count} X Chat conversation(s)${d.nextToken ? ' (more available, pass paginationToken)' : ''}:\n${rows.join('\n') || '(none)'}\n${d.note || ''} ${cost}`.trim(), d);
|
|
6956
|
+
}));
|
|
6957
|
+
server.registerTool('manage_x_chat', {
|
|
6958
|
+
title: 'Manage an X Chat conversation',
|
|
6959
|
+
description: "Manage one of the connected X account's X Chat conversations (id from list_x_chats). For the brand only: 'mute' / 'unmute'; 'delete' removes it from the brand's inbox for good while the others keep theirs (confirm:true). For everyone in it: 'set_disappearing' (disappearAfter like '24h' or '7d'), 'clear_disappearing', 'screen_capture' (blockScreenshots OR notifyOnScreenshot, one per call). GROUPS (id starts with g; the brand must be an admin): 'admin_settings' (adminOnly {addMember, editGroupInfo, sendMessage, startCall, editMessageTtl, blockScreenCapture}; sendMessage:true makes it announcement-only), 'add_admins' / 'remove_admins' (userIds), 'remove_members' (userIds, confirm:true), 'leave' (confirm:true), 'approve_admin_request' / 'reject_admin_request' (userId), 'reject_join_requests' (userIds; APPROVING a join re-keys the encrypted group, so it is done in the X app), 'enable_invite' (optional title, avatarUrl, welcomeMessage, expiresAt; X stores that preview UNENCRYPTED and shows it to anyone with the link), 'disable_invite' (the shared link dies for good: confirm:true). Confirm the exact conversation with the user before passing confirm:true; a refusal is free. The answer is X's own yes or no, never an echo." + XC_SCOPE_NOTE,
|
|
6960
|
+
inputSchema: {
|
|
6961
|
+
action: z.enum(['mute', 'unmute', 'delete', 'set_disappearing', 'clear_disappearing', 'screen_capture', 'admin_settings', 'add_admins', 'remove_admins', 'remove_members', 'leave', 'approve_admin_request', 'reject_admin_request', 'reject_join_requests', 'enable_invite', 'disable_invite']),
|
|
6962
|
+
id: z.string().describe('conversation id from list_x_chats: g + digits for a group, or the other person\'s numeric id for a 1:1'),
|
|
6963
|
+
userIds: z.array(z.string()).optional().describe('NUMERIC X account ids (add_admins, remove_admins, remove_members, reject_join_requests)'),
|
|
6964
|
+
userId: z.string().optional().describe('NUMERIC X account id (approve_admin_request, reject_admin_request)'),
|
|
6965
|
+
disappearAfter: z.string().optional().describe("set_disappearing: '24h', '7d', '90m' or a number of seconds"),
|
|
6966
|
+
blockScreenshots: z.boolean().optional(), notifyOnScreenshot: z.boolean().optional(),
|
|
6967
|
+
adminOnly: z.object({ addMember: z.boolean().optional(), editGroupInfo: z.boolean().optional(), sendMessage: z.boolean().optional(), startCall: z.boolean().optional(), editMessageTtl: z.boolean().optional(), blockScreenCapture: z.boolean().optional() }).optional().describe('admin_settings: true = only admins may'),
|
|
6968
|
+
title: z.string().optional().describe('enable_invite: the group name shown on the link preview (stored unencrypted)'),
|
|
6969
|
+
avatarUrl: z.string().optional().describe('enable_invite: https image for the preview'),
|
|
6970
|
+
welcomeMessage: z.string().optional(),
|
|
6971
|
+
expiresAt: z.string().optional().describe('enable_invite: ISO 8601 or epoch ms; omit for a link that does not expire'),
|
|
6972
|
+
confirm: z.boolean().optional().describe('true only after the user approved this exact delete / leave / removal / link shutdown'),
|
|
6973
|
+
},
|
|
6974
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6975
|
+
}, wrap(async (a) => {
|
|
6976
|
+
const d = await apiPost('/api/x/chats/manage', a);
|
|
6977
|
+
const cost = `Cost ${d.costCredits ?? '?'} credits.`;
|
|
6978
|
+
if (d.action === 'enable_invite') return ok(d.confirmed ? `Invite link on: ${d.invite && d.invite.url}${d.invite && d.invite.expiresAt ? ` (expires ${d.invite.expiresAt})` : ''}. ${d.note || ''} ${cost}` : `X did not return an invite link. ${cost}`, d);
|
|
6979
|
+
return ok(`${d.confirmed ? 'Done' : 'X did not confirm it'}: ${String(d.action).replace(/_/g, ' ')} on ${d.id}${d.userIds ? ` (${d.userIds.join(', ')})` : ''}. ${cost}`, d);
|
|
6980
|
+
}));
|
|
6786
6981
|
server.registerTool('x_user', {
|
|
6787
6982
|
title: 'Look up any public X account',
|
|
6788
6983
|
description: 'ACCOUNT-LEVEL numbers for ANY public X account by handle \u2014 followers, following, posts, listed and media counts, plus bio, location, verified status and account age. The research twin of `x_account` (which reads YOUR connected account): use this to size a competitor or vet a creator before working with them. Returns the identical shape as x_account, so the two are never described differently, and a counter X does not return is reported as unknown rather than zero. Needs X connected.',
|
|
@@ -7126,13 +7321,31 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7126
7321
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (TikTok video_cover_timestamp_ms, on a direct post \u2014 a draft takes no cover, you pick it in the TikTok app). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
7127
7322
|
},
|
|
7128
7323
|
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional() },
|
|
7129
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7324
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7130
7325
|
}, wrap(async (a) => {
|
|
7131
7326
|
const d = await apiPost('/api/tiktok/post', a);
|
|
7132
7327
|
const what = d.media === 'photo' ? (d.images > 1 ? `photo post (${d.images} slides)` : 'photo post') : 'video';
|
|
7133
7328
|
if (d.destination === 'draft') return ok(`Sent the ${what} to TikTok${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app (inbox notification, or + ▸ drafts) for the user to finish and post.${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
|
|
7134
7329
|
return ok(`Posted the ${what} to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}`, d);
|
|
7135
7330
|
}));
|
|
7331
|
+
// A publish TikTok was still PROCESSING when post_to_tiktok stopped polling came back `pending:true` with a publishId
|
|
7332
|
+
// and the words "do not post it again", and nothing to follow it with: an agent asked call_tool for
|
|
7333
|
+
// `tiktok_post_status` on 2026-10-01 and dead-ended. Same read as the web app's /api/tiktok/publish-status.
|
|
7334
|
+
server.registerTool('tiktok_post_status', {
|
|
7335
|
+
title: 'Check whether a TikTok post finished publishing',
|
|
7336
|
+
description: 'Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public postId when TikTok gives one), failed (with TikTok\u2019s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Settings > Connectors > TikTok).',
|
|
7337
|
+
inputSchema: {
|
|
7338
|
+
publishId: z.string().describe('the publishId post_to_tiktok returned'),
|
|
7339
|
+
account: z.string().optional().describe('which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the brand has more than one'),
|
|
7340
|
+
},
|
|
7341
|
+
outputSchema: { publishId: z.string().optional(), status: z.string().optional(), done: z.boolean().optional(), failed: z.boolean().optional(), postId: z.string().nullable().optional(), failReason: z.string().nullable().optional(), uploadedBytes: z.number().nullable().optional() },
|
|
7342
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
7343
|
+
}, wrap(async (a) => {
|
|
7344
|
+
const d = await apiGet('/api/tiktok/publish-status', { publishId: a.publishId, ...(a.account ? { account: a.account } : {}) });
|
|
7345
|
+
if (d.done) return ok(`TikTok finished publishing it${d.postId ? ` (post id ${d.postId})` : ''}.`, d);
|
|
7346
|
+
if (d.failed) return ok(`TikTok did not publish it: ${d.failReason || 'no reason given'}.`, d);
|
|
7347
|
+
return ok(`TikTok is still processing it (${d.status || 'no status yet'}). That is normal for a few minutes; check again shortly and do not post it again.`, d);
|
|
7348
|
+
}));
|
|
7136
7349
|
server.group('channel_admin');
|
|
7137
7350
|
server.registerTool('tiktok_account', {
|
|
7138
7351
|
title: 'Read the connected TikTok account',
|
|
@@ -7728,7 +7941,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7728
7941
|
server.group('ads');
|
|
7729
7942
|
server.registerTool('analyze_campaigns', {
|
|
7730
7943
|
title: 'What to scale, pause, fix and test next',
|
|
7731
|
-
description: 'ONE verdict across EVERY ad platform this workspace has connected. It pulls each platform\'s own report (Meta, Google Ads, ChatGPT Ads, TikTok, LinkedIn, Microsoft Advertising, Pinterest, Reddit, Snapchat, X, Apple Ads), normalises every campaign / ad set / ad into ONE table — spend, impressions, clicks, CTR, CPC, conversions, CPA, ROAS and frequency where the platform reports them — and answers with four lists: `scale`, `pause`, `fix` and `test_next`, plus `insufficient_data`. Each recommendation carries the row\'s OWN numbers as evidence, the reason, a concrete next action, and the EXACT Hermoso tool call that would apply it (for example set_meta_campaign_status with confirm) — which this tool never runs. FOUR REFUSALS TO REPEAT RATHER THAN PAPER OVER: (1) A ROW BELOW THE DATA FLOOR IS NEVER JUDGED — the minimums are stated in the reply, and an under-powered row lands in insufficient_data with what it lacks, never in "pause"; a row whose conversions are too few for a CPA verdict can still be read on CTR and CPC and says so. (2) A PLATFORM WHOSE READ FAILED IS NAMED AS UNREADABLE and its numbers are MISSING, never zero — never move budget on the strength of an absence. (3) A META AD SET IN THE LEARNING PHASE IS HELD BACK, because editing it restarts learning. (4) A CONVERSION COLUMN THE PLATFORM DOES NOT PUBLISH READS UNMEASURED, not zero (Reddit, Snapchat and X in this report). Conversion definitions differ per platform and travel with every row, and each platform\'s days are its own account\'s days, so a cross-platform CPA comparison must carry that caveat. Args: days (complete days ending yesterday, default 14, max 90), platforms (omit for every connected one), goal ("lowest CPA", "ROAS", "leads under $30"), level (campaign | ad_group | ad — a platform with no report at that tier is read at campaign level and says so).
|
|
7944
|
+
description: 'ONE verdict across EVERY ad platform this workspace has connected. It pulls each platform\'s own report (Meta, Google Ads, ChatGPT Ads, TikTok, LinkedIn, Microsoft Advertising, Pinterest, Reddit, Snapchat, X, Apple Ads), normalises every campaign / ad set / ad into ONE table — spend, impressions, clicks, CTR, CPC, conversions, CPA, ROAS and frequency where the platform reports them — and answers with four lists: `scale`, `pause`, `fix` and `test_next`, plus `insufficient_data`. Each recommendation carries the row\'s OWN numbers as evidence, the reason, a concrete next action, and the EXACT Hermoso tool call that would apply it (for example set_meta_campaign_status with confirm) — which this tool never runs. FOUR REFUSALS TO REPEAT RATHER THAN PAPER OVER: (1) A ROW BELOW THE DATA FLOOR IS NEVER JUDGED — the minimums are stated in the reply, and an under-powered row lands in insufficient_data with what it lacks, never in "pause"; a row whose conversions are too few for a CPA verdict can still be read on CTR and CPC and says so. (2) A PLATFORM WHOSE READ FAILED IS NAMED AS UNREADABLE and its numbers are MISSING, never zero — never move budget on the strength of an absence. (3) A META AD SET IN THE LEARNING PHASE IS HELD BACK, because editing it restarts learning. (4) A CONVERSION COLUMN THE PLATFORM DOES NOT PUBLISH READS UNMEASURED, not zero (Reddit, Snapchat and X in this report). Conversion definitions differ per platform and travel with every row, and each platform\'s days are its own account\'s days, so a cross-platform CPA comparison must carry that caveat. Args: days (complete days ending yesterday, default 14, max 90), platforms (omit for every connected one), goal ("lowest CPA", "ROAS", "leads under $30"), level (campaign | ad_group | ad — a platform with no report at that tier is read at campaign level and says so). Changes nothing in any ad account and spends no ad money; it bills one model call over the table.',
|
|
7732
7945
|
inputSchema: {
|
|
7733
7946
|
days: z.number().optional().describe('how many complete days back, ending yesterday (default 14, max 90)'),
|
|
7734
7947
|
platforms: z.array(z.string()).optional().describe('meta, google_ads, openai_ads, tiktok_ads, linkedin, microsoft_ads, pinterest_ads, reddit_ads, snapchat_ads, x_ads, apple_ads — omit for every connected ad platform'),
|
|
@@ -7744,7 +7957,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7744
7957
|
analysis: z.any().optional().describe('{summary, scale[], pause[], fix[], test_next[], insufficient_data[], moved[], dropped[]} — each recommendation with evidence, reason, next_action and the apply call'),
|
|
7745
7958
|
text: z.string().optional(), note: z.string().optional(), notes: z.array(z.string()).optional(),
|
|
7746
7959
|
},
|
|
7747
|
-
annotations: { readOnlyHint:
|
|
7960
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
7748
7961
|
}, wrap(async (a) => {
|
|
7749
7962
|
const d = await apiPost('/api/ads/analyze', a);
|
|
7750
7963
|
if (!d.analysis) return ok(d.note || 'Nothing delivered in that window.', d);
|
|
@@ -8196,7 +8409,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8196
8409
|
alt: z.string().optional().describe('alt text for accessibility and SEO; defaults to a generic credit'),
|
|
8197
8410
|
},
|
|
8198
8411
|
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), productId: z.string().optional(), productUrl: z.string().optional(), media: z.any().optional() },
|
|
8199
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8412
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
8200
8413
|
}, wrap(async (a) => {
|
|
8201
8414
|
const d = await apiPost('/api/shopify/publish-to-product', { productId: a.productId, imageUrl: a.imageUrl, alt: a.alt });
|
|
8202
8415
|
const st = d.media?.status || 'UNKNOWN';
|
|
@@ -11058,7 +11271,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11058
11271
|
});
|
|
11059
11272
|
server.registerTool('list_openai_ads_campaigns', {
|
|
11060
11273
|
title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
|
|
11061
|
-
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 (
|
|
11274
|
+
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 (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business 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.',
|
|
11062
11275
|
inputSchema: {
|
|
11063
11276
|
campaignId: z.string().optional().describe('list this campaign’s ad groups'),
|
|
11064
11277
|
adGroupId: z.string().optional().describe('list this ad group’s ads'),
|
|
@@ -11069,15 +11282,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11069
11282
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11070
11283
|
}, wrap(async (a) => {
|
|
11071
11284
|
const d = await apiGet('/api/openai-ads/campaigns', a);
|
|
11072
|
-
const blk = (x) =>
|
|
11285
|
+
const blk = (x) => `${x.servingIssues && x.servingIssues.length ? ` ⚠ not serving: ${x.servingIssues.map(i => i.meaning).join('; ')}` : ''}${x.phoneVerificationRequired ? ' ⚠ the advertiser must verify a phone number in Ads Manager before this can serve' : ''}${x.geographicTargetingBlocked ? ' ⚠ OpenAI flags this one’s geographic targeting as blocked' : ''}`;
|
|
11073
11286
|
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);
|
|
11074
11287
|
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);
|
|
11075
11288
|
const acc = d.account;
|
|
11076
|
-
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);
|
|
11289
|
+
return ok(`ChatGPT Ads account${acc ? ` "${acc.brandName || acc.name}" (${acc.id})${acc.currency ? `, ${acc.currency}` : ''}${acc.status ? ` · ${acc.status}` : ''}${acc.reviewStatus ? ` · brand review ${acc.reviewStatus}` : ''}${acc.legalName && acc.legalName !== (acc.brandName || acc.name) ? ` · legal name "${acc.legalName}"` : ''}${acc.accountName && acc.accountName !== (acc.brandName || acc.name) ? ` · account name "${acc.accountName}"` : ''}` : ''}\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);
|
|
11077
11290
|
}));
|
|
11078
11291
|
server.registerTool('openai_ads_report', {
|
|
11079
11292
|
title: 'ChatGPT Ads performance report',
|
|
11080
|
-
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.
|
|
11293
|
+
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. Since OpenAI’s 2026-09-30 change these conversions count click-through PLUS view-through goal conversions within the reporting windows (default 30-day click, 1-day view, dated by the ad interaction); CPA uses them and post-click conversion rate uses click-through only. clickWindowDays (7, 14, 30), viewWindowDays (0 = clicks only, or 1) and attributionTimeBasis choose those windows for every conversion column and never change the campaign; use the same ones as Ads Manager when comparing, and the reply’s `attribution` names the windows used. 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. attributedEvents:true adds every event OpenAI attributed beyond the campaign goal (purchases on a sign-up campaign) for the same scope and days; never add those counts to conversions. Read-only and free, so run it FIRST after connecting: it proves the key works with zero spend risk.',
|
|
11081
11294
|
inputSchema: {
|
|
11082
11295
|
campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
|
|
11083
11296
|
since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
|
|
@@ -11091,8 +11304,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11091
11304
|
after: z.string().optional().describe('nextAfter from the previous page; limit goes up to 2000 rows'),
|
|
11092
11305
|
conversions: z.boolean().optional().describe('default true: add conversions, CPA, conversion rate and sales where OpenAI allows them. false leaves them out.'),
|
|
11093
11306
|
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`'),
|
|
11307
|
+
attributedEvents: z.boolean().optional().describe('add events attributed beyond the goal'),
|
|
11308
|
+
eventNames: z.array(z.string()).optional().describe('only these events, e.g. ["order_created"]'),
|
|
11309
|
+
attributionTimeBasis: z.enum(['ad_event_time', 'conversion_time']).optional().describe('default ad_event_time'),
|
|
11310
|
+
clickWindowDays: z.union([z.literal(7), z.literal(14), z.literal(30)]).optional().describe('default 30'),
|
|
11311
|
+
viewWindowDays: z.union([z.literal(0), z.literal(1)]).optional().describe('default 1; 0 = no views'),
|
|
11094
11312
|
},
|
|
11095
|
-
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() },
|
|
11313
|
+
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(), attributedEvents: z.array(z.any()).optional(), attributedGoalConversions: z.number().optional(), attributedOrderSales: z.number().optional(), attributedEventsError: z.string().optional(), hasMore: z.boolean().optional(), nextAfter: z.string().optional(), note: z.string().optional() },
|
|
11096
11314
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11097
11315
|
}, wrap(async (a) => {
|
|
11098
11316
|
const d = await apiPost('/api/openai-ads/report', a);
|
|
@@ -11100,7 +11318,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11100
11318
|
}));
|
|
11101
11319
|
server.registerTool('openai_ads_geo_search', {
|
|
11102
11320
|
title: 'Find ChatGPT Ads location ids',
|
|
11103
|
-
description: 'Look up ChatGPT Ads location ids by name — countries, regions and DMAs — so a campaign can be geo-targeted. GEO AND CUSTOM AUDIENCES ARE THE ONLY LIST-BASED TARGETING THIS PLATFORM HAS: there are no interests, no lookalikes, no age or gender. Everything else is semantic, through an ad group’s context hints. Custom audiences are targeted with customAudienceIds / excludedCustomAudienceIds (see list_openai_ads_audiences). Pass the returned ids as locationIds when creating or updating a campaign; a campaign with no location targeting runs everywhere available. Read-only, free.',
|
|
11321
|
+
description: 'Look up ChatGPT Ads location ids by name — countries, regions and DMAs — so a campaign can be geo-targeted. GEO AND CUSTOM AUDIENCES ARE THE ONLY LIST-BASED TARGETING THIS PLATFORM HAS: there are no interests, no lookalikes, no age or gender. Everything else is semantic, through an ad group’s context hints. Custom audiences are targeted with customAudienceIds / excludedCustomAudienceIds (see list_openai_ads_audiences). Pass the returned ids as locationIds when creating or updating a campaign; a campaign with no location targeting runs everywhere available. OpenAI opens new markets without notice (Indonesia, Malaysia, the Philippines, Singapore, Thailand, Vietnam and Taiwan went live 2026-09-30), so search a market rather than assuming from memory that ChatGPT Ads does not serve it. Read-only, free.',
|
|
11104
11322
|
inputSchema: { query: z.string().describe('a place name, e.g. "Toronto" or "United Kingdom"'), limit: z.number().optional() },
|
|
11105
11323
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
11106
11324
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12362,7 +12580,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12362
12580
|
}));
|
|
12363
12581
|
server.registerTool('openai_ads_bulk', {
|
|
12364
12582
|
title: 'ChatGPT Ads bulk create/update job',
|
|
12365
|
-
description: 'Create or update up to 1,000 ChatGPT Ads campaigns, ad groups and ads in ONE asynchronous job — the direct analogue of a Google Ads atomic mutate, and the only way to build a whole tree in a single call. Each operation is {operation_id, type, idempotency_key|target_resource_id, input}. Types: campaign.create, campaign.update, ad_group.create, ad_group.update, ad.create, ad.update. FORWARD REFERENCES are the point: set input.campaign_idempotency_key / input.ad_group_idempotency_key to another CREATE operation’s idempotency_key and the child attaches to the parent made in the same job (a key no operation mints is refused here, for free, rather than failing the whole job at OpenAI). EVERYTHING CREATED IS PAUSED and spends nothing — status:"active" on a create is overridden, and an UPDATE that would set "active" is REFUSED BY NAME, because one job could otherwise arm a thousand objects in a single call that no confirmation ever saw; turn things on one at a time with set_openai_ads_status. Use validateOnly:true for a FREE dry run (it checks fields and dependencies but NOT update-target existence, image fetching or entity limits, so a validated job can still fail for real). Returns a jobId — poll it with openai_ads_bulk_job; an operation’s result is only final once the job is completed, partially_failed or failed. LIMITS (OpenAI’s own): 1–1000 operations, 16 MiB body, 512 KiB per operation, 10 job creates per 10 seconds per ad account, campaign budget ≥ 1000000 micros, names 3–1000 chars, ad titles 3–50, bodies ≤100, URLs ≤2048, ≤2500 location ids, ≤2000 context hints. THE BULK API IS IN LIMITED PREVIEW AND ENABLED PER AD ACCOUNT: a 404 means this account has not been granted it (not a wrong path), and the refusal says so and names the per-object tools that do the same work.',
|
|
12583
|
+
description: 'Create or update up to 1,000 ChatGPT Ads campaigns, ad groups and ads in ONE asynchronous job — the direct analogue of a Google Ads atomic mutate, and the only way to build a whole tree in a single call. It builds CHAT-CARD campaigns only: its campaign.create has no product-feed mode, and OpenAI’s spreadsheet bulk creation of product-feed campaigns and ad groups with auto-generated ad templates (announced 2026-09-30) is an Ads Manager feature with no API. Build a product-feed campaign with create_openai_ads_campaign (mode product_feed), create_openai_ads_ad_group and an ad whose creative type is the product-ad template instead. Each operation is {operation_id, type, idempotency_key|target_resource_id, input}. Types: campaign.create, campaign.update, ad_group.create, ad_group.update, ad.create, ad.update. FORWARD REFERENCES are the point: set input.campaign_idempotency_key / input.ad_group_idempotency_key to another CREATE operation’s idempotency_key and the child attaches to the parent made in the same job (a key no operation mints is refused here, for free, rather than failing the whole job at OpenAI). EVERYTHING CREATED IS PAUSED and spends nothing — status:"active" on a create is overridden, and an UPDATE that would set "active" is REFUSED BY NAME, because one job could otherwise arm a thousand objects in a single call that no confirmation ever saw; turn things on one at a time with set_openai_ads_status. Use validateOnly:true for a FREE dry run (it checks fields and dependencies but NOT update-target existence, image fetching or entity limits, so a validated job can still fail for real). Returns a jobId — poll it with openai_ads_bulk_job; an operation’s result is only final once the job is completed, partially_failed or failed. LIMITS (OpenAI’s own): 1–1000 operations, 16 MiB body, 512 KiB per operation, 10 job creates per 10 seconds per ad account, campaign budget ≥ 1000000 micros, names 3–1000 chars, ad titles 3–50, bodies ≤100, URLs ≤2048, ≤2500 location ids, ≤2000 context hints. THE BULK API IS IN LIMITED PREVIEW AND ENABLED PER AD ACCOUNT: a 404 means this account has not been granted it (not a wrong path), and the refusal says so and names the per-object tools that do the same work.',
|
|
12366
12584
|
inputSchema: {
|
|
12367
12585
|
operations: z.array(z.record(z.any())).describe('1–1000 operations: {operation_id, type, idempotency_key (creates) or target_resource_id (updates), input:{…}}'),
|
|
12368
12586
|
validateOnly: z.boolean().optional().describe('true = FREE dry run; nothing is created or changed'),
|
|
@@ -12432,7 +12650,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12432
12650
|
}));
|
|
12433
12651
|
server.registerTool('openai_ads_conversions', {
|
|
12434
12652
|
title: 'ChatGPT Ads attributed conversions',
|
|
12435
|
-
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
|
|
12653
|
+
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 conversions and CPA for the account, a campaign, ad group or ad, while this tool adds the click-through / view-through split, 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 viewThroughConversions TO conversions: since OpenAI’s 2026-09-30 change conversions already counts click-through PLUS view-through goal conversions within the chosen windows (their example: 3 click-through and 2 view-through give conversions 5), and the rows and totals carry clickThroughConversions and viewThroughConversions as its two parts. Post-click conversion rate stays click-through only. 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. BEYOND THE GOAL (on by default): each row and the reply carry attributedEvents, every event OpenAI attributed whether or not it is the campaign’s goal (e.g. purchases on a sign-up campaign), with count and value; totals.orderSales is attributed purchase value. An event count already includes goal activity: NEVER add it to conversions; a null value is unknown, not zero. A current day is not final until OpenAI’s daily processing runs. Read-only, free.',
|
|
12436
12654
|
inputSchema: {
|
|
12437
12655
|
level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('inferred from which id you pass — default ad_account'),
|
|
12438
12656
|
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'),
|
|
@@ -12443,14 +12661,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12443
12661
|
recentEvents: z.boolean().optional().describe('true = list a recent sample of the events OpenAI RECEIVED on the pixel instead of attributed totals'),
|
|
12444
12662
|
pixelId: z.string().optional().describe('with recentEvents: the pixel_id to read; omit to use the account\'s first pixel'),
|
|
12445
12663
|
limit: z.number().optional().describe('with recentEvents: how many events, 1-50 (default 50)'),
|
|
12664
|
+
attributedEvents: z.boolean().optional().describe('default true; false = goal counts only'),
|
|
12665
|
+
eventNames: z.array(z.string()).optional().describe('only these events, e.g. ["order_created"]'),
|
|
12666
|
+
attributionTimeBasis: z.enum(['ad_event_time', 'conversion_time']).optional().describe('default ad_event_time'),
|
|
12667
|
+
clickWindowDays: z.union([z.literal(7), z.literal(14), z.literal(30)]).optional().describe('default 30'),
|
|
12668
|
+
viewWindowDays: z.union([z.literal(0), z.literal(1)]).optional().describe('default 1; 0 = no views'),
|
|
12669
|
+
includeZeroRows: z.boolean().optional().describe('default true'),
|
|
12446
12670
|
},
|
|
12447
|
-
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), events: z.array(z.any()).optional(), byType: z.any().optional(), pixelId: z.string().optional(), note: z.string().optional() },
|
|
12671
|
+
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), attributedEvents: z.array(z.any()).optional(), attributedEventsDropped: z.boolean().optional(), events: z.array(z.any()).optional(), byType: z.any().optional(), pixelId: z.string().optional(), note: z.string().optional() },
|
|
12448
12672
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12449
12673
|
}, wrap(async (a) => {
|
|
12450
12674
|
const d = await apiPost('/api/openai-ads/conversions', a);
|
|
12451
12675
|
if (d.mode === 'recentEvents') return ok(`${d.note}\n${(d.events || []).map(e => `• ${e.receivedAt || '?'} ${e.eventType}${e.customEventName ? `/${e.customEventName}` : ''} via ${e.apiChannel || '?'} id=${e.eventId ?? '—'}${e.hasOppref ? ' (ad click)' : ''}`).join('\n')}`, d);
|
|
12452
12676
|
const daily = Array.isArray(d.byDate) && d.byDate.length ? `\nBy day: ${d.byDate.map(x => `${x.date} ${x.conversions}`).join(' · ')}` : '';
|
|
12453
|
-
|
|
12677
|
+
const ev = (r) => ((r.attributedEvents || []).length ? ` · events: ${r.attributedEvents.map(e => `${e.count} ${e.eventName}${e.value != null ? ` (${e.value}${e.valueCurrency ? ` ${e.valueCurrency}` : ''})` : ''}${e.goal ? ' [goal]' : ''}`).join(', ')}` : '');
|
|
12678
|
+
return ok(`${d.note}${daily}\n${(d.rows || []).slice(0, 200).map(r => `• ${r.date ? `${r.date} ` : ''}${r.entityId}${r.country ? ` ${r.country}` : ''}${r.device ? ` ${r.device}` : ''} — ${r.conversions ?? 0} goal conversion(s), ${r.viewThroughConversions ?? 0} view-through${r.orderSales ? `, ${r.orderSales} order sales` : ''}${ev(r)}`).join('\n')}`, d);
|
|
12454
12679
|
}));
|
|
12455
12680
|
server.registerTool('preview_openai_ads_ad', {
|
|
12456
12681
|
title: 'Preview a ChatGPT Ads ad',
|
|
@@ -12486,16 +12711,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12486
12711
|
return ok(`${d.note}${d.apiKey ? `\n\nKEY (store it now — it cannot be retrieved again): ${d.apiKey}` : ''}`, d);
|
|
12487
12712
|
}));
|
|
12488
12713
|
server.registerTool('update_openai_ads_account', {
|
|
12489
|
-
title: 'Update the ChatGPT Ads account brand
|
|
12490
|
-
description: 'Update the ChatGPT Ads
|
|
12491
|
-
inputSchema: {
|
|
12492
|
-
|
|
12714
|
+
title: 'Update the ChatGPT Ads account (brand / account / legal name, URL, favicon)',
|
|
12715
|
+
description: 'Update the ChatGPT Ads account’s names, primary URL and/or favicon. brandName is shown IN ADS, accountName is internal to Ads Manager, legalName is the business. Per OpenAI, a brand-name change PAUSES AD DELIVERY until brand review passes and a legal-name change restarts business review and may pause serving; an account name triggers no review. So a brand or legal change needs confirm:true: without it nothing changes and the reply names the old and new values and the pause, which the user must agree to first. Legacy name sets all three (cannot be combined with them). An unchanged name is not sent. Also url, faviconUrl (public image or home page, ≥256×256) or faviconFileId. An account whose brand review is not approved cannot serve: re-read it before promising anything runs. A 403 “not enabled” is OpenAI’s account setting, not a Hermoso failure.',
|
|
12716
|
+
inputSchema: {
|
|
12717
|
+
brandName: z.string().optional().describe('shown in ads; pauses delivery until review (confirm)'),
|
|
12718
|
+
accountName: z.string().optional().describe('internal; no review'),
|
|
12719
|
+
legalName: z.string().optional().describe('restarts business review (confirm)'),
|
|
12720
|
+
confirm: z.boolean().optional().describe('required for a brand or legal change'),
|
|
12721
|
+
name: z.string().optional().describe('legacy: sets all three names'),
|
|
12493
12722
|
url: z.string().optional().describe('the account’s primary destination — a public http(s) URL. OpenAI accepts this field even though their published parameter table omits it.'),
|
|
12494
12723
|
faviconUrl: z.string().optional().describe('public image URL (or the brand home page) to upload and assign as the account favicon — minimum 128×128'),
|
|
12495
12724
|
faviconFileId: z.string().optional().describe('an already-uploaded favicon file id, if you have one'),
|
|
12496
12725
|
},
|
|
12497
|
-
outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).optional(), faviconFileId: z.string().nullable().optional(), account: z.any().optional(), note: z.string().optional() },
|
|
12498
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12726
|
+
outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).nullable().optional(), unchanged: z.array(z.string()).optional(), would: z.any().optional(), current: z.any().optional(), faviconFileId: z.string().nullable().optional(), account: z.any().optional(), note: z.string().optional() },
|
|
12727
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
12499
12728
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/account', a); return ok(d.note, d); }));
|
|
12500
12729
|
server.registerTool('set_openai_ads_status', {
|
|
12501
12730
|
title: 'Activate, pause or archive a ChatGPT Ads account / campaign / ad group / ad',
|
|
@@ -12645,13 +12874,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12645
12874
|
inputSchema: { windowId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12646
12875
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/spend-window/delete', a))));
|
|
12647
12876
|
server.registerTool('set_openai_ads_daily_spend_limit', {
|
|
12648
|
-
title: 'Set the ChatGPT Ads account daily spending limit', description: 'Create or change the ACCOUNT-WIDE DAILY SPENDING LIMIT on ChatGPT Ads: the most all campaigns together may spend each day, renewing at midnight in the account timezone, until it is removed or reaches its optional end date. OpenAI offers it only on ad accounts with postpaid INVOICE billing and it needs billing-management permission; a card-billed account is refused whatever key is used. amount is per day in the account currency and is required even when only the end date changes (0 stops account spend). A NEW limit starts tomorrow or later: list_openai_ads_spend_windows names the earliest start date. On an existing limit leave startDate out, because its start cannot change. endDate is exclusive; noEndDate:true clears it. A daily limit and a date-range window cannot overlap. Lowering it can stop delivery and raising it lets campaigns spend up to their own budgets, so show the user the current and new values first: without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply states what would. The result is read back from ChatGPT Ads.',
|
|
12649
|
-
inputSchema: { amount: z.number(), expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), startDate: z.string().optional().describe('YYYY-MM-DD, first day of a NEW daily limit (tomorrow or later)'), endDate: z.string().optional().describe('YYYY-MM-DD, exclusive'), noEndDate: z.boolean().optional().describe('true removes the end date'), confirm: z.boolean().optional() },
|
|
12877
|
+
title: 'Set the ChatGPT Ads account daily spending limit', description: 'Create or change the ACCOUNT-WIDE DAILY SPENDING LIMIT on ChatGPT Ads: the most all campaigns together may spend each day, renewing at midnight in the account timezone, until it is removed or reaches its optional end date. OpenAI offers it only on ad accounts with postpaid INVOICE billing and it needs billing-management permission; a card-billed account is refused whatever key is used. amount is per day in the account currency and is required even when only the end date changes (0 stops account spend). A NEW limit starts tomorrow or later: list_openai_ads_spend_windows names the earliest start date. On an existing limit leave startDate out, because its start cannot change. An account can hold SEVERAL non-overlapping daily rules (list_openai_ads_spend_windows returns each with a windowId): pass windowId to change one, or newRule:true (with startDate) to add another; with more than one rule OpenAI requires the windowId. endDate is exclusive; noEndDate:true clears it. A daily limit and a date-range window cannot overlap. Lowering it can stop delivery and raising it lets campaigns spend up to their own budgets, so show the user the current and new values first: without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply states what would. The result is read back from ChatGPT Ads.',
|
|
12878
|
+
inputSchema: { amount: z.number(), windowId: z.string().optional().describe('the daily rule to change, from list_openai_ads_spend_windows; required when the account has more than one'), newRule: z.boolean().optional().describe('true adds another non-overlapping daily rule instead of editing one'), expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), startDate: z.string().optional().describe('YYYY-MM-DD, first day of a NEW daily limit (tomorrow or later)'), endDate: z.string().optional().describe('YYYY-MM-DD, exclusive'), noEndDate: z.boolean().optional().describe('true removes the end date'), confirm: z.boolean().optional() },
|
|
12650
12879
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12651
12880
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/daily-spend-limit', a))));
|
|
12652
12881
|
server.registerTool('remove_openai_ads_daily_spend_limit', {
|
|
12653
|
-
title: 'Remove the ChatGPT Ads account daily spending limit', description: 'Remove the account-wide DAILY spending limit on ChatGPT Ads. This takes a spending CEILING off the whole account, so campaigns may then spend up to their own budgets every day; date-range spend-limit windows are not touched. Like every account spending-limit request it needs postpaid INVOICE billing and billing-management permission; a card-billed account is refused whatever key is used. Without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply names the limit that would be removed. The result is read back from ChatGPT Ads.',
|
|
12654
|
-
inputSchema: { expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), confirm: z.boolean().optional() },
|
|
12882
|
+
title: 'Remove the ChatGPT Ads account daily spending limit', description: 'Remove the account-wide DAILY spending limit on ChatGPT Ads. This takes a spending CEILING off the whole account, so campaigns may then spend up to their own budgets every day; date-range spend-limit windows are not touched. Like every account spending-limit request it needs postpaid INVOICE billing and billing-management permission; a card-billed account is refused whatever key is used. Without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply names the limit that would be removed. With several daily rules, pass windowId to name the one to remove (OpenAI requires it then). The result is read back from ChatGPT Ads.',
|
|
12883
|
+
inputSchema: { windowId: z.string().optional().describe('the daily rule to remove; required when the account has more than one'), expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), confirm: z.boolean().optional() },
|
|
12655
12884
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12656
12885
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/daily-spend-limit/delete', a))));
|
|
12657
12886
|
server.registerTool('set_openai_ads_negative_keywords', {
|
|
@@ -12659,7 +12888,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12659
12888
|
inputSchema: { keywords: z.array(z.string()) }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12660
12889
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/negative-keywords', a))));
|
|
12661
12890
|
server.registerTool('list_openai_ads_feeds', {
|
|
12662
|
-
title: 'List ChatGPT Ads product feeds', description: 'The PRODUCT FEEDS on this ChatGPT Ads account (id, name, countries, currencies,
|
|
12891
|
+
title: 'List ChatGPT Ads product feeds', description: 'The PRODUCT FEEDS on this ChatGPT Ads account (id, name, countries, currencies, the count of ingested products eligible for ads, and campaign count). A product-feed campaign (mode product_feed) advertises products from one of these; update_openai_ads_feed_products fills one. Read-only, 0 credits.',
|
|
12663
12892
|
inputSchema: { limit: z.number().optional(), after: z.string().optional() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12664
12893
|
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feeds', a))));
|
|
12665
12894
|
server.registerTool('create_openai_ads_feed', {
|
|
@@ -12667,7 +12896,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12667
12896
|
inputSchema: { name: z.string(), countries: z.array(z.string()).optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12668
12897
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed', a))));
|
|
12669
12898
|
server.registerTool('list_openai_ads_feed_uploads', {
|
|
12670
|
-
title: 'List ChatGPT Ads feed uploads', description: 'Recent product-feed UPLOADS across the account with per-upload status
|
|
12899
|
+
title: 'List ChatGPT Ads feed uploads', description: 'Recent product-feed UPLOADS across the account with per-upload status, rows accepted / rejected / ads-eligible and OpenAI’s DIAGNOSTICS (code, severity, field and rows affected, e.g. invalid_value on price) — the way to learn why products are not serving. Per-product review status (why ONE product was rejected) is shown only in ChatGPT Ads Manager > Products: OpenAI does not expose it through the API, so say that rather than guessing. Read-only, 0 credits.',
|
|
12671
12900
|
inputSchema: { limit: z.number().optional() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12672
12901
|
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feed-uploads', a))));
|
|
12673
12902
|
server.registerTool('archive_openai_ads_feed', {
|
|
@@ -12675,7 +12904,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12675
12904
|
inputSchema: { feedId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12676
12905
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/archive', a))));
|
|
12677
12906
|
server.registerTool('query_openai_ads_feed_products', {
|
|
12678
|
-
title: 'Query products in a ChatGPT Ads feed', description: 'List the products in a feed that match filters ({field, operator in|not_in|gt|gte|lt|lte|contains|not_contains|starts_with, values[]}) — the SAME filter shape an ad group’s productSet takes, so this previews exactly which products that ad group would advertise. Paginate with after. Read-only, 0 credits.',
|
|
12907
|
+
title: 'Query products in a ChatGPT Ads feed', description: 'List the products in a feed that match filters ({field, operator in|not_in|gt|gte|lt|lte|contains|not_contains|starts_with, values[]}) — the SAME filter shape an ad group’s productSet takes, so this previews exactly which products that ad group would advertise. Each product carries its price and filter values. It does not carry a per-product review status: that is only in ChatGPT Ads Manager > Products. Paginate with after. Read-only, 0 credits.',
|
|
12679
12908
|
inputSchema: { feedId: z.string(), filters: z.array(z.object({ field: z.string(), operator: z.string(), values: z.array(z.string()) })).optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
12680
12909
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12681
12910
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/products/query', a))));
|
|
@@ -12688,6 +12917,26 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12688
12917
|
inputSchema: { feedId: z.string(), action: z.enum(['create', 'activate', 'pause']).optional(), authenticationMethod: z.enum(['password', 'ssh_key']).optional(), sshPublicKey: z.string().optional() },
|
|
12689
12918
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12690
12919
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/sftp', a))));
|
|
12920
|
+
server.registerTool('get_openai_ads_feed_settings', {
|
|
12921
|
+
title: 'Read a ChatGPT Ads feed’s settings', description: 'A product feed’s name, countries and whether its organic ChatGPT search listing respects each product’s is_eligible_search flag. Read-only, 0 credits.',
|
|
12922
|
+
inputSchema: { feedId: z.string() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12923
|
+
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feed/settings', a))));
|
|
12924
|
+
server.registerTool('update_openai_ads_feed_settings', {
|
|
12925
|
+
title: 'Change a ChatGPT Ads feed’s name or organic-listing setting', description: 'Rename a product feed and/or set searchEligibleRespected (its organic ChatGPT search listing follows each product’s is_eligible_search flag). Touches no ads or spend; the reply is what OpenAI stored.',
|
|
12926
|
+
inputSchema: { feedId: z.string(), name: z.string().optional(), searchEligibleRespected: z.boolean().optional() },
|
|
12927
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12928
|
+
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/settings', a))));
|
|
12929
|
+
server.registerTool('list_openai_ads_audit_logs', {
|
|
12930
|
+
title: 'ChatGPT Ads change history (audit log)', description: 'Who changed what on the ChatGPT Ads account and when: each create/update with its actor and every changed field’s before and after value. Read it first when a campaign starts or stops spending unexpectedly. Filter by campaignId (includes its ad groups and ads), adGroupId, adId, actorId, since/until (date, ISO time or unix seconds). Read-only, 0 credits.',
|
|
12931
|
+
inputSchema: { campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(), actorId: z.string().optional(), since: z.string().optional(), until: z.string().optional(), order: z.enum(['asc', 'desc']).optional(), limit: z.number().optional().describe('1-100, default 25'), after: z.string().optional(), before: z.string().optional() },
|
|
12932
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12933
|
+
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/audit-logs', a))));
|
|
12934
|
+
server.registerTool('get_openai_ads_landing_page_crawl', {
|
|
12935
|
+
title: 'What ChatGPT Ads’ crawler saw on an ad’s landing page', description: 'The saved evidence from OpenAI’s landing-page crawler for an ad’s submitted version: crawl status and failure code, the requested and final URL (a redirect shows here), capture time, whether robots.txt allowed the crawler and its user agent, and a screenshot of what it saw. Read it when an ad is rejected or reports a landing-page crawl serving issue. The screenshot link expires within 24 hours. available:false with pending:true means the capture is still running; available:false otherwise means OpenAI holds no capture (or the ad id is not on this account). The status is the crawl job’s, not the page’s HTTP status. Reading never starts a crawl. Read-only, 0 credits.',
|
|
12936
|
+
inputSchema: { adId: z.string().describe('the ad, e.g. ad_… from list_openai_ads_campaigns with an adGroupId') },
|
|
12937
|
+
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), available: z.boolean().optional(), pending: z.boolean().optional(), snapshotId: z.string().nullable().optional(), status: z.string().optional(), sourceUrl: z.string().nullable().optional(), finalUrl: z.string().nullable().optional(), capturedAt: z.string().nullable().optional(), crawlProfile: z.string().nullable().optional(), failureCode: z.string().nullable().optional(), robotsTxtAllowed: z.boolean().nullable().optional(), robotsTxtReason: z.string().nullable().optional(), robotsTxtUrl: z.string().nullable().optional(), userAgent: z.string().nullable().optional(), screenshotUrl: z.string().nullable().optional(), note: z.string().optional() },
|
|
12938
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12939
|
+
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/landing-page-crawl', a))));
|
|
12691
12940
|
|
|
12692
12941
|
server.registerTool('list_pinterest_ads_campaigns', {
|
|
12693
12942
|
title: 'List Pinterest ad accounts / campaigns',
|
|
@@ -13033,13 +13282,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13033
13282
|
}));
|
|
13034
13283
|
server.registerTool('create_reddit_ads_post', {
|
|
13035
13284
|
title: 'Create the Reddit post an ad will promote',
|
|
13036
|
-
description: 'Create the CREATIVE for a Reddit ad. This is the step people skip: a Reddit ad has no creative of its own — it points at a post — so a campaign and ad group with no post behind them can never serve. Types are TEXT (headline + body), IMAGE, VIDEO and CAROUSEL (up to 6 images). For image/video/carousel pass media[] with a PUBLIC mediaUrl; Reddit fetches and validates it itself (minimum 140×140), and a video also needs a thumbnailUrl. The destination for a click rides on the media entry’s destinationUrl, NOT on the ad. Reddit’s call-to-action values are human-readable strings with spaces and capitals — "Learn More", "Shop Now", "Sign Up" — not SCREAMING_SNAKE; the error lists all of them. The post is published on the profile immediately, so show the user the exact headline and body first.',
|
|
13285
|
+
description: 'Create the CREATIVE for a Reddit ad. This is the step people skip: a Reddit ad has no creative of its own — it points at a post — so a campaign and ad group with no post behind them can never serve. Types are TEXT (headline + body), IMAGE, VIDEO and CAROUSEL (up to 6 images), and an IMAGE, VIDEO or CAROUSEL post can carry body text too. For image/video/carousel pass media[] with a PUBLIC mediaUrl; Reddit fetches and validates it itself (minimum 140×140), and a video also needs a thumbnailUrl. The destination for a click rides on the media entry’s destinationUrl, NOT on the ad. Reddit’s call-to-action values are human-readable strings with spaces and capitals — "Learn More", "Shop Now", "Sign Up" — not SCREAMING_SNAKE; the error lists all of them. The post is published on the profile immediately, so show the user the exact headline and body first. A post with body text under media, or a rich-text body, is built by Reddit in the background: the answer is usually the post, and when Reddit is still building it the answer says pending with a jobId, so call this again with redditProfileId and that jobId (never re-send the post, which would make a second one).',
|
|
13037
13286
|
inputSchema: {
|
|
13038
13287
|
adAccountId: z.string().optional(),
|
|
13039
13288
|
redditProfileId: z.string().describe('the Reddit profile id (t2_…) to publish as — from list_reddit_ads_profiles'),
|
|
13289
|
+
jobId: z.string().optional().describe('PICK UP a post Reddit was still building: the jobId from an earlier pending answer. Creates nothing new; every other field is ignored.'),
|
|
13040
13290
|
type: z.enum(['TEXT', 'IMAGE', 'VIDEO', 'CAROUSEL']).optional().describe('default TEXT'),
|
|
13041
13291
|
headline: z.string().describe('the post title — this is the ad’s headline'),
|
|
13042
|
-
body: z.string().optional().describe('body copy
|
|
13292
|
+
body: z.string().optional().describe('body copy: the text of a TEXT post, or text shown with an IMAGE, VIDEO or CAROUSEL post. Up to 40,000 characters.'),
|
|
13293
|
+
textFormat: z.enum(['PLAIN_TEXT', 'RICH_TEXT_JSON']).optional().describe('how Reddit reads body: PLAIN_TEXT (the default) or RICH_TEXT_JSON, a Reddit rich-text JSON document serialized as a string. Cannot be changed after the post is created.'),
|
|
13043
13294
|
media: z.array(z.object({
|
|
13044
13295
|
mediaUrl: z.string().optional().describe('PUBLIC url of the image/video — Reddit fetches it, minimum 140×140'),
|
|
13045
13296
|
destinationUrl: z.string().optional().describe('where a click goes — required for image and carousel posts'),
|
|
@@ -13051,11 +13302,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13051
13302
|
thumbnailUrl: z.string().optional().describe('required for VIDEO posts'),
|
|
13052
13303
|
allowComments: z.boolean().optional().describe('Reddit ads can carry a public comment thread — decide deliberately'),
|
|
13053
13304
|
},
|
|
13054
|
-
outputSchema: { id: z.string().optional(), type: z.string().optional(), headline: z.string().optional(), postUrl: z.string().nullable().optional(), note: z.string().optional() },
|
|
13305
|
+
outputSchema: { id: z.string().optional(), type: z.string().optional(), headline: z.string().optional(), body: z.string().nullable().optional(), textFormat: z.string().nullable().optional(), postUrl: z.string().nullable().optional(), pending: z.boolean().optional(), jobId: z.string().optional(), note: z.string().optional() },
|
|
13055
13306
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
13056
13307
|
}, wrap(async (a) => {
|
|
13057
13308
|
const d = await apiPost('/api/reddit-ads/posts', a);
|
|
13058
|
-
|
|
13309
|
+
// A post Reddit is still building has no id yet: the note carries the jobId to pick it up with, and nothing here
|
|
13310
|
+
// may tell the caller to hand an ad a post that does not exist.
|
|
13311
|
+
return ok(d.pending ? d.note : `${d.note} Pass postId:"${d.id}" to create_reddit_ads_ad.`, d);
|
|
13059
13312
|
}));
|
|
13060
13313
|
server.registerTool('update_reddit_ads_post', {
|
|
13061
13314
|
title: 'Turn comments on or off on a Reddit ad post',
|
|
@@ -13584,7 +13837,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13584
13837
|
}));
|
|
13585
13838
|
server.registerTool('create_reddit_ads_lead_form', {
|
|
13586
13839
|
title: 'Create a Reddit lead generation form',
|
|
13587
|
-
description: 'REDDIT
|
|
13840
|
+
description: 'REDDIT HAS RETIRED THIS, SO IT NOW REFUSES, AND IT CANNOT BE REPLACED LIKE FOR LIKE. Reddit removed onsite lead forms entirely: the API sunset on 2026-09-21 by Reddit’s changelog (their migration guide said 2026-09-30 for the same change; Hermoso stopped creating on the earlier date), Reddit’s API reference now marks the create call deprecated and it answers 410 Gone from 2026-09-30, and Reddit paused every ad still using an onsite form on 2026-09-30. So this tool refuses for free, before calling Reddit, and says what to do instead. DO NOT BUILD A NEW FUNNEL ON IT: Reddit’s replacement is an OFFLINE form on the advertiser’s own landing page, measured with the Reddit Pixel or the Conversions API — list_reddit_ads_pixels and send_reddit_ads_conversions, both of which work here today — inside an offsite lead generation campaign (create_reddit_ads_campaign with objective LEAD_GENERATION). Existing forms stay readable with list_reddit_ads_lead_forms. What it used to do: create a lead generation form — the in-feed form redditors fill in without leaving Reddit, used by LEAD_GENERATION campaigns. Reddit requires a link to a real privacy policy on every form. Ask for the FEWEST fields that make a lead useful: every extra question costs completions. KNOW THE LIMIT BEFORE YOU PROMISE ANYTHING: Reddit exposes no way to attach a form to an ad through the API — there is no lead-form field on an ad, an ad group or a post — so the user picks this form in Reddit’s Ads Manager when building the creative, and downloads its leads from there. There is also no update and no delete, so get the questions right the first time. Free.',
|
|
13588
13841
|
inputSchema: {
|
|
13589
13842
|
adAccountId: z.string().optional(),
|
|
13590
13843
|
name: z.string().describe('internal name — redditors do not see it'),
|
|
@@ -16127,7 +16380,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16127
16380
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (LinkedIn\u2019s video thumbnail upload). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
16128
16381
|
},
|
|
16129
16382
|
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() },
|
|
16130
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16383
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16131
16384
|
}, publishWrap(async (a) => {
|
|
16132
16385
|
const d = await apiPost('/api/linkedin/org-post', a);
|
|
16133
16386
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was posted a second time.)`, d);
|
|
@@ -16755,7 +17008,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16755
17008
|
trash: z.boolean().optional().describe('true -> move to Trash; false -> restore from Trash'),
|
|
16756
17009
|
},
|
|
16757
17010
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), trashed: z.boolean().optional() },
|
|
16758
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17011
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
16759
17012
|
}, wrap(async (a) => {
|
|
16760
17013
|
const d = await apiPost('/api/drive/file/update', a);
|
|
16761
17014
|
return ok(`Updated “${d.name || a.fileId}”.`, d);
|
|
@@ -16868,31 +17121,42 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16868
17121
|
}));
|
|
16869
17122
|
server.registerTool('append_to_doc', {
|
|
16870
17123
|
title: 'Append text to a Google Doc',
|
|
16871
|
-
description: 'Append text to the end of a Google Doc Hermoso can reach — one it created (pass the documentId from create_doc) or one the user handed over with the Google file picker in the app (find its id with list_drive_files).',
|
|
17124
|
+
description: 'Append text to the end of a Google Doc Hermoso can reach — one it created (pass the documentId from create_doc) or one the user handed over with the Google file picker in the app (find its id with list_drive_files). Optional `dropdown:{title, options:[2-50], selected}` appends a Google Docs dropdown chip after the text — e.g. text "Status: " + dropdown {title:"Status", options:["Draft","In review","Approved"]} gives a brief a status the team can click to change; the chip is confirmed by reading the doc back. Set it later with update_doc dropdowns.',
|
|
16872
17125
|
inputSchema: {
|
|
16873
17126
|
documentId: z.string().describe('the document id from create_doc'),
|
|
16874
|
-
text: z.string().describe('text to append at the end of the doc'),
|
|
16875
|
-
|
|
16876
|
-
|
|
17127
|
+
text: z.string().optional().describe('text to append at the end of the doc (optional when a dropdown is passed)'),
|
|
17128
|
+
dropdown: z.object({
|
|
17129
|
+
title: z.string().optional().describe('the dropdown title, e.g. "Status"'),
|
|
17130
|
+
options: z.array(z.string()).describe('2 to 50 distinct options'),
|
|
17131
|
+
selected: z.string().optional().describe('the option it starts on (default: the first)'),
|
|
17132
|
+
}).optional().describe('a dropdown chip to append after the text'),
|
|
17133
|
+
},
|
|
17134
|
+
outputSchema: { ok: z.boolean().optional(), documentId: z.string().optional(), url: z.string().optional(), verified: z.boolean().nullable().optional(),
|
|
17135
|
+
dropdown: z.any().optional(), note: z.string().optional() },
|
|
16877
17136
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
16878
17137
|
}, wrap(async (a) => {
|
|
16879
17138
|
const d = await apiPost('/api/docs/append', a);
|
|
16880
|
-
return ok(`Appended text to the doc — ${d.url}`, d);
|
|
17139
|
+
return ok(d.note || `Appended text to the doc — ${d.url}`, d);
|
|
16881
17140
|
}));
|
|
16882
17141
|
server.registerTool('read_doc', {
|
|
16883
17142
|
title: 'Read a Google Doc',
|
|
16884
|
-
description: 'Read the text of a Google Doc Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING doc becomes readable; find its id with list_drive_files). Pass documentId (from create_doc) OR paste a Google Docs URL as docUrl. Under the drive.file scope it reaches nothing else in the user’s Drive; if Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns the plain text. Read-only, free.',
|
|
17143
|
+
description: 'Read the text of a Google Doc Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING doc becomes readable; find its id with list_drive_files). Pass documentId (from create_doc) OR paste a Google Docs URL as docUrl. Under the drive.file scope it reaches nothing else in the user’s Drive; if Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns the plain text of EVERY tab (each headed by its tab name when there are several), with smart chips (dropdowns, people, dates, rich links) shown as the text Docs displays, plus `dropdowns[]` — each chip with its title, current value and options, which is what update_doc dropdowns sets. Read-only, free.',
|
|
16885
17144
|
inputSchema: {
|
|
16886
17145
|
documentId: z.string().optional().describe('the document id (from create_doc)'),
|
|
16887
17146
|
docUrl: z.string().optional().describe('a Google Docs URL to read — the document id is extracted from it'),
|
|
16888
17147
|
},
|
|
16889
|
-
outputSchema: { ok: z.boolean().optional(), documentId: z.string().optional(), title: z.string().optional(), text: z.string().optional()
|
|
17148
|
+
outputSchema: { ok: z.boolean().optional(), documentId: z.string().optional(), title: z.string().optional(), text: z.string().optional(),
|
|
17149
|
+
tabs: z.array(z.object({ tabId: z.string().nullable().optional(), title: z.string().optional(), chars: z.number().optional() })).optional(),
|
|
17150
|
+
dropdowns: z.array(z.object({ dropdownId: z.string().optional(), tabId: z.string().nullable().optional(), definitionId: z.string().nullable().optional(), title: z.string().optional(),
|
|
17151
|
+
value: z.string().optional(), selectedOptionId: z.string().nullable().optional(), options: z.array(z.object({ optionId: z.string().optional(), value: z.string().optional() })).optional() })).optional() },
|
|
16890
17152
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
16891
17153
|
}, wrap(async (a) => {
|
|
16892
17154
|
const id = (String(a.docUrl || '').match(/\/document\/d\/([\w-]+)/) || [])[1] || a.documentId;
|
|
16893
17155
|
if (!id) return { content: [{ type: 'text', text: 'Pass a documentId or a Google Docs URL (docUrl).' }], isError: true };
|
|
16894
17156
|
const d = await apiGet('/api/docs/read', { documentId: id });
|
|
16895
|
-
|
|
17157
|
+
const chips = (d.dropdowns || []).length ? `\nDropdowns: ${d.dropdowns.map(c => `${c.title ? `“${c.title}”` : '(untitled)'} = “${c.value}” [${c.dropdownId}; options: ${(c.options || []).map(o => o.value).join(' / ')}]`).join('; ')}` : '';
|
|
17158
|
+
const tabs = (d.tabs || []).length > 1 ? ` across ${d.tabs.length} tabs` : '';
|
|
17159
|
+
return ok(`Read “${d.title || 'the doc'}” (${(d.text || '').length} chars${tabs}):${chips}\n${(d.text || '').slice(0, 8000)}`, d);
|
|
16896
17160
|
}));
|
|
16897
17161
|
|
|
16898
17162
|
// ---------- The Sheets / Docs WRITE surface (2026-08-05) ----------
|
|
@@ -16973,23 +17237,27 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16973
17237
|
boldHeader: z.boolean().optional(), freezeRows: z.number().optional().describe('how many top rows to freeze (default 1, 0 = none)'), autoResize: z.boolean().optional(),
|
|
16974
17238
|
},
|
|
16975
17239
|
outputSchema: { ok: z.boolean().optional(), spreadsheetId: z.string().optional(), tab: z.string().optional(), sheetId: z.number().optional(), url: z.string().optional(), applied: z.array(z.string()).optional(), note: z.string().optional() },
|
|
16976
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17240
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
16977
17241
|
}, wrap(async (a) => {
|
|
16978
17242
|
const d = await apiPost('/api/sheets/format', a);
|
|
16979
17243
|
return ok(d.note, d);
|
|
16980
17244
|
}));
|
|
16981
17245
|
server.registerTool('update_doc', {
|
|
16982
17246
|
title: 'Edit a Google Doc in place',
|
|
16983
|
-
description: 'EDIT a Google Doc — the correction append_to_doc cannot make, which until now meant a doc could only ever grow and a wrong line stayed in it forever. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:"…"` replaces the ENTIRE body (rewrite:"" empties it). Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.',
|
|
17247
|
+
description: 'EDIT a Google Doc — the correction append_to_doc cannot make, which until now meant a doc could only ever grow and a wrong line stayed in it forever. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:"…"` replaces the ENTIRE body (rewrite:"" empties it), or `dropdowns:[{title, value}]` sets a dropdown chip (e.g. Status → Approved — read_doc lists every chip with its options; pass dropdownId when two share a title; an unknown title or option is refused with the real list and nothing changes). Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.',
|
|
16984
17248
|
inputSchema: {
|
|
16985
17249
|
documentId: z.string().optional().describe('the document id (from create_doc, or list_drive_files for one the user picked)'),
|
|
16986
17250
|
docUrl: z.string().optional().describe('a Google Docs URL — the id is extracted from it'),
|
|
16987
17251
|
replacements: z.array(z.object({ find: z.string(), replace: z.string().optional(), matchCase: z.boolean().optional() })).optional().describe('find/replace pairs, applied in order'),
|
|
16988
17252
|
rewrite: z.string().optional().describe('replace the WHOLE body with this text ("" empties the doc)'),
|
|
16989
17253
|
confirm: z.boolean().optional(), confirmCells: z.number().optional().describe('echo back the character count the unconfirmed call reported (rewrite only)'),
|
|
17254
|
+
dropdowns: z.array(z.object({ title: z.string().optional().describe('the dropdown title (from read_doc)'), dropdownId: z.string().optional().describe('when two dropdowns share a title'),
|
|
17255
|
+
tabId: z.string().optional(), value: z.string().describe('the option to select, by its display text') })).optional().describe('dropdown chips to set'),
|
|
16990
17256
|
},
|
|
16991
17257
|
outputSchema: { ok: z.boolean().optional(), documentId: z.string().optional(), title: z.string().optional(), url: z.string().optional(), occurrences: z.number().optional(), replacedChars: z.number().optional(), verified: z.boolean().nullable().optional(), text: z.string().nullable().optional(),
|
|
16992
|
-
replacements: z.array(z.object({ find: z.string().optional(), replace: z.string().optional(), occurrences: z.number().optional() })).optional(),
|
|
17258
|
+
replacements: z.array(z.object({ find: z.string().optional(), replace: z.string().optional(), occurrences: z.number().optional() })).optional(),
|
|
17259
|
+
dropdowns: z.array(z.object({ dropdownId: z.string().optional(), title: z.string().optional(), from: z.string().optional(), to: z.string().optional(), changed: z.boolean().optional(), verified: z.boolean().nullable().optional() })).optional(),
|
|
17260
|
+
note: z.string().optional() },
|
|
16993
17261
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
16994
17262
|
}, wrap(async (a) => {
|
|
16995
17263
|
const d = await apiPost('/api/docs/update', a);
|
|
@@ -17049,7 +17317,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17049
17317
|
moveToFolderId: z.string().optional().describe('folder id to move the item into (from create_onedrive_folder / list_onedrive_files)'),
|
|
17050
17318
|
},
|
|
17051
17319
|
outputSchema: { id: z.string().optional(), name: z.string().optional() },
|
|
17052
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17320
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
17053
17321
|
}, wrap(async (a) => {
|
|
17054
17322
|
const d = await apiPost('/api/onedrive/file/update', a);
|
|
17055
17323
|
return ok(`Updated “${d.name || a.fileId}”.`, d);
|
|
@@ -18255,34 +18523,34 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18255
18523
|
server.registerTool('save_skill', {
|
|
18256
18524
|
title: 'Save a skill',
|
|
18257
18525
|
description: 'Save a reusable custom SKILL — a named creative directive/playbook applied to future ads (a hook formula, a UGC recipe, a compliance rule, a named specialist persona like “our founder-story style” or “short-form ad strategist”). Distill an imperative, self-contained directive. Merges into the workspace Skills library (list_skills shows built-ins + your custom skills).',
|
|
18258
|
-
inputSchema: {
|
|
18526
|
+
inputSchema: { ...STORE_BRAND,
|
|
18259
18527
|
name: z.string().describe('short skill name, e.g. “Founder-story hook”'),
|
|
18260
18528
|
directive: z.string().describe('the full instruction the skill applies when used (1–6 sentences, imperative)'),
|
|
18261
18529
|
},
|
|
18262
18530
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
18263
18531
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18264
|
-
}, wrap(async (a) => {
|
|
18532
|
+
}, wrap(inBrand(async (a) => {
|
|
18265
18533
|
const name = String(a.name || '').trim(), directive = String(a.directive || '').trim();
|
|
18266
18534
|
if (!name || !directive) return { content: [{ type: 'text', text: 'A skill needs both a name and a directive.' }], isError: true };
|
|
18267
18535
|
let list = await readStore('heist.skills.v1'); if (!Array.isArray(list)) list = [];
|
|
18268
18536
|
const item = { id: newId('s'), name, directive, kind: 'custom', custom: true, createdAt: Date.now() };
|
|
18269
18537
|
await writeStore('heist.skills.v1', [item, ...list].slice(0, 200));
|
|
18270
18538
|
return ok(`Saved skill “${name}” — it’s now in the workspace Skills library.`, { ok: true, id: item.id });
|
|
18271
|
-
}));
|
|
18539
|
+
})));
|
|
18272
18540
|
server.registerTool('delete_skill', {
|
|
18273
18541
|
title: 'Delete a custom skill',
|
|
18274
18542
|
description: 'Delete one of the workspace’s CUSTOM skills by id (from list_skills). Built-in skills/recipes can’t be deleted. Minor + re-creatable, so no confirm needed.',
|
|
18275
|
-
inputSchema: { id: z.string().describe('the custom skill id (from list_skills)') },
|
|
18543
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the custom skill id (from list_skills)') },
|
|
18276
18544
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18277
18545
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18278
|
-
}, wrap(async (a) => {
|
|
18546
|
+
}, wrap(inBrand(async (a) => {
|
|
18279
18547
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the skill id (from list_skills).' }], isError: true };
|
|
18280
18548
|
let list = await readStore('heist.skills.v1'); if (!Array.isArray(list)) list = [];
|
|
18281
18549
|
if (!list.some(s => s && s.id === id)) return ok(`No custom skill ${id} (built-ins can’t be deleted).`, { ok: false, removed: false });
|
|
18282
18550
|
await tombstone('heist.skills.v1', id); // write the delete FIRST so the content merge honors it (won’t resurrect)
|
|
18283
18551
|
await writeStore('heist.skills.v1', list.filter(s => s && s.id !== id));
|
|
18284
18552
|
return ok(`Deleted skill ${id}.`, { ok: true, removed: true });
|
|
18285
|
-
}));
|
|
18553
|
+
})));
|
|
18286
18554
|
server.registerTool('list_memory', {
|
|
18287
18555
|
title: 'List memory',
|
|
18288
18556
|
description: 'List the durable facts & preferences saved in this workspace’s Memory (what the studio remembers about the brand, audience, taste, and do/don’t rules) — the same Memory the web app shows. These shape every future ad. Read-only, free.',
|
|
@@ -18321,13 +18589,13 @@ function memoryNoteVerdict(text) {
|
|
|
18321
18589
|
server.registerTool('remember', {
|
|
18322
18590
|
title: 'Remember a fact',
|
|
18323
18591
|
description: 'Save a durable fact or PREFERENCE about the brand, audience, or the user’s creative TASTE (e.g. “audience is first-time homebuyers”, “prefers bold lime accents”, “always captions off”) into the workspace Memory so it shapes FUTURE ads. For lasting things, not one-off requests. Merges into the existing Memory (never overwrites); de-dupes on identical text. NEVER how Hermoso, a tool, a connector or a platform API behaves (what a call returns, errors, permissions, limits, ids) and never a phone number or email — those are refused.',
|
|
18324
|
-
inputSchema: {
|
|
18592
|
+
inputSchema: { ...STORE_BRAND,
|
|
18325
18593
|
text: z.string().describe('the fact/preference, concise'),
|
|
18326
18594
|
category: z.string().optional().describe('short bucket: Brand, Audience, Taste, Do, Don’t, or Preference (default General)'),
|
|
18327
18595
|
},
|
|
18328
18596
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
18329
18597
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18330
|
-
}, wrap(async (a) => {
|
|
18598
|
+
}, wrap(inBrand(async (a) => {
|
|
18331
18599
|
const text = String(a.text || '').trim(); if (!text) return { content: [{ type: 'text', text: 'Nothing to remember — pass text.' }], isError: true };
|
|
18332
18600
|
{ const v = memoryNoteVerdict(text); if (!v.ok) return { content: [{ type: 'text', text: v.message }], isError: true }; }
|
|
18333
18601
|
let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
|
|
@@ -18335,21 +18603,21 @@ function memoryNoteVerdict(text) {
|
|
|
18335
18603
|
const item = { id: newId('m'), text, category: String(a.category || 'General').trim() || 'General', source: 'mcp', createdAt: Date.now() };
|
|
18336
18604
|
await writeStore('heist.memory.v1', [item, ...list].slice(0, 200));
|
|
18337
18605
|
return ok(`Saved to memory: “${text}” [${item.category}].`, { ok: true, id: item.id });
|
|
18338
|
-
}));
|
|
18606
|
+
})));
|
|
18339
18607
|
server.registerTool('forget', {
|
|
18340
18608
|
title: 'Forget a memory',
|
|
18341
18609
|
description: 'Delete a saved Memory item by its id (from list_memory). Records a cross-device delete so it doesn’t come back. Minor + re-creatable (you can remember it again), so no confirm needed.',
|
|
18342
|
-
inputSchema: { id: z.string().describe('the memory item id (from list_memory)') },
|
|
18610
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the memory item id (from list_memory)') },
|
|
18343
18611
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18344
18612
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18345
|
-
}, wrap(async (a) => {
|
|
18613
|
+
}, wrap(inBrand(async (a) => {
|
|
18346
18614
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the memory id (from list_memory).' }], isError: true };
|
|
18347
18615
|
let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
|
|
18348
18616
|
if (!list.some(m => m && m.id === id)) return ok(`No memory item ${id}.`, { ok: false, removed: false });
|
|
18349
18617
|
await tombstone('heist.memory.v1', id);
|
|
18350
18618
|
await writeStore('heist.memory.v1', list.filter(m => m && m.id !== id));
|
|
18351
18619
|
return ok(`Forgot memory ${id}.`, { ok: true, removed: true });
|
|
18352
|
-
}));
|
|
18620
|
+
})));
|
|
18353
18621
|
|
|
18354
18622
|
// ── SWIPEFILE — the workspace's saved-ad research board: named COLLECTIONS holding the ads and creatives the user
|
|
18355
18623
|
// kept. It is a SERVER-SYNCED store, not browser state: `adInspo.swipefile.v1` is in the client's PROFILE_SCOPED_SYNC
|
|
@@ -18362,10 +18630,10 @@ function memoryNoteVerdict(text) {
|
|
|
18362
18630
|
server.registerTool('tidy_memory', {
|
|
18363
18631
|
title: 'Tidy the Memory list',
|
|
18364
18632
|
description: 'Clean up and consolidate the workspace Memory: drops entries that are about how Hermoso, a tool, a connector or a platform API behaves (product behaviour, not the brand), drops phone numbers and emails, and merges near-duplicate facts into one sentence each. Call with no argument to get the PROPOSAL (what would be removed and merged, with reasons) — nothing changes. Call again with confirm:true to apply it through the same typed writers the app uses (deletes carry tombstones so they stay deleted on every device). One small model call; a few credits.',
|
|
18365
|
-
inputSchema: { confirm: z.boolean().optional().describe('true to APPLY the proposal; omit to only see it') },
|
|
18633
|
+
inputSchema: { ...STORE_BRAND, confirm: z.boolean().optional().describe('true to APPLY the proposal; omit to only see it') },
|
|
18366
18634
|
outputSchema: { scanned: z.number().optional(), remove: z.array(z.any()).optional(), merge: z.array(z.any()).optional(), applied: z.boolean().optional() },
|
|
18367
18635
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18368
|
-
}, wrap(async (a) => {
|
|
18636
|
+
}, wrap(inBrand(async (a) => {
|
|
18369
18637
|
let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
|
|
18370
18638
|
if (!list.length) return ok('Memory is empty — nothing to tidy.', { scanned: 0, remove: [], merge: [], applied: false });
|
|
18371
18639
|
const r = await apiPost('/api/memory/tidy', { items: list.map(m => ({ id: m.id, text: m.text, category: m.category })) });
|
|
@@ -18381,7 +18649,7 @@ function memoryNoteVerdict(text) {
|
|
|
18381
18649
|
const merged = (d.merge || []).map(m => ({ id: newId('m'), text: m.text, category: m.category || 'General', source: 'tidy', createdAt: Date.now() }));
|
|
18382
18650
|
await writeStore('heist.memory.v1', [...merged, ...list.filter(m => m && !gone.has(String(m.id)))].slice(0, 200));
|
|
18383
18651
|
return ok(`Tidied: removed ${gone.size - (d.merge || []).flatMap(m => m.ids).length}, merged ${(d.merge || []).length} group(s). ${list.length} → ${list.length - gone.size + merged.length} memories.\n${lines.join('\n')}`, { scanned: list.length, remove: d.remove, merge: d.merge, applied: true });
|
|
18384
|
-
}));
|
|
18652
|
+
})));
|
|
18385
18653
|
|
|
18386
18654
|
const SWIPE_KEY = 'adInspo.swipefile.v1';
|
|
18387
18655
|
const swipeEmpty = () => ({ collections: [{ id: 'default', name: 'My Swipefile' }], activeId: 'default', ads: [] });
|
|
@@ -18424,7 +18692,7 @@ function memoryNoteVerdict(text) {
|
|
|
18424
18692
|
server.registerTool('save_to_swipefile', {
|
|
18425
18693
|
title: 'Save ads to the swipefile',
|
|
18426
18694
|
description: 'Save one or more ads/creatives to a named SWIPEFILE collection, creating the collection if it does not exist — the headless twin of the heart on every ad card in the web app. Use it whenever research turns up something worth keeping: a competitor ad from search_meta_ads / pull_competitor_ads, an organic post, or one of your own renders. Saved ads persist to the workspace board the web Swipefile tab shows, and feed the taste signal every future ad is planned against. De-dupes: re-saving the same ad (same key/link/media) MOVES it into the named collection instead of duplicating it. Free.',
|
|
18427
|
-
inputSchema: {
|
|
18695
|
+
inputSchema: { ...STORE_BRAND,
|
|
18428
18696
|
collection: z.string().describe('the collection name — an existing one, or a new one to create'),
|
|
18429
18697
|
items: z.array(z.object({
|
|
18430
18698
|
key: z.string().optional().describe('a stable id for this ad if you have one (an ad_archive_id, creativeId, …). Omit and one is derived from the link/media so re-saving is idempotent'),
|
|
@@ -18441,7 +18709,7 @@ function memoryNoteVerdict(text) {
|
|
|
18441
18709
|
},
|
|
18442
18710
|
outputSchema: { ok: z.boolean().optional(), collection: z.string().optional(), collectionId: z.string().optional(), saved: z.number().optional(), moved: z.number().optional(), total: z.number().optional() },
|
|
18443
18711
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
18444
|
-
}, wrap(async (a) => {
|
|
18712
|
+
}, wrap(inBrand(async (a) => {
|
|
18445
18713
|
const name = String(a.collection || '').trim();
|
|
18446
18714
|
const items = (Array.isArray(a.items) ? a.items : []).filter(x => x && typeof x === 'object');
|
|
18447
18715
|
if (!name) return { content: [{ type: 'text', text: 'Name the collection to save into.' }], isError: true };
|
|
@@ -18461,7 +18729,7 @@ function memoryNoteVerdict(text) {
|
|
|
18461
18729
|
s.activeId = col.id;
|
|
18462
18730
|
await writeStore(SWIPE_KEY, s);
|
|
18463
18731
|
return ok(`${saved} ad(s) saved${moved ? `, ${moved} moved` : ''} into “${col.name}”${created ? ' (new collection)' : ''} — it’s on the workspace swipefile now (list_swipefile to read it back).`, { ok: true, collection: col.name, collectionId: col.id, saved, moved, total: s.ads.length });
|
|
18464
|
-
}));
|
|
18732
|
+
})));
|
|
18465
18733
|
|
|
18466
18734
|
|
|
18467
18735
|
// ── SAVED CREATORS: outreach status + notes (2026-09-05: "parity with dedicated tools"). A creator saved by
|
|
@@ -18471,14 +18739,14 @@ function memoryNoteVerdict(text) {
|
|
|
18471
18739
|
server.registerTool('update_saved_creator', {
|
|
18472
18740
|
title: 'Update a saved creator (outreach status, note)',
|
|
18473
18741
|
description: 'Set the OUTREACH STATUS and/or a NOTE on a creator already saved in the swipefile (find_creators -> save_to_swipefile, or the heart on a creator card). Status is one of new | contacted | replied | booked | passed. The note is free text (deal terms, rate, what was sent). Reads back the updated row. Use list_swipefile to find the key. Free.',
|
|
18474
|
-
inputSchema: {
|
|
18742
|
+
inputSchema: { ...STORE_BRAND,
|
|
18475
18743
|
key: z.string().describe("the saved row's key from list_swipefile, e.g. tiktok:handle"),
|
|
18476
18744
|
status: z.enum(['new', 'contacted', 'replied', 'booked', 'passed']).optional(),
|
|
18477
18745
|
note: z.string().max(2000).optional().describe('replaces the existing note; pass "" to clear it'),
|
|
18478
18746
|
},
|
|
18479
18747
|
outputSchema: { ok: z.boolean().optional(), key: z.string().optional(), status: z.string().optional(), note: z.string().optional(), creator: z.any().optional() },
|
|
18480
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
18481
|
-
}, wrap(async (a) => {
|
|
18748
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18749
|
+
}, wrap(inBrand(async (a) => {
|
|
18482
18750
|
const key = String(a.key || '').trim();
|
|
18483
18751
|
if (!key) return { content: [{ type: 'text', text: 'Pass the saved creator key (from list_swipefile).' }], isError: true };
|
|
18484
18752
|
if (a.status == null && a.note == null) return { content: [{ type: 'text', text: 'Pass a status and/or a note.' }], isError: true };
|
|
@@ -18495,7 +18763,7 @@ function memoryNoteVerdict(text) {
|
|
|
18495
18763
|
await writeStore(SWIPE_KEY, s);
|
|
18496
18764
|
const o = row.creator.outreach;
|
|
18497
18765
|
return ok(`@${row.creator.handle || key}: status ${o.status}${o.note ? ` — note: ${o.note}` : ''}.`, { ok: true, key, status: o.status, note: o.note, creator: row.creator });
|
|
18498
|
-
}));
|
|
18766
|
+
})));
|
|
18499
18767
|
|
|
18500
18768
|
// ── PLAYBOOKS — the "what's working + the plays to run" cards, distinct from the swipefile's raw creative. Same
|
|
18501
18769
|
// server-synced store seam (`heist.playbooks.v1`, union-merged by id in adapters/sync-merge.js); the web app has a
|
|
@@ -18525,22 +18793,21 @@ function memoryNoteVerdict(text) {
|
|
|
18525
18793
|
server.registerTool('save_playbook', {
|
|
18526
18794
|
title: 'Save a playbook',
|
|
18527
18795
|
description: 'Save a reusable PLAYBOOK — the strategy takeaways worth re-running: the hooks that work, the angles, the formats, and the concrete plays. Use it to keep what a competitor_teardown or mine_angles just found, or to bank a creative you want to repeat. Lands in the same Playbooks library the web app lists, runs and manages. Distinct from save_skill (a directive applied to every ad) and from the swipefile (raw saved creative). Free.',
|
|
18528
|
-
inputSchema: {
|
|
18796
|
+
inputSchema: { ...STORE_BRAND,
|
|
18529
18797
|
name: z.string().describe('the playbook headline — what it is, in a few words'),
|
|
18530
18798
|
hooks: z.array(z.string()).optional().describe('the opening hooks worth reusing, verbatim'),
|
|
18531
18799
|
angles: z.array(z.object({ title: z.string(), detail: z.string().optional() }).passthrough()).optional().describe('the persuasion angles ({title, detail})'),
|
|
18532
18800
|
formats: z.array(z.string()).optional().describe('the formats/recipes this plays best in (e.g. ugc_selfie, cinematic, static)'),
|
|
18533
18801
|
plays: z.array(z.object({ title: z.string(), detail: z.string().optional() }).passthrough()).optional().describe('the concrete plays to run ({title, detail}) — the actionable half'),
|
|
18534
|
-
brand: z.string().optional().describe('which brand this is for (defaults to the workspace brand)'),
|
|
18535
18802
|
source: z.string().optional().describe('where it came from, e.g. “teardown · Ridge”'),
|
|
18536
18803
|
},
|
|
18537
18804
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
18538
18805
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18539
|
-
}, wrap(async (a) => {
|
|
18806
|
+
}, wrap(inBrand(async (a) => {
|
|
18540
18807
|
const headline = String(a.name || '').trim();
|
|
18541
18808
|
if (!headline) return { content: [{ type: 'text', text: 'A playbook needs a name.' }], isError: true };
|
|
18542
18809
|
const list = await readPlaybooks();
|
|
18543
|
-
const brand = String(
|
|
18810
|
+
const brand = String((await readStore('heist.brand.v1'))?.name || '').trim() || namedBrand(a); // the label is the brand the playbook LANDS in (inBrand scoped the read), never a name that did not scope the write
|
|
18544
18811
|
const item = {
|
|
18545
18812
|
id: newId('pb'), headline: headline.slice(0, 200),
|
|
18546
18813
|
hooks: (a.hooks || []).slice(0, 20).map(t => ({ text: String(t).slice(0, 400), why: 'saved by an agent' })),
|
|
@@ -18551,21 +18818,21 @@ function memoryNoteVerdict(text) {
|
|
|
18551
18818
|
};
|
|
18552
18819
|
await writeStore(PB_KEY, [item, ...list].slice(0, 200));
|
|
18553
18820
|
return ok(`Saved playbook “${item.headline}” — it’s in the workspace Playbooks library (list_playbooks, and the app’s Playbooks tab can run it).`, { ok: true, id: item.id });
|
|
18554
|
-
}));
|
|
18821
|
+
})));
|
|
18555
18822
|
server.registerTool('delete_playbook', {
|
|
18556
18823
|
title: 'Delete a playbook',
|
|
18557
18824
|
description: 'Delete a saved playbook by id (from list_playbooks). Records a cross-device delete so it does not come back on the next sync. Minor + re-creatable, so no confirm needed.',
|
|
18558
|
-
inputSchema: { id: z.string().describe('the playbook id (from list_playbooks)') },
|
|
18825
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the playbook id (from list_playbooks)') },
|
|
18559
18826
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18560
18827
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18561
|
-
}, wrap(async (a) => {
|
|
18828
|
+
}, wrap(inBrand(async (a) => {
|
|
18562
18829
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the playbook id (from list_playbooks).' }], isError: true };
|
|
18563
18830
|
const list = await readPlaybooks();
|
|
18564
18831
|
if (!list.some(p => p && p.id === id)) return ok(`No playbook ${id}.`, { ok: false, removed: false });
|
|
18565
18832
|
await tombstone(PB_KEY, id); // the delete is written BEFORE the content so the union-merge already sees it
|
|
18566
18833
|
await writeStore(PB_KEY, list.filter(p => p && p.id !== id));
|
|
18567
18834
|
return ok(`Deleted playbook ${id}.`, { ok: true, removed: true });
|
|
18568
|
-
}));
|
|
18835
|
+
})));
|
|
18569
18836
|
|
|
18570
18837
|
// ── SAVED CREATORS — the workspace's reusable on-camera cast (`heist.avatars.v1`) ──────────────────────────────
|
|
18571
18838
|
// Closed 2026-08-01 (feature-surface sweep §4b, "on the web, absent from MCP"). The app has the whole lifecycle —
|
|
@@ -18597,7 +18864,7 @@ function memoryNoteVerdict(text) {
|
|
|
18597
18864
|
server.registerTool('save_creator', {
|
|
18598
18865
|
title: 'Save a creator',
|
|
18599
18866
|
description: 'Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + > Pick a creator > save. Pass the portrait’s public url (a generate_image render of an AI person, or a photo of a real person you have permission to use, or of yourself; never a photo just because it is public) plus a name to call them by; from then on list_creators returns them and their url can be re-passed to generate_avatar / generate_video / recast_motion. Saving is FREE and renders nothing. LIKENESS: leave `source` "generated" for an AI-made person (free on every plan) and use "upload"/"social" for a REAL person. ' + LIKENESS_TERMS,
|
|
18600
|
-
inputSchema: {
|
|
18867
|
+
inputSchema: { ...STORE_BRAND,
|
|
18601
18868
|
name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
|
|
18602
18869
|
image: z.string().optional().describe('REQUIRED except with useAnyway. public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns'),
|
|
18603
18870
|
source: z.enum(['generated', 'upload', 'social']).optional().describe('"generated" (default) = an AI-made person; "upload" / "social" = a REAL person'),
|
|
@@ -18608,7 +18875,7 @@ function memoryNoteVerdict(text) {
|
|
|
18608
18875
|
},
|
|
18609
18876
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional(), refQuality: z.any().optional() },
|
|
18610
18877
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18611
|
-
}, wrap(async (a) => {
|
|
18878
|
+
}, wrap(inBrand(async (a) => {
|
|
18612
18879
|
const name = String(a.name || '').trim();
|
|
18613
18880
|
// A LINK TO A PHOTO'S WEB PAGE (an Unsplash / Pexels / Pinterest page, 2026-09-29) is saved as the PICTURE behind it,
|
|
18614
18881
|
// so the cast never holds a page no render can read; a page with no reachable picture is refused here, saving nothing.
|
|
@@ -18665,14 +18932,14 @@ function memoryNoteVerdict(text) {
|
|
|
18665
18932
|
await writeStore('heist.avatars.v1', [item, ...list].slice(0, 200));
|
|
18666
18933
|
const warn = source !== 'generated' ? ' Saved as a real person: this is recorded as your confirmation that you are this person or have their consent, and that you take full, unlimited responsibility for their use (hermoso.ai/terms).' : '';
|
|
18667
18934
|
return ok(`Saved “${item.name}” to the workspace cast — they now show up in list_creators and in the app’s creator picker. Star them in a finished ad with render_ad(creator: "${item.name}"), have them say a line with generate_video(creator: "${item.name}", speak: "…"), or re-cast them in a raw render by passing ${abs(item.image)} as generate_video.refImage / recast_motion.image (generate_avatar.image only for the animated-photo look, when asked for by name).${warn}`, { ok: true, id: item.id, creator: { id: item.id, name: item.name, image: abs(item.image), source } });
|
|
18668
|
-
}));
|
|
18935
|
+
})));
|
|
18669
18936
|
server.registerTool('delete_creator', {
|
|
18670
18937
|
title: 'Delete a creator',
|
|
18671
18938
|
description: 'Remove a saved creator from this workspace’s cast by id (from list_creators). Records a cross-device delete so they don’t reappear on the user’s other devices. It only drops the roster entry — ads already rendered with that person are untouched — and the same portrait can be saved again with save_creator, so no confirm is needed.',
|
|
18672
|
-
inputSchema: { id: z.string().describe('the creator id (from list_creators)') },
|
|
18939
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the creator id (from list_creators)') },
|
|
18673
18940
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18674
18941
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18675
|
-
}, wrap(async (a) => {
|
|
18942
|
+
}, wrap(inBrand(async (a) => {
|
|
18676
18943
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the creator id (from list_creators).' }], isError: true };
|
|
18677
18944
|
let list = await readStore('heist.avatars.v1'); if (!Array.isArray(list)) list = [];
|
|
18678
18945
|
const hit = list.find(x => x && x.id === id);
|
|
@@ -18680,11 +18947,11 @@ function memoryNoteVerdict(text) {
|
|
|
18680
18947
|
await tombstone('heist.avatars.v1', id); // written BEFORE the content PUT so the union-merge already sees the delete
|
|
18681
18948
|
await writeStore('heist.avatars.v1', list.filter(x => x && x.id !== id));
|
|
18682
18949
|
return ok(`Removed “${hit.name || 'Creator'}” from the workspace cast.`, { ok: true, removed: true });
|
|
18683
|
-
}));
|
|
18950
|
+
})));
|
|
18684
18951
|
server.registerTool('update_brand', {
|
|
18685
18952
|
title: 'Update brand fields',
|
|
18686
|
-
description: 'Patch SPECIFIC fields of the workspace brand profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, and how the brand and product names are pronounced) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. Only pass the fields you’re changing.',
|
|
18687
|
-
inputSchema: {
|
|
18953
|
+
description: 'Patch SPECIFIC fields of the workspace brand profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, and how the brand and product names are pronounced) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.',
|
|
18954
|
+
inputSchema: { ...STORE_BRAND,
|
|
18688
18955
|
name: z.string().optional(), domain: z.string().optional().describe('website domain'), sells: z.string().optional().describe('what the brand sells'),
|
|
18689
18956
|
summary: z.string().optional().describe('one-line description'), category: z.string().optional(), audience: z.string().optional(),
|
|
18690
18957
|
positioning: z.string().optional(), voice: z.string().optional().describe('brand voice/tone'), style: z.string().optional().describe('visual style — palette, typography, aesthetic'), goal: z.string().optional().describe('current marketing goal'),
|
|
@@ -18692,8 +18959,8 @@ function memoryNoteVerdict(text) {
|
|
|
18692
18959
|
pronunciations: z.record(z.string()).optional().describe('how PRODUCT names are said aloud, e.g. {"Power Cakes": "POW-er cakes"}. Merged into the saved ones; an empty string removes one.'),
|
|
18693
18960
|
},
|
|
18694
18961
|
outputSchema: { ok: z.boolean().optional(), updated: z.array(z.string()).optional(), brand: z.any().optional() },
|
|
18695
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
18696
|
-
}, wrap(async (a) => {
|
|
18962
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
18963
|
+
}, wrap(inBrand(async (a) => {
|
|
18697
18964
|
const allow = ['name', 'domain', 'sells', 'summary', 'category', 'audience', 'positioning', 'voice', 'style', 'goal', 'pronounce'];
|
|
18698
18965
|
const patch = {}; for (const k of allow) if (a[k] != null && String(a[k]).trim()) patch[k] = String(a[k]).slice(0, 400);
|
|
18699
18966
|
let brand = await readStore('heist.brand.v1'); if (!brand || typeof brand !== 'object' || Array.isArray(brand)) brand = {};
|
|
@@ -18706,8 +18973,8 @@ function memoryNoteVerdict(text) {
|
|
|
18706
18973
|
if (!Object.keys(patch).length) return { content: [{ type: 'text', text: 'Pass at least one brand field to change.' }], isError: true };
|
|
18707
18974
|
const merged = { ...brand, ...patch };
|
|
18708
18975
|
await writeStore('heist.brand.v1', merged); // the store PUT preserves server-side brand enrichments (playbook/pronounce)
|
|
18709
|
-
return ok(`Updated brand (${Object.keys(patch).join(', ')}
|
|
18710
|
-
}));
|
|
18976
|
+
return ok(`Updated ${merged.name ? `“${merged.name}”` : 'the brand'}${namedBrand(a) ? ` (the brand you named, ${namedBrand(a)})` : ''} — ${Object.keys(patch).join(', ')}.`, { ok: true, updated: Object.keys(patch), brand: merged });
|
|
18977
|
+
})));
|
|
18711
18978
|
server.registerTool('store_get', {
|
|
18712
18979
|
title: 'Read a workspace store',
|
|
18713
18980
|
description: 'Read one of this workspace’s data stores by key, for visibility into what the app holds — playbooks, swipefile, saved locations, avatars, creations, chats, brand, memory, skills. Read-only, free. Allowed keys: ' + STORE_GET_ALLOW.join(', ') + '. (The typed tools — list_memory / list_skills / get_brand — are friendlier for those; use store_get for the rest.)',
|
|
@@ -18747,7 +19014,7 @@ function memoryNoteVerdict(text) {
|
|
|
18747
19014
|
watchEmail: z.boolean().optional().describe('weekly competitor-watch email on/off'),
|
|
18748
19015
|
},
|
|
18749
19016
|
outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).optional(), language: z.string().optional(), theme: z.string().optional(), notifications: z.any().optional() },
|
|
18750
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19017
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18751
19018
|
}, wrap(async (a) => {
|
|
18752
19019
|
const body = {};
|
|
18753
19020
|
if (a.language != null) body.language = a.language;
|
|
@@ -18806,11 +19073,7 @@ function memoryNoteVerdict(text) {
|
|
|
18806
19073
|
// A feature whose permission Meta has not approved Hermoso's app for yet, read live by the server off Meta's own
|
|
18807
19074
|
// answer about the app. It is the OPPOSITE instruction to the permission gap above: nothing the user does fixes
|
|
18808
19075
|
// it, so it is never phrased as a reconnect. The sentence is the SERVER'S (`awaitingApprovalNote`).
|
|
18809
|
-
const
|
|
18810
|
-
const waitingNote = waiting.length
|
|
18811
|
-
? '\n\n' + waiting.map(c => `ℹ ${c.provider}: ${c.awaitingApprovalNote}`).join('\n')
|
|
18812
|
-
+ '\nThose tools stay callable (they work for people with a role on the Hermoso app), but for this user Meta will refuse them until it approves Hermoso: say that plainly and never suggest reconnecting for it.'
|
|
18813
|
-
: '';
|
|
19076
|
+
const waitingNote = ''; // a feature not offered yet is not listed on the connection (owner decision, 2026-10-02)
|
|
18814
19077
|
const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}`);
|
|
18815
19078
|
const postingNote = posting.length
|
|
18816
19079
|
? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
|
|
@@ -19022,7 +19285,7 @@ function memoryNoteVerdict(text) {
|
|
|
19022
19285
|
accountIds: z.array(z.string()).describe('the ids (from list_connector_accounts) this brand may use — an empty array shares nothing'),
|
|
19023
19286
|
},
|
|
19024
19287
|
outputSchema: { ok: z.boolean().optional(), provider: z.string().optional(), selectedIds: z.array(z.string()).optional() },
|
|
19025
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19288
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19026
19289
|
}, wrap(async (a) => {
|
|
19027
19290
|
const p = CONN_ACCOUNT_PICKERS[a.provider];
|
|
19028
19291
|
if (!p) return { content: [{ type: 'text', text: `set_connector_accounts covers: ${CONN_PICKER_IDS.join(', ')}.` }], isError: true };
|
|
@@ -19089,7 +19352,7 @@ function memoryNoteVerdict(text) {
|
|
|
19089
19352
|
fields: z.record(z.string()).optional().describe('that provider\'s own field names and values, e.g. {"apiKey":"…"}; the names for each provider are in the description'),
|
|
19090
19353
|
},
|
|
19091
19354
|
outputSchema: { ok: z.boolean().optional(), provider: z.string().optional(), connected: z.boolean().optional(), label: z.string().nullable().optional(), status: z.string().nullable().optional(), warning: z.string().optional(), step: z.string().optional(), publicKey: z.string().optional(), setupToken: z.string().optional(), expiresInHours: z.number().optional() },
|
|
19092
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19355
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19093
19356
|
}, wrap(async (a) => {
|
|
19094
19357
|
const refuse = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
19095
19358
|
const provider = String(a.provider || '').trim().toLowerCase();
|
|
@@ -19185,7 +19448,7 @@ function memoryNoteVerdict(text) {
|
|
|
19185
19448
|
confirm: z.boolean().optional().describe('REQUIRED true — this invites a real person'),
|
|
19186
19449
|
},
|
|
19187
19450
|
outputSchema: { ok: z.boolean().optional(), invited: z.boolean().optional(), link: z.string().optional() },
|
|
19188
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19451
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
19189
19452
|
}, wrap(async (a) => {
|
|
19190
19453
|
if (a.confirm !== true) return ok(`This will invite ${a.email || '(no email)'} as ${a.role || 'member'} to the workspace. Confirm with the user, then call again with confirm:true.`, { ok: false });
|
|
19191
19454
|
const d = await apiPost('/api/team/invite', { email: a.email, role: a.role });
|
|
@@ -19233,7 +19496,7 @@ function memoryNoteVerdict(text) {
|
|
|
19233
19496
|
confirm: z.boolean().optional().describe('REQUIRED true'),
|
|
19234
19497
|
},
|
|
19235
19498
|
outputSchema: { ok: z.boolean().optional(), email: z.string().optional(), role: z.string().optional() },
|
|
19236
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19499
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19237
19500
|
}, wrap(async (a) => {
|
|
19238
19501
|
if (a.confirm !== true) return ok(`This will set ${a.email || '(no email)'} to ${a.role}. Confirm with the user, then call again with confirm:true.`, { ok: false });
|
|
19239
19502
|
// Naming somebody who is not a member 404s server-side and surfaces as an error here. On success the role we
|
|
@@ -19314,7 +19577,7 @@ function memoryNoteVerdict(text) {
|
|
|
19314
19577
|
candidates: z.array(z.any()).optional().describe('discovered brands ({name, domain, kind, reason})'),
|
|
19315
19578
|
diagnostics: z.any().optional().describe('discovery diagnostics (model usage, web grounding)'),
|
|
19316
19579
|
},
|
|
19317
|
-
annotations: { readOnlyHint:
|
|
19580
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19318
19581
|
}, wrap(async ({ domain, mode = 'competitors' }) => {
|
|
19319
19582
|
const d = await apiPost('/api/inspire/competitors', { domain, mode });
|
|
19320
19583
|
const list = (d.candidates || []).map(c => `${c.name} (${c.domain || '—'}, ${c.kind})`).join('; ');
|
|
@@ -19345,7 +19608,7 @@ function memoryNoteVerdict(text) {
|
|
|
19345
19608
|
google: z.any().optional().describe('Google results ({ads[], cursor} or {error}; null when not requested)'),
|
|
19346
19609
|
linkedin: z.any().optional().describe('LinkedIn results ({ads[], cursor} or {error}; null when not requested)'),
|
|
19347
19610
|
},
|
|
19348
|
-
annotations: { readOnlyHint:
|
|
19611
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19349
19612
|
}, wrap(async (a) => {
|
|
19350
19613
|
// META ONLY, AND NOT BY REQUEST — BY CONSTRUCTION. `...a` used to spread the caller's own `platforms` OVER the
|
|
19351
19614
|
// default, so the "default ['facebook']" was only ever a default for a caller who said nothing. Models did not:
|
|
@@ -19458,7 +19721,7 @@ function memoryNoteVerdict(text) {
|
|
|
19458
19721
|
watching: z.array(z.string()).optional().describe('the brand names now under watch (empty = the watch is off)'),
|
|
19459
19722
|
nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
|
|
19460
19723
|
},
|
|
19461
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19724
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19462
19725
|
}, wrap(async (a) => {
|
|
19463
19726
|
// An OMITTED list is refused; an EMPTY one is the documented way to stop. Defaulting the omission to [] would
|
|
19464
19727
|
// turn "I forgot the argument" into "delete this workspace's whole watch", silently.
|
|
@@ -19555,7 +19818,7 @@ function memoryNoteVerdict(text) {
|
|
|
19555
19818
|
results: z.array(z.any()).optional().describe('the found ads/videos (normalized card objects with served URLs)'),
|
|
19556
19819
|
actions: z.any().optional().describe('follow-up actions the research loop suggested'),
|
|
19557
19820
|
},
|
|
19558
|
-
annotations: { readOnlyHint:
|
|
19821
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19559
19822
|
}, wrap(async ({ query, brand }) => {
|
|
19560
19823
|
const brandObj = typeof brand === 'string' ? { name: brand } : brand || null;
|
|
19561
19824
|
const d = await apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: query }], brand: brandObj });
|
|
@@ -19646,7 +19909,7 @@ function memoryNoteVerdict(text) {
|
|
|
19646
19909
|
found: z.number().optional().describe('total ads found upstream'),
|
|
19647
19910
|
ads: z.array(z.any()).optional().describe('the compact ad objects ({page_name, body, cta, link, dates, media})'),
|
|
19648
19911
|
},
|
|
19649
|
-
annotations: { readOnlyHint:
|
|
19912
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19650
19913
|
}, wrap(async (a) => {
|
|
19651
19914
|
if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
|
|
19652
19915
|
const common = qp({ country: a.country, status: a.status, media_type: a.mediaType });
|
|
@@ -19699,7 +19962,7 @@ function memoryNoteVerdict(text) {
|
|
|
19699
19962
|
found: z.number().optional().describe('total ads found upstream'),
|
|
19700
19963
|
ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, format, adUrl, image, firstShown, lastShown})'),
|
|
19701
19964
|
},
|
|
19702
|
-
annotations: { readOnlyHint:
|
|
19965
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19703
19966
|
}, wrap(async (a) => {
|
|
19704
19967
|
if (!a.domain && !a.advertiserId) throw Object.assign(new Error('Pass domain or advertiserId.'), { status: 400 }); // an authored refusal says so, or the error ledger files it as "could not tell"
|
|
19705
19968
|
const d = await apiGet('/api/google/company-ads', qp({ domain: a.domain, advertiser_id: a.advertiserId, region: a.region, get_ad_details: 'false' }));
|
|
@@ -19723,7 +19986,7 @@ function memoryNoteVerdict(text) {
|
|
|
19723
19986
|
found: z.number().optional().describe('total ads found upstream'),
|
|
19724
19987
|
ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, headline, description, cta, link, media, dates, impressions})'),
|
|
19725
19988
|
},
|
|
19726
|
-
annotations: { readOnlyHint:
|
|
19989
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19727
19990
|
}, wrap(async (a) => {
|
|
19728
19991
|
if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
|
|
19729
19992
|
const d = await apiGet('/api/linkedin/search', qp({ company: a.company, keyword: a.keyword, companyId: a.companyId, countries: a.countries }));
|
|
@@ -19758,7 +20021,7 @@ function memoryNoteVerdict(text) {
|
|
|
19758
20021
|
enrich: z.boolean().optional().describe('read follower counts for the top 6 (default true, ~1 credit each)'),
|
|
19759
20022
|
marketplace: z.boolean().optional().describe('also search Instagram’s creator marketplace (Meta’s own creator directory, free) for the same niche; needs the Meta connector'),
|
|
19760
20023
|
},
|
|
19761
|
-
annotations: { readOnlyHint:
|
|
20024
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19762
20025
|
}, wrap(async (a) => {
|
|
19763
20026
|
const d = await apiPost('/api/creators/search', a);
|
|
19764
20027
|
const fmt = (v) => v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(v >= 1e5 ? 0 : 1)}K` : String(v);
|
|
@@ -19780,7 +20043,7 @@ function memoryNoteVerdict(text) {
|
|
|
19780
20043
|
limit: z.number().optional().describe('posts per platform, 1–60 (default 24)'),
|
|
19781
20044
|
queries: z.number().optional().describe('query variants per platform, 1–4 (default 1); each is a paid search call'),
|
|
19782
20045
|
},
|
|
19783
|
-
annotations: { readOnlyHint:
|
|
20046
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19784
20047
|
}, wrap(async (a) => {
|
|
19785
20048
|
const d = await apiPost('/api/posts/search', a);
|
|
19786
20049
|
if (!d.shown) return ok(`${d.summary} Nothing matched — try broader words or add platforms.`, d);
|
|
@@ -19801,7 +20064,7 @@ function memoryNoteVerdict(text) {
|
|
|
19801
20064
|
found: z.number().optional().describe('total videos found'),
|
|
19802
20065
|
videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
|
|
19803
20066
|
},
|
|
19804
|
-
annotations: { readOnlyHint:
|
|
20067
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19805
20068
|
}, wrap(async ({ query, limit }) => {
|
|
19806
20069
|
const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
|
|
19807
20070
|
const all = (d.search_item_list || []).map((x) => x.aweme_info).filter(Boolean).map((v) => {
|
|
@@ -19827,7 +20090,7 @@ function memoryNoteVerdict(text) {
|
|
|
19827
20090
|
found: z.number().optional().describe('total reels found'),
|
|
19828
20091
|
reels: z.array(z.any()).optional().describe('the compact reel objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
|
|
19829
20092
|
},
|
|
19830
|
-
annotations: { readOnlyHint:
|
|
20093
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19831
20094
|
}, wrap(async ({ query, limit }) => {
|
|
19832
20095
|
let d;
|
|
19833
20096
|
try { d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query }); }
|
|
@@ -19857,7 +20120,7 @@ function memoryNoteVerdict(text) {
|
|
|
19857
20120
|
found: z.number().optional().describe('total videos found'),
|
|
19858
20121
|
videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, link, cover}), ranked by views'),
|
|
19859
20122
|
},
|
|
19860
|
-
annotations: { readOnlyHint:
|
|
20123
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19861
20124
|
}, wrap(async ({ query, limit }) => {
|
|
19862
20125
|
const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
|
|
19863
20126
|
const all = (d.videos || []).filter((v) => (v.type || 'video') === 'video').map((v) => {
|
|
@@ -19878,7 +20141,7 @@ function memoryNoteVerdict(text) {
|
|
|
19878
20141
|
found: z.number().optional().describe('total posts found'),
|
|
19879
20142
|
posts: z.array(z.any()).optional().describe('the compact post objects ({desc, subreddit, upvotes, comments, link})'),
|
|
19880
20143
|
},
|
|
19881
|
-
annotations: { readOnlyHint:
|
|
20144
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19882
20145
|
}, wrap(async ({ query, limit }) => {
|
|
19883
20146
|
// `sort: 'relevance'` IS THE QUERY (measured live 2026-09-03): the ad-data provider's Reddit search honours the
|
|
19884
20147
|
// query ONLY under relevance — `top` and `new` return the site-wide top/new feeds and ignore it entirely, so this
|
|
@@ -19905,7 +20168,7 @@ function memoryNoteVerdict(text) {
|
|
|
19905
20168
|
found: z.number().optional().describe('total posts found'),
|
|
19906
20169
|
posts: z.array(z.any()).optional().describe('the compact post objects ({desc, author, handle, likes, link, cover})'),
|
|
19907
20170
|
},
|
|
19908
|
-
annotations: { readOnlyHint:
|
|
20171
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19909
20172
|
}, wrap(async ({ query, limit }) => {
|
|
19910
20173
|
const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
|
|
19911
20174
|
const all = (d.posts || d.results || []).map((p) => {
|
|
@@ -19928,7 +20191,7 @@ function memoryNoteVerdict(text) {
|
|
|
19928
20191
|
params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
|
|
19929
20192
|
},
|
|
19930
20193
|
outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
|
|
19931
|
-
annotations: { readOnlyHint:
|
|
20194
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19932
20195
|
}, wrap(async ({ path, params }) => {
|
|
19933
20196
|
const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
|
|
19934
20197
|
const raw = JSON.stringify(d);
|
|
@@ -19945,7 +20208,7 @@ function memoryNoteVerdict(text) {
|
|
|
19945
20208
|
server.registerTool('get_brand', {
|
|
19946
20209
|
title: 'Get saved brand',
|
|
19947
20210
|
description: 'What Hermoso ALREADY KNOWS for this account/workspace — the same saved brand profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call it when you need to know whether a brand is on file: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. Not a required first step before a render: the create tools read the saved brand by themselves. 0 credits.',
|
|
19948
|
-
inputSchema: {},
|
|
20211
|
+
inputSchema: { ...STORE_BRAND },
|
|
19949
20212
|
outputSchema: {
|
|
19950
20213
|
hasBrand: z.boolean().optional().describe('whether a brand is saved for this workspace'),
|
|
19951
20214
|
brand: z.any().optional().describe('the saved brand profile (name, domain, category, products, palette, …) or null'),
|
|
@@ -19954,7 +20217,7 @@ function memoryNoteVerdict(text) {
|
|
|
19954
20217
|
personaNote: z.string().optional().describe('how to work for that kind of user (a creator wants organic content for their own feed, not ads by default; an explorer came to play with the models and needs no brand). Empty when persona is null.'),
|
|
19955
20218
|
},
|
|
19956
20219
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
19957
|
-
}, wrap(async () => {
|
|
20220
|
+
}, wrap(inBrand(async () => {
|
|
19958
20221
|
const d = await apiGet('/api/brand/current');
|
|
19959
20222
|
// WHO IS ASKING (2026-09-22): a creator's saved "brand" is their personal brand and an explorer has none on purpose,
|
|
19960
20223
|
// so the text says so before it says what the create tools do with the profile — the same note the web Studio reads.
|
|
@@ -19965,7 +20228,7 @@ function memoryNoteVerdict(text) {
|
|
|
19965
20228
|
? `No saved brand for this workspace, and that is the user's choice: they picked "Just exploring". Make what they ask with no brand at all; a brand is optional (draft_brand, or the web Studio) and only worth mentioning if THEY ask for something about their own business.${who}`
|
|
19966
20229
|
: `No saved brand for this workspace yet — onboard one with draft_brand (it saves automatically), or the user can onboard in the web Studio. Not a precondition: every tool works from what the user tells you.${who}`);
|
|
19967
20230
|
return ok(text, d);
|
|
19968
|
-
}));
|
|
20231
|
+
})));
|
|
19969
20232
|
|
|
19970
20233
|
server.registerTool('draft_brand', {
|
|
19971
20234
|
title: 'Draft brand profile',
|
|
@@ -20104,7 +20367,7 @@ function memoryNoteVerdict(text) {
|
|
|
20104
20367
|
transcript: z.string().nullable().optional().describe('verbatim voiceover + on-screen text with a beat list (null when silent/unreachable)'),
|
|
20105
20368
|
words: z.array(z.any()).optional(), anchors: z.array(z.any()).optional(), cues: z.array(z.any()).optional(),
|
|
20106
20369
|
},
|
|
20107
|
-
annotations: { readOnlyHint:
|
|
20370
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
20108
20371
|
}, wrap(async ({ url, frames, words, anchors }) => {
|
|
20109
20372
|
const wantWords = words === true || words === 'only' || anchors != null;
|
|
20110
20373
|
const wr = wantWords ? apiPost('/api/video/words', { url, ...(anchors ? { anchors } : {}) }).then(r => ({ r }), e => ({ e })) : null;
|
|
@@ -20173,7 +20436,7 @@ function memoryNoteVerdict(text) {
|
|
|
20173
20436
|
top_fix: z.string().optional().describe('the single biggest improvement lever'),
|
|
20174
20437
|
strengths: z.any().optional().describe('what the ad already does well'),
|
|
20175
20438
|
},
|
|
20176
|
-
annotations: { readOnlyHint:
|
|
20439
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
20177
20440
|
}, wrap(async ({ url, kind = 'image', intent = '' }) => {
|
|
20178
20441
|
const d = await apiPost('/api/score/ad', { url, kind, intent, format: kind });
|
|
20179
20442
|
if (!d) return ok('Could not score that ad.');
|
|
@@ -20208,6 +20471,25 @@ function memoryNoteVerdict(text) {
|
|
|
20208
20471
|
// neither can answer "make it nighttime" or "change the background to a city street". That is video-to-video, it has
|
|
20209
20472
|
// been a job worker (`videoedit`) all along, and only `editVideoClip` in public/app.js ever submitted one — so a
|
|
20210
20473
|
// headless caller's only route to a restyled clip was paying for a whole new render.
|
|
20474
|
+
// ONE-TAP LOOKS (2026-10-02): the web menu's Restyle grid and the Studio's restyle_video, headless. A preset id (or a
|
|
20475
|
+
// described look) runs as a video edit: same engines, same price, same free preservation read-back as edit_video.
|
|
20476
|
+
server.registerTool('restyle_video', {
|
|
20477
|
+
title: 'Restyle a video',
|
|
20478
|
+
description: "RESTYLE an existing clip into a different LOOK in one step, keeping its shots, motion, timing and sound: claymation, anime, 80s VHS, film noir, an ancient fresco, a 1920s gangster film, 16-bit pixel art and more (the current list is `Restyle looks` in hermoso_capabilities), or describe your own look. A paid video edit, priced like edit_video (dryRun on the job quotes it). For changing ONE thing in the clip use edit_video.",
|
|
20479
|
+
inputSchema: {
|
|
20480
|
+
video: z.string().describe('the source video URL (a render, job result or list_library)'),
|
|
20481
|
+
style: z.string().optional().describe('a look id from hermoso_capabilities (e.g. claymation, vhs-80s, film-noir)'),
|
|
20482
|
+
describe: z.string().optional().describe('a look in the user’s own words, when no preset fits, or extra detail on top of a preset'),
|
|
20483
|
+
},
|
|
20484
|
+
outputSchema: { ...JOB_OUT },
|
|
20485
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20486
|
+
}, wrap(async ({ video, style, describe }) => {
|
|
20487
|
+
const note = String(describe || '').trim();
|
|
20488
|
+
if (!String(style || '').trim() && !note) return { content: [{ type: 'text', text: 'Name a look (style, from hermoso_capabilities) or describe one.' }], isError: true };
|
|
20489
|
+
const r = await renderJob('videoedit', { video, prompt: `Restyle as ${style || note}`, grammar: true, restyle: String(style || '').trim() || 'custom', ...(note ? { restyleNote: note } : {}) }, `Restyle · ${style || 'custom look'}`);
|
|
20490
|
+
return okVideo(`Restyled video: ${r.url}`, r);
|
|
20491
|
+
}));
|
|
20492
|
+
|
|
20211
20493
|
server.registerTool('edit_video', {
|
|
20212
20494
|
title: 'Edit a video clip',
|
|
20213
20495
|
description: "EDIT an existing clip from a plain instruction (video-to-video): the motion, timing, framing and cut stay, the named thing changes. 'change only the mug to red', 'make it nighttime', 'restyle it as claymation'. Your words are wrapped so the model keeps everything else identical, changes only what you named and repeats that lock; lighting is kept unless the change needs new light (set lighting to force either); literal:true sends your words as written. One change per call holds best. previewFirstFrame:true edits ONE still first (one image edit; previewAt picks the second) and quotes the clip; nothing else runs until you call again, ideally with previewStill. The reply scores how well the shot held outside the change (free) and flags an edit that touched more than asked. NOT for cuts/trims/end cards (post_edit), a new video (generate_video / render_ad), translation (dub_video) or a saved creator's face (recast_motion). Best on 3-15s clips.",
|
|
@@ -20475,7 +20757,7 @@ function memoryNoteVerdict(text) {
|
|
|
20475
20757
|
teardown: z.any().optional().describe('the playbook — hook_taxonomy, campaigns, white_space, counter_plays, not_saying'),
|
|
20476
20758
|
adCount: z.number().optional().describe('how many ads were analyzed'),
|
|
20477
20759
|
},
|
|
20478
|
-
annotations: { readOnlyHint:
|
|
20760
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20479
20761
|
}, wrap(async ({ competitor, ads, language }) => {
|
|
20480
20762
|
const name = String(competitor?.name || '').trim();
|
|
20481
20763
|
if (!name) throw new Error('competitor.name is required.');
|
|
@@ -20511,7 +20793,7 @@ function memoryNoteVerdict(text) {
|
|
|
20511
20793
|
findings: z.array(z.any()).optional().describe('flagged issues ({severity, issue, policy_quote, fix_suggestion, where_in_ad})'),
|
|
20512
20794
|
anchors: z.array(z.any()).optional().describe('the Meta policy pages consulted ({url, …})'),
|
|
20513
20795
|
},
|
|
20514
|
-
annotations: { readOnlyHint:
|
|
20796
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
20515
20797
|
}, wrap(async ({ copy, claims, category, imageDescription }) => {
|
|
20516
20798
|
const d = await apiPost('/api/policy/check', { copy, claims: claims || '', category: category || '', imageDescription: imageDescription || '' });
|
|
20517
20799
|
const findings = (d.findings || []).map((f, i) => `${i + 1}. [${f.severity || 'issue'}] ${f.where_in_ad ? `"${f.where_in_ad}" — ` : ''}${f.issue || ''}\n Meta: “${f.policy_quote || ''}”${f.fix_suggestion ? `\n Fix: ${f.fix_suggestion}` : ''}`).join('\n');
|
|
@@ -20585,7 +20867,7 @@ function memoryNoteVerdict(text) {
|
|
|
20585
20867
|
},
|
|
20586
20868
|
// NOT read-only (2026-09-30, OpenAI's plugin scan): passing brandId switches this key's ACTIVE BRAND for every later
|
|
20587
20869
|
// call, exactly like use_brand (activeBrand pins it server-side). That is a persisted, user-visible state change.
|
|
20588
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint:
|
|
20870
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20589
20871
|
}, wrap(async ({ brandId, reviews, reviewsUrl, useOwnReviewsOnly }) => {
|
|
20590
20872
|
const brand = await activeBrand(brandId);
|
|
20591
20873
|
const own = {};
|
|
@@ -20647,7 +20929,7 @@ function memoryNoteVerdict(text) {
|
|
|
20647
20929
|
unconfirmed: z.string().nullable().optional().describe('set when the save landed but the confirming read failed — neither saved nor failed; verify with list_product_photos'),
|
|
20648
20930
|
saveError: z.string().nullable().optional().describe('set when the photo passed the check but could NOT be saved to the brand'),
|
|
20649
20931
|
},
|
|
20650
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
20932
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20651
20933
|
}, wrap(async ({ imageUrl, source_note, brandId }) => {
|
|
20652
20934
|
const brand = await activeBrand(brandId);
|
|
20653
20935
|
const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
|