hermoso 0.1.365 → 0.1.371
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/tools.mjs +291 -143
- 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';
|
|
@@ -365,7 +365,7 @@ export const MCP_INSTRUCTIONS_WIDGET = [
|
|
|
365
365
|
'• CREATE: get_brand / draft_brand (the saved brand), plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants of a finished ad with edit_image, headline_variants, hook_variants, multiply_ad. Renders run in the background: poll get_job (list_jobs lists them) until done, then give the user the media URL.',
|
|
366
366
|
"• FILES & POSTING: upload_file turns a file into a URL; schedule_post queues a post to the user's own connected accounts; list_scheduled shows the queue; list_connectors shows what is connected (the user connects accounts in the Hermoso app under Settings > Connectors); list_library lists past creations.",
|
|
367
367
|
'• RESULTS: post_performance, analyze_campaigns.',
|
|
368
|
-
'• WORKSPACE: list_brands, use_brand, create_brand,
|
|
368
|
+
'• WORKSPACE: list_brands, use_brand, create_brand, hermoso_credits, report_bug, request_feature.',
|
|
369
369
|
"CREDITS: tools that run an AI model or ad research use credits from the user's Hermoso account, and the reply says how many. If the balance is too low, say so plainly; the user manages their account at hermoso.ai. Never start a purchase.",
|
|
370
370
|
'CONFIRM with the user before scheduling a post, deleting anything, or replacing a saved brand profile (draft_brand with save:true).',
|
|
371
371
|
].join('\n');
|
|
@@ -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 }] }));
|
|
@@ -2181,7 +2222,10 @@ export function parseToolScope(raw) {
|
|
|
2181
2222
|
// plan's price, the saved card and auto-reload, and its own description points at upgrade_plan / set_auto_reload.
|
|
2182
2223
|
// The plugin guidelines say a plugin "must not display subscription plans … or promote upgrades"; hermoso_credits
|
|
2183
2224
|
// still answers "what is my balance" on ChatGPT, and the plan is managed in the Hermoso app.
|
|
2184
|
-
|
|
2225
|
+
// `delete_brand` JOINED THEM 2026-09-30 (owner's call, for the 1.0.0 review): destroying a whole workspace is a heavy,
|
|
2226
|
+
// irreversible action a reviewer has to reason about, and it is always one click away in the Hermoso app. Every other
|
|
2227
|
+
// surface keeps it.
|
|
2228
|
+
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'delete_brand']);
|
|
2185
2229
|
// ── WHAT A WIDGET HOST (CHATGPT) LISTS, AFTER OPENAI'S AUTOMATED PLUGIN SCAN (2026-09-30) ──────────────────────────
|
|
2186
2230
|
// Two rules from developers.openai.com/plugins/plugin-guidelines.md, both HOST rules: every other surface keeps today's
|
|
2187
2231
|
// roster exactly.
|
|
@@ -2260,6 +2304,34 @@ export const withStaleNote = (out, ctx) => {
|
|
|
2260
2304
|
if (last) last.text = `${last.text}\n\n${STALE_ROSTER_NOTE}`; else out.content.push({ type: 'text', text: STALE_ROSTER_NOTE });
|
|
2261
2305
|
return out;
|
|
2262
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
|
+
};
|
|
2263
2335
|
export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
2264
2336
|
const spec = LEGACY_TOOL_NAMES[name];
|
|
2265
2337
|
reportDeadEnd('stale_roster', name, `${name} no longer exists — the host's tool list is an older published snapshot`);
|
|
@@ -2273,6 +2345,7 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
|
2273
2345
|
const fn = h && (h.handler || h.callback);
|
|
2274
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);
|
|
2275
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); }
|
|
2276
2349
|
if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
|
|
2277
2350
|
const parsed = h.inputSchema.safeParse(input);
|
|
2278
2351
|
if (!parsed.success) {
|
|
@@ -2387,6 +2460,17 @@ const SEND_OUTPUT_SCHEMA = false;
|
|
|
2387
2460
|
|
|
2388
2461
|
// Identity-preserving when there is nothing to strip, which is what keeps the cached canon a single shared
|
|
2389
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
|
+
|
|
2390
2474
|
export function stripOutputSchema(def) {
|
|
2391
2475
|
if (SEND_OUTPUT_SCHEMA || !def || !def.outputSchema) return def;
|
|
2392
2476
|
const { outputSchema: _dropSchema, ...rest } = def;
|
|
@@ -2394,6 +2478,7 @@ export function stripOutputSchema(def) {
|
|
|
2394
2478
|
}
|
|
2395
2479
|
|
|
2396
2480
|
export function defForHost(name, def, widgetHost) {
|
|
2481
|
+
explicitHints(def);
|
|
2397
2482
|
const out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def)) : stripOutputSchema(def);
|
|
2398
2483
|
if (!widgetHost || !out || !WIDGET_WITHHELD_TOOLS.has(name)) return out;
|
|
2399
2484
|
const meta = out._meta;
|
|
@@ -2635,6 +2720,7 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2635
2720
|
const fn = h.handler || h.callback;
|
|
2636
2721
|
if (typeof fn === 'function') {
|
|
2637
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 }; }
|
|
2638
2724
|
if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
|
|
2639
2725
|
const parsed = h.inputSchema.safeParse(input);
|
|
2640
2726
|
if (!parsed.success) {
|
|
@@ -3367,7 +3453,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3367
3453
|
.map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
3368
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 });
|
|
3369
3455
|
}
|
|
3370
|
-
|
|
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 });
|
|
3371
3459
|
for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
|
|
3372
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)'}`);
|
|
3373
3461
|
const looser = top.length > total ? top.length - total : 0;
|
|
@@ -3449,6 +3537,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3449
3537
|
// only by a host still holding call_tool from an older scan; a tool this session does not list is refused by name.
|
|
3450
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 };
|
|
3451
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
|
+
}
|
|
3452
3545
|
if (h.inputSchema) {
|
|
3453
3546
|
const parsed = h.inputSchema.safeParse(input);
|
|
3454
3547
|
if (!parsed.success) {
|
|
@@ -3513,7 +3606,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3513
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.'),
|
|
3514
3607
|
},
|
|
3515
3608
|
outputSchema: { url: z.string().optional(), uri: z.string().optional(), handle: z.string().optional(), note: z.string() },
|
|
3516
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
3609
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
3517
3610
|
}, wrap(async (a) => {
|
|
3518
3611
|
const r = await apiPost('/api/bluesky/post', a);
|
|
3519
3612
|
return ok(r.note || `Posted to Bluesky — ${r.url || ''}`, r);
|
|
@@ -3575,7 +3668,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3575
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.'),
|
|
3576
3669
|
},
|
|
3577
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() },
|
|
3578
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
3671
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
3579
3672
|
}, wrap(async (a) => {
|
|
3580
3673
|
const r = await apiPost('/api/telegram/post', a);
|
|
3581
3674
|
return ok(r.note || `Posted to Telegram${r.url ? ` — ${r.url}` : ''}`, r);
|
|
@@ -4085,6 +4178,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4085
4178
|
`• ${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')
|
|
4086
4179
|
+ `\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.)`}`
|
|
4087
4180
|
: '';
|
|
4181
|
+
// ZERO BRANDS IS A REAL STATE (2026-10-01): the owner may delete their last brand. Say so, and say how to make one,
|
|
4182
|
+
// rather than printing an empty list under a heading that implies there should be something in it.
|
|
4183
|
+
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 });
|
|
4088
4184
|
return ok(`Brands on this account:\n${lines}\n\nSwitch with use_brand.${sharedTxt}`, { ...d, sharedWorkspaces: shared });
|
|
4089
4185
|
}));
|
|
4090
4186
|
|
|
@@ -4136,6 +4232,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4136
4232
|
}
|
|
4137
4233
|
const own = (d.brands || []).map(b => `• ${b.name} (id: ${b.id})`).join('\n');
|
|
4138
4234
|
const shr = shared.map(w => `• ${w.name || 'Shared workspace'}${w.ownerName ? ` — ${w.ownerName}` : ''} (id: ${w.profileUuid}, shared with you)`).join('\n');
|
|
4235
|
+
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)
|
|
4139
4236
|
return { content: [{ type: 'text', text: `No brand matching "${brand}". Available:\n${own}${shr ? `\n${shr}` : ''}` }], isError: true };
|
|
4140
4237
|
});
|
|
4141
4238
|
server.registerTool('use_brand', {
|
|
@@ -4224,7 +4321,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4224
4321
|
if (a.confirmConnectors != null) q.set('confirmConnectors', String(a.confirmConnectors));
|
|
4225
4322
|
const d = await apiDelete(`/api/brands/${encodeURIComponent(hit.id)}?${q}`);
|
|
4226
4323
|
const dis = d.connectorsDisconnected || [];
|
|
4227
|
-
|
|
4324
|
+
const nq = +d.scheduledPostsCancelled || 0; // its queued posts go with it (2026-10-01)
|
|
4325
|
+
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);
|
|
4228
4326
|
}));
|
|
4229
4327
|
|
|
4230
4328
|
|
|
@@ -5013,7 +5111,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5013
5111
|
server.registerTool('post_to_meta', {
|
|
5014
5112
|
title: 'Post to Facebook, Instagram or Threads',
|
|
5015
5113
|
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.',
|
|
5016
|
-
|
|
5114
|
+
// LOOSE ON PURPOSE (2026-09-30): an argument this schema does not declare is FORWARDED, not stripped, so a caption
|
|
5115
|
+
// sent as `caption` or a picture as `image_url` reaches the server and is refused BY NAME with the right field
|
|
5116
|
+
// (metaPostAliasRefusal). Stripped, it vanished and the call read as empty: five identical refusals in 35 minutes.
|
|
5117
|
+
inputSchema: z.object({
|
|
5017
5118
|
...HOOK_ATTR,
|
|
5018
5119
|
message: z.string().optional().describe('post text / caption'),
|
|
5019
5120
|
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)'),
|
|
@@ -5073,9 +5174,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5073
5174
|
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.'),
|
|
5074
5175
|
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.'),
|
|
5075
5176
|
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.'),
|
|
5076
|
-
},
|
|
5177
|
+
}).loose(),
|
|
5077
5178
|
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() },
|
|
5078
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5179
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5079
5180
|
}, publishWrap(async (a) => {
|
|
5080
5181
|
const d = await apiPost('/api/meta/post', a);
|
|
5081
5182
|
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);
|
|
@@ -5201,7 +5302,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5201
5302
|
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.'),
|
|
5202
5303
|
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.'),
|
|
5203
5304
|
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.'),
|
|
5204
|
-
aiGenerated: z.boolean().optional().describe('
|
|
5305
|
+
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.'),
|
|
5205
5306
|
// ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
|
|
5206
5307
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
5207
5308
|
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.'),
|
|
@@ -5215,7 +5316,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5215
5316
|
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.'),
|
|
5216
5317
|
},
|
|
5217
5318
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), label: z.string().optional() },
|
|
5218
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5319
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5219
5320
|
}, wrap(async (a) => {
|
|
5220
5321
|
const d = await apiPost('/api/schedule', a);
|
|
5221
5322
|
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);
|
|
@@ -5234,7 +5335,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5234
5335
|
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(),
|
|
5235
5336
|
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(),
|
|
5236
5337
|
},
|
|
5237
|
-
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5338
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5238
5339
|
}, wrap(async ({ id, channel, upcoming, fired, brand } = {}) => {
|
|
5239
5340
|
const d = await apiGet('/api/schedule', brand ? { brandId: brand } : {});
|
|
5240
5341
|
// ONE POST IN FULL (2026-09-13). The compact list below shortens captions, and reschedule_post's `captions`
|
|
@@ -5369,7 +5470,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5369
5470
|
linkPicture: z.string().optional().describe('FACEBOOK — replaces the link preview image url; an empty string removes the override.'),
|
|
5370
5471
|
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.'),
|
|
5371
5472
|
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.'),
|
|
5372
|
-
aiGenerated: z.boolean().optional().describe('
|
|
5473
|
+
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.'),
|
|
5373
5474
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
5374
5475
|
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.'),
|
|
5375
5476
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
|
|
@@ -5380,7 +5481,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5380
5481
|
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.'),
|
|
5381
5482
|
},
|
|
5382
5483
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), visibility: z.string().optional() },
|
|
5383
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5484
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5384
5485
|
}, wrap(async (a) => {
|
|
5385
5486
|
const { id, ...patch } = a;
|
|
5386
5487
|
const d = await apiPatch(`/api/schedule/${encodeURIComponent(id)}`, patch);
|
|
@@ -5423,7 +5524,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5423
5524
|
captions: z.record(z.string()).optional().describe('CORRECT ONE CHANNEL’S CAPTION on retry, e.g. { "x": "..." } when only that channel refused the text.'),
|
|
5424
5525
|
},
|
|
5425
5526
|
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() },
|
|
5426
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5527
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5427
5528
|
}, wrap(async (a) => {
|
|
5428
5529
|
const { id, ...rest } = a;
|
|
5429
5530
|
const d = await apiPost(`/api/schedule/${encodeURIComponent(id)}/retry`, rest);
|
|
@@ -5453,7 +5554,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5453
5554
|
visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional(),
|
|
5454
5555
|
},
|
|
5455
5556
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), duplicateOf: z.string().optional(), note: z.string().optional() },
|
|
5456
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5557
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5457
5558
|
}, wrap(async (a) => {
|
|
5458
5559
|
const { id, ...rest } = a;
|
|
5459
5560
|
const d = await apiPost(`/api/schedule/${encodeURIComponent(id)}/duplicate`, rest);
|
|
@@ -5498,7 +5599,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5498
5599
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page.'),
|
|
5499
5600
|
},
|
|
5500
5601
|
outputSchema: { enabled: z.boolean().optional(), dryRun: z.boolean().optional(), running: z.boolean().optional(), daysAhead: z.number().optional() },
|
|
5501
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5602
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5502
5603
|
}, wrap(async (a) => {
|
|
5503
5604
|
const d = await apiPut('/api/schedule/refill', a);
|
|
5504
5605
|
return ok(d.enabled
|
|
@@ -5517,7 +5618,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5517
5618
|
posts: z.array(z.any()).optional(), queued: z.array(z.any()).optional(), skippedSlots: z.array(z.any()).optional(),
|
|
5518
5619
|
notes: z.array(z.string()).optional(), spend: z.any().optional(), perChannel: z.any().optional(),
|
|
5519
5620
|
},
|
|
5520
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5621
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5521
5622
|
}, wrap(async (a) => {
|
|
5522
5623
|
const d = await apiPost('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force });
|
|
5523
5624
|
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)`);
|
|
@@ -5543,7 +5644,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5543
5644
|
visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
|
|
5544
5645
|
},
|
|
5545
5646
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional() },
|
|
5546
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5647
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5547
5648
|
}, publishWrap(async (a) => {
|
|
5548
5649
|
const d = await apiPost('/api/linkedin/post', a);
|
|
5549
5650
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was posted a second time.)`, d);
|
|
@@ -5579,7 +5680,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5579
5680
|
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.'),
|
|
5580
5681
|
},
|
|
5581
5682
|
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() },
|
|
5582
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5683
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5583
5684
|
}, wrap(async (a) => {
|
|
5584
5685
|
const d = await apiPost('/api/x/post', a);
|
|
5585
5686
|
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' : '';
|
|
@@ -5598,7 +5699,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5598
5699
|
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.'),
|
|
5599
5700
|
},
|
|
5600
5701
|
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() },
|
|
5601
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5702
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5602
5703
|
}, wrap(async (a) => {
|
|
5603
5704
|
const d = await apiPost('/api/x/article', a);
|
|
5604
5705
|
if (d.draft) return ok(`Saved an X Article DRAFT — “${d.title}”. Nothing is public yet. ${d.note || ''}`.trim(), d);
|
|
@@ -5872,7 +5973,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5872
5973
|
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.'),
|
|
5873
5974
|
},
|
|
5874
5975
|
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() },
|
|
5875
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
5976
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5876
5977
|
}, publishWrap(async (a) => {
|
|
5877
5978
|
const d = await apiPost('/api/pinterest/pin', a);
|
|
5878
5979
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was pinned a second time.)`, d);
|
|
@@ -6095,7 +6196,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6095
6196
|
languageCode: z.string().optional().describe("BCP-47 language of the Post, default 'en'"),
|
|
6096
6197
|
},
|
|
6097
6198
|
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() },
|
|
6098
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6199
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6099
6200
|
}, wrap(async (a) => {
|
|
6100
6201
|
const d = await apiPost('/api/google-business/post', a);
|
|
6101
6202
|
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);
|
|
@@ -6262,7 +6363,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6262
6363
|
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.'),
|
|
6263
6364
|
},
|
|
6264
6365
|
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() },
|
|
6265
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6366
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6266
6367
|
}, wrap(async (a) => {
|
|
6267
6368
|
const d = await apiPost('/api/youtube/upload', a);
|
|
6268
6369
|
// `d.privacy`, `d.categoryId` and `d.publishAt` are all what YOUTUBE returned on the row, not what we asked — an
|
|
@@ -7123,13 +7224,31 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7123
7224
|
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.'),
|
|
7124
7225
|
},
|
|
7125
7226
|
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() },
|
|
7126
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7227
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7127
7228
|
}, wrap(async (a) => {
|
|
7128
7229
|
const d = await apiPost('/api/tiktok/post', a);
|
|
7129
7230
|
const what = d.media === 'photo' ? (d.images > 1 ? `photo post (${d.images} slides)` : 'photo post') : 'video';
|
|
7130
7231
|
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);
|
|
7131
7232
|
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);
|
|
7132
7233
|
}));
|
|
7234
|
+
// A publish TikTok was still PROCESSING when post_to_tiktok stopped polling came back `pending:true` with a publishId
|
|
7235
|
+
// and the words "do not post it again", and nothing to follow it with: an agent asked call_tool for
|
|
7236
|
+
// `tiktok_post_status` on 2026-10-01 and dead-ended. Same read as the web app's /api/tiktok/publish-status.
|
|
7237
|
+
server.registerTool('tiktok_post_status', {
|
|
7238
|
+
title: 'Check whether a TikTok post finished publishing',
|
|
7239
|
+
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).',
|
|
7240
|
+
inputSchema: {
|
|
7241
|
+
publishId: z.string().describe('the publishId post_to_tiktok returned'),
|
|
7242
|
+
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'),
|
|
7243
|
+
},
|
|
7244
|
+
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() },
|
|
7245
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
7246
|
+
}, wrap(async (a) => {
|
|
7247
|
+
const d = await apiGet('/api/tiktok/publish-status', { publishId: a.publishId, ...(a.account ? { account: a.account } : {}) });
|
|
7248
|
+
if (d.done) return ok(`TikTok finished publishing it${d.postId ? ` (post id ${d.postId})` : ''}.`, d);
|
|
7249
|
+
if (d.failed) return ok(`TikTok did not publish it: ${d.failReason || 'no reason given'}.`, d);
|
|
7250
|
+
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);
|
|
7251
|
+
}));
|
|
7133
7252
|
server.group('channel_admin');
|
|
7134
7253
|
server.registerTool('tiktok_account', {
|
|
7135
7254
|
title: 'Read the connected TikTok account',
|
|
@@ -7725,7 +7844,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7725
7844
|
server.group('ads');
|
|
7726
7845
|
server.registerTool('analyze_campaigns', {
|
|
7727
7846
|
title: 'What to scale, pause, fix and test next',
|
|
7728
|
-
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).
|
|
7847
|
+
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.',
|
|
7729
7848
|
inputSchema: {
|
|
7730
7849
|
days: z.number().optional().describe('how many complete days back, ending yesterday (default 14, max 90)'),
|
|
7731
7850
|
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'),
|
|
@@ -7741,7 +7860,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7741
7860
|
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'),
|
|
7742
7861
|
text: z.string().optional(), note: z.string().optional(), notes: z.array(z.string()).optional(),
|
|
7743
7862
|
},
|
|
7744
|
-
annotations: { readOnlyHint:
|
|
7863
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
7745
7864
|
}, wrap(async (a) => {
|
|
7746
7865
|
const d = await apiPost('/api/ads/analyze', a);
|
|
7747
7866
|
if (!d.analysis) return ok(d.note || 'Nothing delivered in that window.', d);
|
|
@@ -8193,7 +8312,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8193
8312
|
alt: z.string().optional().describe('alt text for accessibility and SEO; defaults to a generic credit'),
|
|
8194
8313
|
},
|
|
8195
8314
|
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), productId: z.string().optional(), productUrl: z.string().optional(), media: z.any().optional() },
|
|
8196
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
8315
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
8197
8316
|
}, wrap(async (a) => {
|
|
8198
8317
|
const d = await apiPost('/api/shopify/publish-to-product', { productId: a.productId, imageUrl: a.imageUrl, alt: a.alt });
|
|
8199
8318
|
const st = d.media?.status || 'UNKNOWN';
|
|
@@ -11055,7 +11174,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11055
11174
|
});
|
|
11056
11175
|
server.registerTool('list_openai_ads_campaigns', {
|
|
11057
11176
|
title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
|
|
11058
|
-
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 (
|
|
11177
|
+
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.',
|
|
11059
11178
|
inputSchema: {
|
|
11060
11179
|
campaignId: z.string().optional().describe('list this campaign’s ad groups'),
|
|
11061
11180
|
adGroupId: z.string().optional().describe('list this ad group’s ads'),
|
|
@@ -11066,15 +11185,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11066
11185
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11067
11186
|
}, wrap(async (a) => {
|
|
11068
11187
|
const d = await apiGet('/api/openai-ads/campaigns', a);
|
|
11069
|
-
const blk = (x) =>
|
|
11188
|
+
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' : ''}`;
|
|
11070
11189
|
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);
|
|
11071
11190
|
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);
|
|
11072
11191
|
const acc = d.account;
|
|
11073
|
-
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);
|
|
11192
|
+
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);
|
|
11074
11193
|
}));
|
|
11075
11194
|
server.registerTool('openai_ads_report', {
|
|
11076
11195
|
title: 'ChatGPT Ads performance report',
|
|
11077
|
-
description: 'Performance for ChatGPT Ads — impressions, clicks, spend, CTR, CPC, CPM, and CONVERSIONS with CPA, post-click conversion rate and attributed order sales / ROAS, in the ad account currency. OpenAI returns conversions only with granularity none or daily and with no segment or a country/device segment (never platform or product), and CPA, conversion rate and sales only with no segment at all; the report adds every column OpenAI allows for the shape you asked and its note names any it left out, so a missing column is their limit, not a zero. These conversions are CLICK-THROUGH (the ones CPA and bidding use); view-through lives only in openai_ads_conversions and is never added to them. The scope follows the id you pass: none = the whole ad account, or campaignId / adGroupId / adId. PRODUCT-FEED CAMPAIGNS serving in the multi-product CAROUSEL unit also report per-card numbers: ask for them in `fields` — carousel_product_card_impressions, carousel_product_card_clicks, product_impressions, product_clicks, product_spend, product_ctr, product_cpc, product_cpm plus product_title / product_price / product_feed_id and the other product_* fields (complete from 2026-08-20 on a rolling 30-day basis); they come back under each row’s `fields`. A card impression counts when a product card becomes viewable and is NOT a billable impression, so never add it to spend math. granularity is hourly, daily, monthly or none (default daily); the default window is the last 30 days; segment by country or device for a breakdown, and level rolls the rows up by campaign / ad group / ad. A report with NO rows genuinely means there was NO delivery in that window — say exactly that; never present zeros as measured performance. Read-only and free, so run it FIRST after connecting: it proves the key works with zero spend risk.',
|
|
11196
|
+
description: 'Performance for ChatGPT Ads — impressions, clicks, spend, CTR, CPC, CPM, and CONVERSIONS with CPA, post-click conversion rate and attributed order sales / ROAS, in the ad account currency. OpenAI returns conversions only with granularity none or daily and with no segment or a country/device segment (never platform or product), and CPA, conversion rate and sales only with no segment at all; the report adds every column OpenAI allows for the shape you asked and its note names any it left out, so a missing column is their limit, not a zero. These conversions are CLICK-THROUGH (the ones CPA and bidding use); view-through lives only in openai_ads_conversions and is never added to them. The scope follows the id you pass: none = the whole ad account, or campaignId / adGroupId / adId. PRODUCT-FEED CAMPAIGNS serving in the multi-product CAROUSEL unit also report per-card numbers: ask for them in `fields` — carousel_product_card_impressions, carousel_product_card_clicks, product_impressions, product_clicks, product_spend, product_ctr, product_cpc, product_cpm plus product_title / product_price / product_feed_id and the other product_* fields (complete from 2026-08-20 on a rolling 30-day basis); they come back under each row’s `fields`. A card impression counts when a product card becomes viewable and is NOT a billable impression, so never add it to spend math. granularity is hourly, daily, monthly or none (default daily); the default window is the last 30 days; segment by country or device for a breakdown, and level rolls the rows up by campaign / ad group / ad. A report with NO rows genuinely means there was NO delivery in that window — say exactly that; never present zeros as measured performance. 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.',
|
|
11078
11197
|
inputSchema: {
|
|
11079
11198
|
campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
|
|
11080
11199
|
since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
|
|
@@ -11088,8 +11207,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11088
11207
|
after: z.string().optional().describe('nextAfter from the previous page; limit goes up to 2000 rows'),
|
|
11089
11208
|
conversions: z.boolean().optional().describe('default true: add conversions, CPA, conversion rate and sales where OpenAI allows them. false leaves them out.'),
|
|
11090
11209
|
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`'),
|
|
11210
|
+
attributedEvents: z.boolean().optional().describe('add events attributed beyond the goal'),
|
|
11211
|
+
eventNames: z.array(z.string()).optional().describe('only these events, e.g. ["order_created"]'),
|
|
11212
|
+
attributionTimeBasis: z.enum(['ad_event_time', 'conversion_time']).optional().describe('default ad_event_time'),
|
|
11213
|
+
clickWindowDays: z.union([z.literal(7), z.literal(14), z.literal(30)]).optional().describe('default 30'),
|
|
11214
|
+
viewWindowDays: z.union([z.literal(0), z.literal(1)]).optional().describe('default 1; 0 = no views'),
|
|
11091
11215
|
},
|
|
11092
|
-
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() },
|
|
11216
|
+
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() },
|
|
11093
11217
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11094
11218
|
}, wrap(async (a) => {
|
|
11095
11219
|
const d = await apiPost('/api/openai-ads/report', a);
|
|
@@ -11097,7 +11221,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11097
11221
|
}));
|
|
11098
11222
|
server.registerTool('openai_ads_geo_search', {
|
|
11099
11223
|
title: 'Find ChatGPT Ads location ids',
|
|
11100
|
-
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.',
|
|
11224
|
+
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.',
|
|
11101
11225
|
inputSchema: { query: z.string().describe('a place name, e.g. "Toronto" or "United Kingdom"'), limit: z.number().optional() },
|
|
11102
11226
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
11103
11227
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12359,7 +12483,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12359
12483
|
}));
|
|
12360
12484
|
server.registerTool('openai_ads_bulk', {
|
|
12361
12485
|
title: 'ChatGPT Ads bulk create/update job',
|
|
12362
|
-
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.',
|
|
12486
|
+
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.',
|
|
12363
12487
|
inputSchema: {
|
|
12364
12488
|
operations: z.array(z.record(z.any())).describe('1–1000 operations: {operation_id, type, idempotency_key (creates) or target_resource_id (updates), input:{…}}'),
|
|
12365
12489
|
validateOnly: z.boolean().optional().describe('true = FREE dry run; nothing is created or changed'),
|
|
@@ -12429,7 +12553,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12429
12553
|
}));
|
|
12430
12554
|
server.registerTool('openai_ads_conversions', {
|
|
12431
12555
|
title: 'ChatGPT Ads attributed conversions',
|
|
12432
|
-
description: 'ATTRIBUTED CONVERSIONS for ChatGPT Ads — the number the whole pixel + conversion-event setup exists to produce, beyond what openai_ads_report shows: that report carries click-through conversions and CPA for the account, a campaign, ad group or ad, while this tool adds VIEW-THROUGH conversions, totals per id across many ids, and the received-event sample. Pass entityIds — the campaign / ad group / ad ids to report on — with a matching level; the default window is the last 30 days. NEVER ADD conversions AND viewThroughConversions TOGETHER: OpenAI states that "conversions is always equal to click_through_conversions" and that view-through is "a separate, supplemental metric" NOT added to that total, and that view-through is reporting-only because CPA, post-click CVR, bidding, billing and conversion optimization all remain click-through-based. NO ROWS means no attributed conversion was recorded, not that data is missing — say exactly that, and check that an event setting exists (list_openai_ads_conversion_events) and that its pixel snippet is actually live on the site. RECEIVED EVENTS: pass recentEvents:true (no ids needed) to read a recent SAMPLE of the events OpenAI actually received on the pixel — type, event time, receive time, API channel and the event id — which answers "did OpenAI get the signup at all?" when a conversion is missing. It is a sample, not a complete log, and receiving an event is not the same as attributing it. Read-only, free.',
|
|
12556
|
+
description: 'ATTRIBUTED CONVERSIONS for ChatGPT Ads — the number the whole pixel + conversion-event setup exists to produce, beyond what openai_ads_report shows: that report carries click-through conversions and CPA for the account, a campaign, ad group or ad, while this tool adds VIEW-THROUGH conversions, totals per id across many ids, and the received-event sample. Pass entityIds — the campaign / ad group / ad ids to report on — with a matching level; the default window is the last 30 days. NEVER ADD conversions AND viewThroughConversions TOGETHER: OpenAI states that "conversions is always equal to click_through_conversions" and that view-through is "a separate, supplemental metric" NOT added to that total, and that view-through is reporting-only because CPA, post-click CVR, bidding, billing and conversion optimization all remain click-through-based. NO ROWS means no attributed conversion was recorded, not that data is missing — say exactly that, and check that an event setting exists (list_openai_ads_conversion_events) and that its pixel snippet is actually live on the site. RECEIVED EVENTS: pass recentEvents:true (no ids needed) to read a recent SAMPLE of the events OpenAI actually received on the pixel — type, event time, receive time, API channel and the event id — which answers "did OpenAI get the signup at all?" when a conversion is missing. It is a sample, not a complete log, and receiving an event is not the same as attributing it. 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.',
|
|
12433
12557
|
inputSchema: {
|
|
12434
12558
|
level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('inferred from which id you pass — default ad_account'),
|
|
12435
12559
|
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'),
|
|
@@ -12440,14 +12564,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12440
12564
|
recentEvents: z.boolean().optional().describe('true = list a recent sample of the events OpenAI RECEIVED on the pixel instead of attributed totals'),
|
|
12441
12565
|
pixelId: z.string().optional().describe('with recentEvents: the pixel_id to read; omit to use the account\'s first pixel'),
|
|
12442
12566
|
limit: z.number().optional().describe('with recentEvents: how many events, 1-50 (default 50)'),
|
|
12567
|
+
attributedEvents: z.boolean().optional().describe('default true; false = goal counts only'),
|
|
12568
|
+
eventNames: z.array(z.string()).optional().describe('only these events, e.g. ["order_created"]'),
|
|
12569
|
+
attributionTimeBasis: z.enum(['ad_event_time', 'conversion_time']).optional().describe('default ad_event_time'),
|
|
12570
|
+
clickWindowDays: z.union([z.literal(7), z.literal(14), z.literal(30)]).optional().describe('default 30'),
|
|
12571
|
+
viewWindowDays: z.union([z.literal(0), z.literal(1)]).optional().describe('default 1; 0 = no views'),
|
|
12572
|
+
includeZeroRows: z.boolean().optional().describe('default true'),
|
|
12443
12573
|
},
|
|
12444
|
-
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() },
|
|
12574
|
+
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() },
|
|
12445
12575
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12446
12576
|
}, wrap(async (a) => {
|
|
12447
12577
|
const d = await apiPost('/api/openai-ads/conversions', a);
|
|
12448
12578
|
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);
|
|
12449
12579
|
const daily = Array.isArray(d.byDate) && d.byDate.length ? `\nBy day: ${d.byDate.map(x => `${x.date} ${x.conversions}`).join(' · ')}` : '';
|
|
12450
|
-
|
|
12580
|
+
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(', ')}` : '');
|
|
12581
|
+
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);
|
|
12451
12582
|
}));
|
|
12452
12583
|
server.registerTool('preview_openai_ads_ad', {
|
|
12453
12584
|
title: 'Preview a ChatGPT Ads ad',
|
|
@@ -12483,16 +12614,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12483
12614
|
return ok(`${d.note}${d.apiKey ? `\n\nKEY (store it now — it cannot be retrieved again): ${d.apiKey}` : ''}`, d);
|
|
12484
12615
|
}));
|
|
12485
12616
|
server.registerTool('update_openai_ads_account', {
|
|
12486
|
-
title: 'Update the ChatGPT Ads account brand
|
|
12487
|
-
description: 'Update the ChatGPT Ads
|
|
12488
|
-
inputSchema: {
|
|
12489
|
-
|
|
12617
|
+
title: 'Update the ChatGPT Ads account (brand / account / legal name, URL, favicon)',
|
|
12618
|
+
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.',
|
|
12619
|
+
inputSchema: {
|
|
12620
|
+
brandName: z.string().optional().describe('shown in ads; pauses delivery until review (confirm)'),
|
|
12621
|
+
accountName: z.string().optional().describe('internal; no review'),
|
|
12622
|
+
legalName: z.string().optional().describe('restarts business review (confirm)'),
|
|
12623
|
+
confirm: z.boolean().optional().describe('required for a brand or legal change'),
|
|
12624
|
+
name: z.string().optional().describe('legacy: sets all three names'),
|
|
12490
12625
|
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.'),
|
|
12491
12626
|
faviconUrl: z.string().optional().describe('public image URL (or the brand home page) to upload and assign as the account favicon — minimum 128×128'),
|
|
12492
12627
|
faviconFileId: z.string().optional().describe('an already-uploaded favicon file id, if you have one'),
|
|
12493
12628
|
},
|
|
12494
|
-
outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).optional(), faviconFileId: z.string().nullable().optional(), account: z.any().optional(), note: z.string().optional() },
|
|
12495
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12629
|
+
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() },
|
|
12630
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
12496
12631
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/account', a); return ok(d.note, d); }));
|
|
12497
12632
|
server.registerTool('set_openai_ads_status', {
|
|
12498
12633
|
title: 'Activate, pause or archive a ChatGPT Ads account / campaign / ad group / ad',
|
|
@@ -12642,13 +12777,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12642
12777
|
inputSchema: { windowId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12643
12778
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/spend-window/delete', a))));
|
|
12644
12779
|
server.registerTool('set_openai_ads_daily_spend_limit', {
|
|
12645
|
-
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.',
|
|
12646
|
-
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() },
|
|
12780
|
+
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.',
|
|
12781
|
+
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() },
|
|
12647
12782
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12648
12783
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/daily-spend-limit', a))));
|
|
12649
12784
|
server.registerTool('remove_openai_ads_daily_spend_limit', {
|
|
12650
|
-
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.',
|
|
12651
|
-
inputSchema: { expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), confirm: z.boolean().optional() },
|
|
12785
|
+
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.',
|
|
12786
|
+
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() },
|
|
12652
12787
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12653
12788
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/daily-spend-limit/delete', a))));
|
|
12654
12789
|
server.registerTool('set_openai_ads_negative_keywords', {
|
|
@@ -12656,7 +12791,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12656
12791
|
inputSchema: { keywords: z.array(z.string()) }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12657
12792
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/negative-keywords', a))));
|
|
12658
12793
|
server.registerTool('list_openai_ads_feeds', {
|
|
12659
|
-
title: 'List ChatGPT Ads product feeds', description: 'The PRODUCT FEEDS on this ChatGPT Ads account (id, name, countries, currencies,
|
|
12794
|
+
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.',
|
|
12660
12795
|
inputSchema: { limit: z.number().optional(), after: z.string().optional() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12661
12796
|
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feeds', a))));
|
|
12662
12797
|
server.registerTool('create_openai_ads_feed', {
|
|
@@ -12664,7 +12799,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12664
12799
|
inputSchema: { name: z.string(), countries: z.array(z.string()).optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12665
12800
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed', a))));
|
|
12666
12801
|
server.registerTool('list_openai_ads_feed_uploads', {
|
|
12667
|
-
title: 'List ChatGPT Ads feed uploads', description: 'Recent product-feed UPLOADS across the account with per-upload status
|
|
12802
|
+
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.',
|
|
12668
12803
|
inputSchema: { limit: z.number().optional() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12669
12804
|
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feed-uploads', a))));
|
|
12670
12805
|
server.registerTool('archive_openai_ads_feed', {
|
|
@@ -12672,7 +12807,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12672
12807
|
inputSchema: { feedId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12673
12808
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/archive', a))));
|
|
12674
12809
|
server.registerTool('query_openai_ads_feed_products', {
|
|
12675
|
-
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.',
|
|
12810
|
+
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.',
|
|
12676
12811
|
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() },
|
|
12677
12812
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12678
12813
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/products/query', a))));
|
|
@@ -12685,6 +12820,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12685
12820
|
inputSchema: { feedId: z.string(), action: z.enum(['create', 'activate', 'pause']).optional(), authenticationMethod: z.enum(['password', 'ssh_key']).optional(), sshPublicKey: z.string().optional() },
|
|
12686
12821
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12687
12822
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/sftp', a))));
|
|
12823
|
+
server.registerTool('get_openai_ads_feed_settings', {
|
|
12824
|
+
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.',
|
|
12825
|
+
inputSchema: { feedId: z.string() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12826
|
+
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feed/settings', a))));
|
|
12827
|
+
server.registerTool('update_openai_ads_feed_settings', {
|
|
12828
|
+
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.',
|
|
12829
|
+
inputSchema: { feedId: z.string(), name: z.string().optional(), searchEligibleRespected: z.boolean().optional() },
|
|
12830
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12831
|
+
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/settings', a))));
|
|
12832
|
+
server.registerTool('list_openai_ads_audit_logs', {
|
|
12833
|
+
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.',
|
|
12834
|
+
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() },
|
|
12835
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12836
|
+
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/audit-logs', a))));
|
|
12688
12837
|
|
|
12689
12838
|
server.registerTool('list_pinterest_ads_campaigns', {
|
|
12690
12839
|
title: 'List Pinterest ad accounts / campaigns',
|
|
@@ -16124,7 +16273,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16124
16273
|
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.'),
|
|
16125
16274
|
},
|
|
16126
16275
|
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() },
|
|
16127
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16276
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
16128
16277
|
}, publishWrap(async (a) => {
|
|
16129
16278
|
const d = await apiPost('/api/linkedin/org-post', a);
|
|
16130
16279
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was posted a second time.)`, d);
|
|
@@ -16752,7 +16901,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16752
16901
|
trash: z.boolean().optional().describe('true -> move to Trash; false -> restore from Trash'),
|
|
16753
16902
|
},
|
|
16754
16903
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), trashed: z.boolean().optional() },
|
|
16755
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
16904
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
16756
16905
|
}, wrap(async (a) => {
|
|
16757
16906
|
const d = await apiPost('/api/drive/file/update', a);
|
|
16758
16907
|
return ok(`Updated “${d.name || a.fileId}”.`, d);
|
|
@@ -16970,7 +17119,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16970
17119
|
boldHeader: z.boolean().optional(), freezeRows: z.number().optional().describe('how many top rows to freeze (default 1, 0 = none)'), autoResize: z.boolean().optional(),
|
|
16971
17120
|
},
|
|
16972
17121
|
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() },
|
|
16973
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17122
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
16974
17123
|
}, wrap(async (a) => {
|
|
16975
17124
|
const d = await apiPost('/api/sheets/format', a);
|
|
16976
17125
|
return ok(d.note, d);
|
|
@@ -17046,7 +17195,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17046
17195
|
moveToFolderId: z.string().optional().describe('folder id to move the item into (from create_onedrive_folder / list_onedrive_files)'),
|
|
17047
17196
|
},
|
|
17048
17197
|
outputSchema: { id: z.string().optional(), name: z.string().optional() },
|
|
17049
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
17198
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
17050
17199
|
}, wrap(async (a) => {
|
|
17051
17200
|
const d = await apiPost('/api/onedrive/file/update', a);
|
|
17052
17201
|
return ok(`Updated “${d.name || a.fileId}”.`, d);
|
|
@@ -18252,34 +18401,34 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18252
18401
|
server.registerTool('save_skill', {
|
|
18253
18402
|
title: 'Save a skill',
|
|
18254
18403
|
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).',
|
|
18255
|
-
inputSchema: {
|
|
18404
|
+
inputSchema: { ...STORE_BRAND,
|
|
18256
18405
|
name: z.string().describe('short skill name, e.g. “Founder-story hook”'),
|
|
18257
18406
|
directive: z.string().describe('the full instruction the skill applies when used (1–6 sentences, imperative)'),
|
|
18258
18407
|
},
|
|
18259
18408
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
18260
18409
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18261
|
-
}, wrap(async (a) => {
|
|
18410
|
+
}, wrap(inBrand(async (a) => {
|
|
18262
18411
|
const name = String(a.name || '').trim(), directive = String(a.directive || '').trim();
|
|
18263
18412
|
if (!name || !directive) return { content: [{ type: 'text', text: 'A skill needs both a name and a directive.' }], isError: true };
|
|
18264
18413
|
let list = await readStore('heist.skills.v1'); if (!Array.isArray(list)) list = [];
|
|
18265
18414
|
const item = { id: newId('s'), name, directive, kind: 'custom', custom: true, createdAt: Date.now() };
|
|
18266
18415
|
await writeStore('heist.skills.v1', [item, ...list].slice(0, 200));
|
|
18267
18416
|
return ok(`Saved skill “${name}” — it’s now in the workspace Skills library.`, { ok: true, id: item.id });
|
|
18268
|
-
}));
|
|
18417
|
+
})));
|
|
18269
18418
|
server.registerTool('delete_skill', {
|
|
18270
18419
|
title: 'Delete a custom skill',
|
|
18271
18420
|
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.',
|
|
18272
|
-
inputSchema: { id: z.string().describe('the custom skill id (from list_skills)') },
|
|
18421
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the custom skill id (from list_skills)') },
|
|
18273
18422
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18274
18423
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18275
|
-
}, wrap(async (a) => {
|
|
18424
|
+
}, wrap(inBrand(async (a) => {
|
|
18276
18425
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the skill id (from list_skills).' }], isError: true };
|
|
18277
18426
|
let list = await readStore('heist.skills.v1'); if (!Array.isArray(list)) list = [];
|
|
18278
18427
|
if (!list.some(s => s && s.id === id)) return ok(`No custom skill ${id} (built-ins can’t be deleted).`, { ok: false, removed: false });
|
|
18279
18428
|
await tombstone('heist.skills.v1', id); // write the delete FIRST so the content merge honors it (won’t resurrect)
|
|
18280
18429
|
await writeStore('heist.skills.v1', list.filter(s => s && s.id !== id));
|
|
18281
18430
|
return ok(`Deleted skill ${id}.`, { ok: true, removed: true });
|
|
18282
|
-
}));
|
|
18431
|
+
})));
|
|
18283
18432
|
server.registerTool('list_memory', {
|
|
18284
18433
|
title: 'List memory',
|
|
18285
18434
|
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.',
|
|
@@ -18318,13 +18467,13 @@ function memoryNoteVerdict(text) {
|
|
|
18318
18467
|
server.registerTool('remember', {
|
|
18319
18468
|
title: 'Remember a fact',
|
|
18320
18469
|
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.',
|
|
18321
|
-
inputSchema: {
|
|
18470
|
+
inputSchema: { ...STORE_BRAND,
|
|
18322
18471
|
text: z.string().describe('the fact/preference, concise'),
|
|
18323
18472
|
category: z.string().optional().describe('short bucket: Brand, Audience, Taste, Do, Don’t, or Preference (default General)'),
|
|
18324
18473
|
},
|
|
18325
18474
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
18326
18475
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18327
|
-
}, wrap(async (a) => {
|
|
18476
|
+
}, wrap(inBrand(async (a) => {
|
|
18328
18477
|
const text = String(a.text || '').trim(); if (!text) return { content: [{ type: 'text', text: 'Nothing to remember — pass text.' }], isError: true };
|
|
18329
18478
|
{ const v = memoryNoteVerdict(text); if (!v.ok) return { content: [{ type: 'text', text: v.message }], isError: true }; }
|
|
18330
18479
|
let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
|
|
@@ -18332,21 +18481,21 @@ function memoryNoteVerdict(text) {
|
|
|
18332
18481
|
const item = { id: newId('m'), text, category: String(a.category || 'General').trim() || 'General', source: 'mcp', createdAt: Date.now() };
|
|
18333
18482
|
await writeStore('heist.memory.v1', [item, ...list].slice(0, 200));
|
|
18334
18483
|
return ok(`Saved to memory: “${text}” [${item.category}].`, { ok: true, id: item.id });
|
|
18335
|
-
}));
|
|
18484
|
+
})));
|
|
18336
18485
|
server.registerTool('forget', {
|
|
18337
18486
|
title: 'Forget a memory',
|
|
18338
18487
|
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.',
|
|
18339
|
-
inputSchema: { id: z.string().describe('the memory item id (from list_memory)') },
|
|
18488
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the memory item id (from list_memory)') },
|
|
18340
18489
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18341
18490
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18342
|
-
}, wrap(async (a) => {
|
|
18491
|
+
}, wrap(inBrand(async (a) => {
|
|
18343
18492
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the memory id (from list_memory).' }], isError: true };
|
|
18344
18493
|
let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
|
|
18345
18494
|
if (!list.some(m => m && m.id === id)) return ok(`No memory item ${id}.`, { ok: false, removed: false });
|
|
18346
18495
|
await tombstone('heist.memory.v1', id);
|
|
18347
18496
|
await writeStore('heist.memory.v1', list.filter(m => m && m.id !== id));
|
|
18348
18497
|
return ok(`Forgot memory ${id}.`, { ok: true, removed: true });
|
|
18349
|
-
}));
|
|
18498
|
+
})));
|
|
18350
18499
|
|
|
18351
18500
|
// ── SWIPEFILE — the workspace's saved-ad research board: named COLLECTIONS holding the ads and creatives the user
|
|
18352
18501
|
// kept. It is a SERVER-SYNCED store, not browser state: `adInspo.swipefile.v1` is in the client's PROFILE_SCOPED_SYNC
|
|
@@ -18359,10 +18508,10 @@ function memoryNoteVerdict(text) {
|
|
|
18359
18508
|
server.registerTool('tidy_memory', {
|
|
18360
18509
|
title: 'Tidy the Memory list',
|
|
18361
18510
|
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.',
|
|
18362
|
-
inputSchema: { confirm: z.boolean().optional().describe('true to APPLY the proposal; omit to only see it') },
|
|
18511
|
+
inputSchema: { ...STORE_BRAND, confirm: z.boolean().optional().describe('true to APPLY the proposal; omit to only see it') },
|
|
18363
18512
|
outputSchema: { scanned: z.number().optional(), remove: z.array(z.any()).optional(), merge: z.array(z.any()).optional(), applied: z.boolean().optional() },
|
|
18364
18513
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18365
|
-
}, wrap(async (a) => {
|
|
18514
|
+
}, wrap(inBrand(async (a) => {
|
|
18366
18515
|
let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
|
|
18367
18516
|
if (!list.length) return ok('Memory is empty — nothing to tidy.', { scanned: 0, remove: [], merge: [], applied: false });
|
|
18368
18517
|
const r = await apiPost('/api/memory/tidy', { items: list.map(m => ({ id: m.id, text: m.text, category: m.category })) });
|
|
@@ -18378,7 +18527,7 @@ function memoryNoteVerdict(text) {
|
|
|
18378
18527
|
const merged = (d.merge || []).map(m => ({ id: newId('m'), text: m.text, category: m.category || 'General', source: 'tidy', createdAt: Date.now() }));
|
|
18379
18528
|
await writeStore('heist.memory.v1', [...merged, ...list.filter(m => m && !gone.has(String(m.id)))].slice(0, 200));
|
|
18380
18529
|
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 });
|
|
18381
|
-
}));
|
|
18530
|
+
})));
|
|
18382
18531
|
|
|
18383
18532
|
const SWIPE_KEY = 'adInspo.swipefile.v1';
|
|
18384
18533
|
const swipeEmpty = () => ({ collections: [{ id: 'default', name: 'My Swipefile' }], activeId: 'default', ads: [] });
|
|
@@ -18421,7 +18570,7 @@ function memoryNoteVerdict(text) {
|
|
|
18421
18570
|
server.registerTool('save_to_swipefile', {
|
|
18422
18571
|
title: 'Save ads to the swipefile',
|
|
18423
18572
|
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.',
|
|
18424
|
-
inputSchema: {
|
|
18573
|
+
inputSchema: { ...STORE_BRAND,
|
|
18425
18574
|
collection: z.string().describe('the collection name — an existing one, or a new one to create'),
|
|
18426
18575
|
items: z.array(z.object({
|
|
18427
18576
|
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'),
|
|
@@ -18438,7 +18587,7 @@ function memoryNoteVerdict(text) {
|
|
|
18438
18587
|
},
|
|
18439
18588
|
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() },
|
|
18440
18589
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
18441
|
-
}, wrap(async (a) => {
|
|
18590
|
+
}, wrap(inBrand(async (a) => {
|
|
18442
18591
|
const name = String(a.collection || '').trim();
|
|
18443
18592
|
const items = (Array.isArray(a.items) ? a.items : []).filter(x => x && typeof x === 'object');
|
|
18444
18593
|
if (!name) return { content: [{ type: 'text', text: 'Name the collection to save into.' }], isError: true };
|
|
@@ -18458,7 +18607,7 @@ function memoryNoteVerdict(text) {
|
|
|
18458
18607
|
s.activeId = col.id;
|
|
18459
18608
|
await writeStore(SWIPE_KEY, s);
|
|
18460
18609
|
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 });
|
|
18461
|
-
}));
|
|
18610
|
+
})));
|
|
18462
18611
|
|
|
18463
18612
|
|
|
18464
18613
|
// ── SAVED CREATORS: outreach status + notes (2026-09-05: "parity with dedicated tools"). A creator saved by
|
|
@@ -18468,14 +18617,14 @@ function memoryNoteVerdict(text) {
|
|
|
18468
18617
|
server.registerTool('update_saved_creator', {
|
|
18469
18618
|
title: 'Update a saved creator (outreach status, note)',
|
|
18470
18619
|
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.',
|
|
18471
|
-
inputSchema: {
|
|
18620
|
+
inputSchema: { ...STORE_BRAND,
|
|
18472
18621
|
key: z.string().describe("the saved row's key from list_swipefile, e.g. tiktok:handle"),
|
|
18473
18622
|
status: z.enum(['new', 'contacted', 'replied', 'booked', 'passed']).optional(),
|
|
18474
18623
|
note: z.string().max(2000).optional().describe('replaces the existing note; pass "" to clear it'),
|
|
18475
18624
|
},
|
|
18476
18625
|
outputSchema: { ok: z.boolean().optional(), key: z.string().optional(), status: z.string().optional(), note: z.string().optional(), creator: z.any().optional() },
|
|
18477
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
18478
|
-
}, wrap(async (a) => {
|
|
18626
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18627
|
+
}, wrap(inBrand(async (a) => {
|
|
18479
18628
|
const key = String(a.key || '').trim();
|
|
18480
18629
|
if (!key) return { content: [{ type: 'text', text: 'Pass the saved creator key (from list_swipefile).' }], isError: true };
|
|
18481
18630
|
if (a.status == null && a.note == null) return { content: [{ type: 'text', text: 'Pass a status and/or a note.' }], isError: true };
|
|
@@ -18492,7 +18641,7 @@ function memoryNoteVerdict(text) {
|
|
|
18492
18641
|
await writeStore(SWIPE_KEY, s);
|
|
18493
18642
|
const o = row.creator.outreach;
|
|
18494
18643
|
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 });
|
|
18495
|
-
}));
|
|
18644
|
+
})));
|
|
18496
18645
|
|
|
18497
18646
|
// ── PLAYBOOKS — the "what's working + the plays to run" cards, distinct from the swipefile's raw creative. Same
|
|
18498
18647
|
// server-synced store seam (`heist.playbooks.v1`, union-merged by id in adapters/sync-merge.js); the web app has a
|
|
@@ -18522,22 +18671,21 @@ function memoryNoteVerdict(text) {
|
|
|
18522
18671
|
server.registerTool('save_playbook', {
|
|
18523
18672
|
title: 'Save a playbook',
|
|
18524
18673
|
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.',
|
|
18525
|
-
inputSchema: {
|
|
18674
|
+
inputSchema: { ...STORE_BRAND,
|
|
18526
18675
|
name: z.string().describe('the playbook headline — what it is, in a few words'),
|
|
18527
18676
|
hooks: z.array(z.string()).optional().describe('the opening hooks worth reusing, verbatim'),
|
|
18528
18677
|
angles: z.array(z.object({ title: z.string(), detail: z.string().optional() }).passthrough()).optional().describe('the persuasion angles ({title, detail})'),
|
|
18529
18678
|
formats: z.array(z.string()).optional().describe('the formats/recipes this plays best in (e.g. ugc_selfie, cinematic, static)'),
|
|
18530
18679
|
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'),
|
|
18531
|
-
brand: z.string().optional().describe('which brand this is for (defaults to the workspace brand)'),
|
|
18532
18680
|
source: z.string().optional().describe('where it came from, e.g. “teardown · Ridge”'),
|
|
18533
18681
|
},
|
|
18534
18682
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
18535
18683
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18536
|
-
}, wrap(async (a) => {
|
|
18684
|
+
}, wrap(inBrand(async (a) => {
|
|
18537
18685
|
const headline = String(a.name || '').trim();
|
|
18538
18686
|
if (!headline) return { content: [{ type: 'text', text: 'A playbook needs a name.' }], isError: true };
|
|
18539
18687
|
const list = await readPlaybooks();
|
|
18540
|
-
const brand = String(
|
|
18688
|
+
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
|
|
18541
18689
|
const item = {
|
|
18542
18690
|
id: newId('pb'), headline: headline.slice(0, 200),
|
|
18543
18691
|
hooks: (a.hooks || []).slice(0, 20).map(t => ({ text: String(t).slice(0, 400), why: 'saved by an agent' })),
|
|
@@ -18548,21 +18696,21 @@ function memoryNoteVerdict(text) {
|
|
|
18548
18696
|
};
|
|
18549
18697
|
await writeStore(PB_KEY, [item, ...list].slice(0, 200));
|
|
18550
18698
|
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 });
|
|
18551
|
-
}));
|
|
18699
|
+
})));
|
|
18552
18700
|
server.registerTool('delete_playbook', {
|
|
18553
18701
|
title: 'Delete a playbook',
|
|
18554
18702
|
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.',
|
|
18555
|
-
inputSchema: { id: z.string().describe('the playbook id (from list_playbooks)') },
|
|
18703
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the playbook id (from list_playbooks)') },
|
|
18556
18704
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18557
18705
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18558
|
-
}, wrap(async (a) => {
|
|
18706
|
+
}, wrap(inBrand(async (a) => {
|
|
18559
18707
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the playbook id (from list_playbooks).' }], isError: true };
|
|
18560
18708
|
const list = await readPlaybooks();
|
|
18561
18709
|
if (!list.some(p => p && p.id === id)) return ok(`No playbook ${id}.`, { ok: false, removed: false });
|
|
18562
18710
|
await tombstone(PB_KEY, id); // the delete is written BEFORE the content so the union-merge already sees it
|
|
18563
18711
|
await writeStore(PB_KEY, list.filter(p => p && p.id !== id));
|
|
18564
18712
|
return ok(`Deleted playbook ${id}.`, { ok: true, removed: true });
|
|
18565
|
-
}));
|
|
18713
|
+
})));
|
|
18566
18714
|
|
|
18567
18715
|
// ── SAVED CREATORS — the workspace's reusable on-camera cast (`heist.avatars.v1`) ──────────────────────────────
|
|
18568
18716
|
// Closed 2026-08-01 (feature-surface sweep §4b, "on the web, absent from MCP"). The app has the whole lifecycle —
|
|
@@ -18594,7 +18742,7 @@ function memoryNoteVerdict(text) {
|
|
|
18594
18742
|
server.registerTool('save_creator', {
|
|
18595
18743
|
title: 'Save a creator',
|
|
18596
18744
|
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,
|
|
18597
|
-
inputSchema: {
|
|
18745
|
+
inputSchema: { ...STORE_BRAND,
|
|
18598
18746
|
name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
|
|
18599
18747
|
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'),
|
|
18600
18748
|
source: z.enum(['generated', 'upload', 'social']).optional().describe('"generated" (default) = an AI-made person; "upload" / "social" = a REAL person'),
|
|
@@ -18605,7 +18753,7 @@ function memoryNoteVerdict(text) {
|
|
|
18605
18753
|
},
|
|
18606
18754
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional(), refQuality: z.any().optional() },
|
|
18607
18755
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18608
|
-
}, wrap(async (a) => {
|
|
18756
|
+
}, wrap(inBrand(async (a) => {
|
|
18609
18757
|
const name = String(a.name || '').trim();
|
|
18610
18758
|
// A LINK TO A PHOTO'S WEB PAGE (an Unsplash / Pexels / Pinterest page, 2026-09-29) is saved as the PICTURE behind it,
|
|
18611
18759
|
// so the cast never holds a page no render can read; a page with no reachable picture is refused here, saving nothing.
|
|
@@ -18662,14 +18810,14 @@ function memoryNoteVerdict(text) {
|
|
|
18662
18810
|
await writeStore('heist.avatars.v1', [item, ...list].slice(0, 200));
|
|
18663
18811
|
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).' : '';
|
|
18664
18812
|
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 } });
|
|
18665
|
-
}));
|
|
18813
|
+
})));
|
|
18666
18814
|
server.registerTool('delete_creator', {
|
|
18667
18815
|
title: 'Delete a creator',
|
|
18668
18816
|
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.',
|
|
18669
|
-
inputSchema: { id: z.string().describe('the creator id (from list_creators)') },
|
|
18817
|
+
inputSchema: { ...STORE_BRAND, id: z.string().describe('the creator id (from list_creators)') },
|
|
18670
18818
|
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional() },
|
|
18671
18819
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18672
|
-
}, wrap(async (a) => {
|
|
18820
|
+
}, wrap(inBrand(async (a) => {
|
|
18673
18821
|
const id = String(a.id || '').trim(); if (!id) return { content: [{ type: 'text', text: 'Pass the creator id (from list_creators).' }], isError: true };
|
|
18674
18822
|
let list = await readStore('heist.avatars.v1'); if (!Array.isArray(list)) list = [];
|
|
18675
18823
|
const hit = list.find(x => x && x.id === id);
|
|
@@ -18677,11 +18825,11 @@ function memoryNoteVerdict(text) {
|
|
|
18677
18825
|
await tombstone('heist.avatars.v1', id); // written BEFORE the content PUT so the union-merge already sees the delete
|
|
18678
18826
|
await writeStore('heist.avatars.v1', list.filter(x => x && x.id !== id));
|
|
18679
18827
|
return ok(`Removed “${hit.name || 'Creator'}” from the workspace cast.`, { ok: true, removed: true });
|
|
18680
|
-
}));
|
|
18828
|
+
})));
|
|
18681
18829
|
server.registerTool('update_brand', {
|
|
18682
18830
|
title: 'Update brand fields',
|
|
18683
18831
|
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.',
|
|
18684
|
-
inputSchema: {
|
|
18832
|
+
inputSchema: { ...STORE_BRAND,
|
|
18685
18833
|
name: z.string().optional(), domain: z.string().optional().describe('website domain'), sells: z.string().optional().describe('what the brand sells'),
|
|
18686
18834
|
summary: z.string().optional().describe('one-line description'), category: z.string().optional(), audience: z.string().optional(),
|
|
18687
18835
|
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'),
|
|
@@ -18689,8 +18837,8 @@ function memoryNoteVerdict(text) {
|
|
|
18689
18837
|
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.'),
|
|
18690
18838
|
},
|
|
18691
18839
|
outputSchema: { ok: z.boolean().optional(), updated: z.array(z.string()).optional(), brand: z.any().optional() },
|
|
18692
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
18693
|
-
}, wrap(async (a) => {
|
|
18840
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
18841
|
+
}, wrap(inBrand(async (a) => {
|
|
18694
18842
|
const allow = ['name', 'domain', 'sells', 'summary', 'category', 'audience', 'positioning', 'voice', 'style', 'goal', 'pronounce'];
|
|
18695
18843
|
const patch = {}; for (const k of allow) if (a[k] != null && String(a[k]).trim()) patch[k] = String(a[k]).slice(0, 400);
|
|
18696
18844
|
let brand = await readStore('heist.brand.v1'); if (!brand || typeof brand !== 'object' || Array.isArray(brand)) brand = {};
|
|
@@ -18703,8 +18851,8 @@ function memoryNoteVerdict(text) {
|
|
|
18703
18851
|
if (!Object.keys(patch).length) return { content: [{ type: 'text', text: 'Pass at least one brand field to change.' }], isError: true };
|
|
18704
18852
|
const merged = { ...brand, ...patch };
|
|
18705
18853
|
await writeStore('heist.brand.v1', merged); // the store PUT preserves server-side brand enrichments (playbook/pronounce)
|
|
18706
|
-
return ok(`Updated brand (${Object.keys(patch).join(', ')}
|
|
18707
|
-
}));
|
|
18854
|
+
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 });
|
|
18855
|
+
})));
|
|
18708
18856
|
server.registerTool('store_get', {
|
|
18709
18857
|
title: 'Read a workspace store',
|
|
18710
18858
|
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.)',
|
|
@@ -18744,7 +18892,7 @@ function memoryNoteVerdict(text) {
|
|
|
18744
18892
|
watchEmail: z.boolean().optional().describe('weekly competitor-watch email on/off'),
|
|
18745
18893
|
},
|
|
18746
18894
|
outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).optional(), language: z.string().optional(), theme: z.string().optional(), notifications: z.any().optional() },
|
|
18747
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
18895
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
18748
18896
|
}, wrap(async (a) => {
|
|
18749
18897
|
const body = {};
|
|
18750
18898
|
if (a.language != null) body.language = a.language;
|
|
@@ -19019,7 +19167,7 @@ function memoryNoteVerdict(text) {
|
|
|
19019
19167
|
accountIds: z.array(z.string()).describe('the ids (from list_connector_accounts) this brand may use — an empty array shares nothing'),
|
|
19020
19168
|
},
|
|
19021
19169
|
outputSchema: { ok: z.boolean().optional(), provider: z.string().optional(), selectedIds: z.array(z.string()).optional() },
|
|
19022
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19170
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19023
19171
|
}, wrap(async (a) => {
|
|
19024
19172
|
const p = CONN_ACCOUNT_PICKERS[a.provider];
|
|
19025
19173
|
if (!p) return { content: [{ type: 'text', text: `set_connector_accounts covers: ${CONN_PICKER_IDS.join(', ')}.` }], isError: true };
|
|
@@ -19086,7 +19234,7 @@ function memoryNoteVerdict(text) {
|
|
|
19086
19234
|
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'),
|
|
19087
19235
|
},
|
|
19088
19236
|
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() },
|
|
19089
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19237
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19090
19238
|
}, wrap(async (a) => {
|
|
19091
19239
|
const refuse = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
19092
19240
|
const provider = String(a.provider || '').trim().toLowerCase();
|
|
@@ -19182,7 +19330,7 @@ function memoryNoteVerdict(text) {
|
|
|
19182
19330
|
confirm: z.boolean().optional().describe('REQUIRED true — this invites a real person'),
|
|
19183
19331
|
},
|
|
19184
19332
|
outputSchema: { ok: z.boolean().optional(), invited: z.boolean().optional(), link: z.string().optional() },
|
|
19185
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19333
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
19186
19334
|
}, wrap(async (a) => {
|
|
19187
19335
|
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 });
|
|
19188
19336
|
const d = await apiPost('/api/team/invite', { email: a.email, role: a.role });
|
|
@@ -19230,7 +19378,7 @@ function memoryNoteVerdict(text) {
|
|
|
19230
19378
|
confirm: z.boolean().optional().describe('REQUIRED true'),
|
|
19231
19379
|
},
|
|
19232
19380
|
outputSchema: { ok: z.boolean().optional(), email: z.string().optional(), role: z.string().optional() },
|
|
19233
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19381
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19234
19382
|
}, wrap(async (a) => {
|
|
19235
19383
|
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 });
|
|
19236
19384
|
// Naming somebody who is not a member 404s server-side and surfaces as an error here. On success the role we
|
|
@@ -19311,7 +19459,7 @@ function memoryNoteVerdict(text) {
|
|
|
19311
19459
|
candidates: z.array(z.any()).optional().describe('discovered brands ({name, domain, kind, reason})'),
|
|
19312
19460
|
diagnostics: z.any().optional().describe('discovery diagnostics (model usage, web grounding)'),
|
|
19313
19461
|
},
|
|
19314
|
-
annotations: { readOnlyHint:
|
|
19462
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19315
19463
|
}, wrap(async ({ domain, mode = 'competitors' }) => {
|
|
19316
19464
|
const d = await apiPost('/api/inspire/competitors', { domain, mode });
|
|
19317
19465
|
const list = (d.candidates || []).map(c => `${c.name} (${c.domain || '—'}, ${c.kind})`).join('; ');
|
|
@@ -19342,7 +19490,7 @@ function memoryNoteVerdict(text) {
|
|
|
19342
19490
|
google: z.any().optional().describe('Google results ({ads[], cursor} or {error}; null when not requested)'),
|
|
19343
19491
|
linkedin: z.any().optional().describe('LinkedIn results ({ads[], cursor} or {error}; null when not requested)'),
|
|
19344
19492
|
},
|
|
19345
|
-
annotations: { readOnlyHint:
|
|
19493
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19346
19494
|
}, wrap(async (a) => {
|
|
19347
19495
|
// META ONLY, AND NOT BY REQUEST — BY CONSTRUCTION. `...a` used to spread the caller's own `platforms` OVER the
|
|
19348
19496
|
// default, so the "default ['facebook']" was only ever a default for a caller who said nothing. Models did not:
|
|
@@ -19455,7 +19603,7 @@ function memoryNoteVerdict(text) {
|
|
|
19455
19603
|
watching: z.array(z.string()).optional().describe('the brand names now under watch (empty = the watch is off)'),
|
|
19456
19604
|
nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
|
|
19457
19605
|
},
|
|
19458
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
19606
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
19459
19607
|
}, wrap(async (a) => {
|
|
19460
19608
|
// An OMITTED list is refused; an EMPTY one is the documented way to stop. Defaulting the omission to [] would
|
|
19461
19609
|
// turn "I forgot the argument" into "delete this workspace's whole watch", silently.
|
|
@@ -19552,7 +19700,7 @@ function memoryNoteVerdict(text) {
|
|
|
19552
19700
|
results: z.array(z.any()).optional().describe('the found ads/videos (normalized card objects with served URLs)'),
|
|
19553
19701
|
actions: z.any().optional().describe('follow-up actions the research loop suggested'),
|
|
19554
19702
|
},
|
|
19555
|
-
annotations: { readOnlyHint:
|
|
19703
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19556
19704
|
}, wrap(async ({ query, brand }) => {
|
|
19557
19705
|
const brandObj = typeof brand === 'string' ? { name: brand } : brand || null;
|
|
19558
19706
|
const d = await apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: query }], brand: brandObj });
|
|
@@ -19643,7 +19791,7 @@ function memoryNoteVerdict(text) {
|
|
|
19643
19791
|
found: z.number().optional().describe('total ads found upstream'),
|
|
19644
19792
|
ads: z.array(z.any()).optional().describe('the compact ad objects ({page_name, body, cta, link, dates, media})'),
|
|
19645
19793
|
},
|
|
19646
|
-
annotations: { readOnlyHint:
|
|
19794
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19647
19795
|
}, wrap(async (a) => {
|
|
19648
19796
|
if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
|
|
19649
19797
|
const common = qp({ country: a.country, status: a.status, media_type: a.mediaType });
|
|
@@ -19696,7 +19844,7 @@ function memoryNoteVerdict(text) {
|
|
|
19696
19844
|
found: z.number().optional().describe('total ads found upstream'),
|
|
19697
19845
|
ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, format, adUrl, image, firstShown, lastShown})'),
|
|
19698
19846
|
},
|
|
19699
|
-
annotations: { readOnlyHint:
|
|
19847
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19700
19848
|
}, wrap(async (a) => {
|
|
19701
19849
|
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"
|
|
19702
19850
|
const d = await apiGet('/api/google/company-ads', qp({ domain: a.domain, advertiser_id: a.advertiserId, region: a.region, get_ad_details: 'false' }));
|
|
@@ -19720,7 +19868,7 @@ function memoryNoteVerdict(text) {
|
|
|
19720
19868
|
found: z.number().optional().describe('total ads found upstream'),
|
|
19721
19869
|
ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, headline, description, cta, link, media, dates, impressions})'),
|
|
19722
19870
|
},
|
|
19723
|
-
annotations: { readOnlyHint:
|
|
19871
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19724
19872
|
}, wrap(async (a) => {
|
|
19725
19873
|
if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
|
|
19726
19874
|
const d = await apiGet('/api/linkedin/search', qp({ company: a.company, keyword: a.keyword, companyId: a.companyId, countries: a.countries }));
|
|
@@ -19755,7 +19903,7 @@ function memoryNoteVerdict(text) {
|
|
|
19755
19903
|
enrich: z.boolean().optional().describe('read follower counts for the top 6 (default true, ~1 credit each)'),
|
|
19756
19904
|
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'),
|
|
19757
19905
|
},
|
|
19758
|
-
annotations: { readOnlyHint:
|
|
19906
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19759
19907
|
}, wrap(async (a) => {
|
|
19760
19908
|
const d = await apiPost('/api/creators/search', a);
|
|
19761
19909
|
const fmt = (v) => v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(v >= 1e5 ? 0 : 1)}K` : String(v);
|
|
@@ -19777,7 +19925,7 @@ function memoryNoteVerdict(text) {
|
|
|
19777
19925
|
limit: z.number().optional().describe('posts per platform, 1–60 (default 24)'),
|
|
19778
19926
|
queries: z.number().optional().describe('query variants per platform, 1–4 (default 1); each is a paid search call'),
|
|
19779
19927
|
},
|
|
19780
|
-
annotations: { readOnlyHint:
|
|
19928
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19781
19929
|
}, wrap(async (a) => {
|
|
19782
19930
|
const d = await apiPost('/api/posts/search', a);
|
|
19783
19931
|
if (!d.shown) return ok(`${d.summary} Nothing matched — try broader words or add platforms.`, d);
|
|
@@ -19798,7 +19946,7 @@ function memoryNoteVerdict(text) {
|
|
|
19798
19946
|
found: z.number().optional().describe('total videos found'),
|
|
19799
19947
|
videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
|
|
19800
19948
|
},
|
|
19801
|
-
annotations: { readOnlyHint:
|
|
19949
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19802
19950
|
}, wrap(async ({ query, limit }) => {
|
|
19803
19951
|
const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
|
|
19804
19952
|
const all = (d.search_item_list || []).map((x) => x.aweme_info).filter(Boolean).map((v) => {
|
|
@@ -19824,7 +19972,7 @@ function memoryNoteVerdict(text) {
|
|
|
19824
19972
|
found: z.number().optional().describe('total reels found'),
|
|
19825
19973
|
reels: z.array(z.any()).optional().describe('the compact reel objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
|
|
19826
19974
|
},
|
|
19827
|
-
annotations: { readOnlyHint:
|
|
19975
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19828
19976
|
}, wrap(async ({ query, limit }) => {
|
|
19829
19977
|
let d;
|
|
19830
19978
|
try { d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query }); }
|
|
@@ -19854,7 +20002,7 @@ function memoryNoteVerdict(text) {
|
|
|
19854
20002
|
found: z.number().optional().describe('total videos found'),
|
|
19855
20003
|
videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, link, cover}), ranked by views'),
|
|
19856
20004
|
},
|
|
19857
|
-
annotations: { readOnlyHint:
|
|
20005
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19858
20006
|
}, wrap(async ({ query, limit }) => {
|
|
19859
20007
|
const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
|
|
19860
20008
|
const all = (d.videos || []).filter((v) => (v.type || 'video') === 'video').map((v) => {
|
|
@@ -19875,7 +20023,7 @@ function memoryNoteVerdict(text) {
|
|
|
19875
20023
|
found: z.number().optional().describe('total posts found'),
|
|
19876
20024
|
posts: z.array(z.any()).optional().describe('the compact post objects ({desc, subreddit, upvotes, comments, link})'),
|
|
19877
20025
|
},
|
|
19878
|
-
annotations: { readOnlyHint:
|
|
20026
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19879
20027
|
}, wrap(async ({ query, limit }) => {
|
|
19880
20028
|
// `sort: 'relevance'` IS THE QUERY (measured live 2026-09-03): the ad-data provider's Reddit search honours the
|
|
19881
20029
|
// query ONLY under relevance — `top` and `new` return the site-wide top/new feeds and ignore it entirely, so this
|
|
@@ -19902,7 +20050,7 @@ function memoryNoteVerdict(text) {
|
|
|
19902
20050
|
found: z.number().optional().describe('total posts found'),
|
|
19903
20051
|
posts: z.array(z.any()).optional().describe('the compact post objects ({desc, author, handle, likes, link, cover})'),
|
|
19904
20052
|
},
|
|
19905
|
-
annotations: { readOnlyHint:
|
|
20053
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19906
20054
|
}, wrap(async ({ query, limit }) => {
|
|
19907
20055
|
const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
|
|
19908
20056
|
const all = (d.posts || d.results || []).map((p) => {
|
|
@@ -19925,7 +20073,7 @@ function memoryNoteVerdict(text) {
|
|
|
19925
20073
|
params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
|
|
19926
20074
|
},
|
|
19927
20075
|
outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
|
|
19928
|
-
annotations: { readOnlyHint:
|
|
20076
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
19929
20077
|
}, wrap(async ({ path, params }) => {
|
|
19930
20078
|
const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
|
|
19931
20079
|
const raw = JSON.stringify(d);
|
|
@@ -19942,7 +20090,7 @@ function memoryNoteVerdict(text) {
|
|
|
19942
20090
|
server.registerTool('get_brand', {
|
|
19943
20091
|
title: 'Get saved brand',
|
|
19944
20092
|
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.',
|
|
19945
|
-
inputSchema: {},
|
|
20093
|
+
inputSchema: { ...STORE_BRAND },
|
|
19946
20094
|
outputSchema: {
|
|
19947
20095
|
hasBrand: z.boolean().optional().describe('whether a brand is saved for this workspace'),
|
|
19948
20096
|
brand: z.any().optional().describe('the saved brand profile (name, domain, category, products, palette, …) or null'),
|
|
@@ -19951,7 +20099,7 @@ function memoryNoteVerdict(text) {
|
|
|
19951
20099
|
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.'),
|
|
19952
20100
|
},
|
|
19953
20101
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
19954
|
-
}, wrap(async () => {
|
|
20102
|
+
}, wrap(inBrand(async () => {
|
|
19955
20103
|
const d = await apiGet('/api/brand/current');
|
|
19956
20104
|
// WHO IS ASKING (2026-09-22): a creator's saved "brand" is their personal brand and an explorer has none on purpose,
|
|
19957
20105
|
// so the text says so before it says what the create tools do with the profile — the same note the web Studio reads.
|
|
@@ -19962,7 +20110,7 @@ function memoryNoteVerdict(text) {
|
|
|
19962
20110
|
? `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}`
|
|
19963
20111
|
: `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}`);
|
|
19964
20112
|
return ok(text, d);
|
|
19965
|
-
}));
|
|
20113
|
+
})));
|
|
19966
20114
|
|
|
19967
20115
|
server.registerTool('draft_brand', {
|
|
19968
20116
|
title: 'Draft brand profile',
|
|
@@ -20101,7 +20249,7 @@ function memoryNoteVerdict(text) {
|
|
|
20101
20249
|
transcript: z.string().nullable().optional().describe('verbatim voiceover + on-screen text with a beat list (null when silent/unreachable)'),
|
|
20102
20250
|
words: z.array(z.any()).optional(), anchors: z.array(z.any()).optional(), cues: z.array(z.any()).optional(),
|
|
20103
20251
|
},
|
|
20104
|
-
annotations: { readOnlyHint:
|
|
20252
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
20105
20253
|
}, wrap(async ({ url, frames, words, anchors }) => {
|
|
20106
20254
|
const wantWords = words === true || words === 'only' || anchors != null;
|
|
20107
20255
|
const wr = wantWords ? apiPost('/api/video/words', { url, ...(anchors ? { anchors } : {}) }).then(r => ({ r }), e => ({ e })) : null;
|
|
@@ -20170,7 +20318,7 @@ function memoryNoteVerdict(text) {
|
|
|
20170
20318
|
top_fix: z.string().optional().describe('the single biggest improvement lever'),
|
|
20171
20319
|
strengths: z.any().optional().describe('what the ad already does well'),
|
|
20172
20320
|
},
|
|
20173
|
-
annotations: { readOnlyHint:
|
|
20321
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
20174
20322
|
}, wrap(async ({ url, kind = 'image', intent = '' }) => {
|
|
20175
20323
|
const d = await apiPost('/api/score/ad', { url, kind, intent, format: kind });
|
|
20176
20324
|
if (!d) return ok('Could not score that ad.');
|
|
@@ -20472,7 +20620,7 @@ function memoryNoteVerdict(text) {
|
|
|
20472
20620
|
teardown: z.any().optional().describe('the playbook — hook_taxonomy, campaigns, white_space, counter_plays, not_saying'),
|
|
20473
20621
|
adCount: z.number().optional().describe('how many ads were analyzed'),
|
|
20474
20622
|
},
|
|
20475
|
-
annotations: { readOnlyHint:
|
|
20623
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20476
20624
|
}, wrap(async ({ competitor, ads, language }) => {
|
|
20477
20625
|
const name = String(competitor?.name || '').trim();
|
|
20478
20626
|
if (!name) throw new Error('competitor.name is required.');
|
|
@@ -20508,7 +20656,7 @@ function memoryNoteVerdict(text) {
|
|
|
20508
20656
|
findings: z.array(z.any()).optional().describe('flagged issues ({severity, issue, policy_quote, fix_suggestion, where_in_ad})'),
|
|
20509
20657
|
anchors: z.array(z.any()).optional().describe('the Meta policy pages consulted ({url, …})'),
|
|
20510
20658
|
},
|
|
20511
|
-
annotations: { readOnlyHint:
|
|
20659
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
20512
20660
|
}, wrap(async ({ copy, claims, category, imageDescription }) => {
|
|
20513
20661
|
const d = await apiPost('/api/policy/check', { copy, claims: claims || '', category: category || '', imageDescription: imageDescription || '' });
|
|
20514
20662
|
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');
|
|
@@ -20582,7 +20730,7 @@ function memoryNoteVerdict(text) {
|
|
|
20582
20730
|
},
|
|
20583
20731
|
// NOT read-only (2026-09-30, OpenAI's plugin scan): passing brandId switches this key's ACTIVE BRAND for every later
|
|
20584
20732
|
// call, exactly like use_brand (activeBrand pins it server-side). That is a persisted, user-visible state change.
|
|
20585
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint:
|
|
20733
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20586
20734
|
}, wrap(async ({ brandId, reviews, reviewsUrl, useOwnReviewsOnly }) => {
|
|
20587
20735
|
const brand = await activeBrand(brandId);
|
|
20588
20736
|
const own = {};
|
|
@@ -20644,7 +20792,7 @@ function memoryNoteVerdict(text) {
|
|
|
20644
20792
|
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'),
|
|
20645
20793
|
saveError: z.string().nullable().optional().describe('set when the photo passed the check but could NOT be saved to the brand'),
|
|
20646
20794
|
},
|
|
20647
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
20795
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20648
20796
|
}, wrap(async ({ imageUrl, source_note, brandId }) => {
|
|
20649
20797
|
const brand = await activeBrand(brandId);
|
|
20650
20798
|
const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
|