hermoso 0.1.160 → 0.1.162
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 +55 -11
- package/mcp/client.mjs +25 -0
- package/mcp/hermoso-mcp.mjs +9 -2
- package/mcp/http.mjs +21 -4
- package/mcp/roster-scope.mjs +125 -0
- package/mcp/tools.mjs +170 -51
- package/mcp/well-formed.mjs +1 -1
- package/package.json +2 -2
- package/skills/hermoso-ad-from-brand/SKILL.md +1 -1
- package/skills/hermoso-generate/SKILL.md +1 -1
- package/skills/hermoso-product-photoshoot/SKILL.md +1 -1
- package/skills/hermoso-research/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**718 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
@@ -32,14 +32,14 @@ Two shapes, and the right one is decided by **what your client can do**, not by
|
|
|
32
32
|
| **Runs in a browser** — Claude.ai, ChatGPT, Claude Desktop | the hosted connector `https://app.hermoso.ai/mcp` | It cannot spawn a local process, so a URL is the only shape it has. Nothing to install, no key to paste, and the full toolset arrives with your saved brand context. This is the right answer for these clients, not a lesser one. |
|
|
33
33
|
| **Can run a shell** — Claude Code, Cursor, Codex, Cline, OpenClaw, Hermes, your own scripts | the CLI, `npm install -g hermoso` | A tool manifest is loaded into every session whether or not a tool is called. A shell command costs nothing until it runs, and it reaches **every** tool rather than the default roster. |
|
|
34
34
|
|
|
35
|
-
**The measured difference** (2026-08-
|
|
35
|
+
**The measured difference** (2026-08-27, counted as real tool definitions rather than estimated from bytes):
|
|
36
36
|
|
|
37
37
|
| | tools in range | loaded per session |
|
|
38
38
|
| --- | --- | --- |
|
|
39
|
-
| Hosted connector, default roster |
|
|
40
|
-
| Hosted connector, `?tools=all` |
|
|
41
|
-
| stdio server (`npx -y hermoso mcp`) |
|
|
42
|
-
| **CLI** | **all
|
|
39
|
+
| Hosted connector, default roster | 306 | **181,713 tokens** |
|
|
40
|
+
| Hosted connector, `?tools=all` | 718 | **472,062 tokens** |
|
|
41
|
+
| stdio server (`npx -y hermoso mcp`) | 306 | **181,713 tokens** |
|
|
42
|
+
| **CLI** | **all 718** | **0** |
|
|
43
43
|
|
|
44
44
|
The CLI answers the same questions on demand instead, and only when asked:
|
|
45
45
|
|
|
@@ -50,7 +50,7 @@ npx -y hermoso call plan_ad --json '{"product":"…"}' # run it
|
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
So a terminal agent reaches its first call in roughly **3.4K tokens with the whole roster in range**, against
|
|
53
|
-
**
|
|
53
|
+
**182K for a fraction of it**. `tools` and `tools <name>` read a registry bundled in the package — no key, no
|
|
54
54
|
network, no sign-in — so an agent can browse the entire product before anyone signs in. Only `call` spends, and
|
|
55
55
|
only that needs `hermoso auth login` once.
|
|
56
56
|
|
|
@@ -64,6 +64,49 @@ calls into one area. `enable_tools({groups:['ads']})` turns campaign management
|
|
|
64
64
|
tools are then native — no shell quoting, structured results. One shell round trip beats loading a 221K-token
|
|
65
65
|
group for a single tool; the reverse is true once a session settles into that area.
|
|
66
66
|
|
|
67
|
+
## Your agent can sign itself up
|
|
68
|
+
|
|
69
|
+
An agent with no Hermoso account can provision one, get its own key, and be rendering ads in the same session.
|
|
70
|
+
No human at a browser, no ticket, no waiting.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# 1. Start a signup. This call takes no credential, because the credential is what it creates.
|
|
74
|
+
curl -sX POST https://app.hermoso.ai/v1/signup \
|
|
75
|
+
-H 'content-type: application/json' \
|
|
76
|
+
-d '{"plan":"pro","period":"mo"}'
|
|
77
|
+
# -> { "id": "cs_...", "checkout_url": "https://checkout.stripe.com/...", "claim_token": "hsc_..." }
|
|
78
|
+
|
|
79
|
+
# 2. Pay at checkout_url. Store claim_token first: it is returned only in that response.
|
|
80
|
+
|
|
81
|
+
# 3. Claim it. Poll until status is "ready".
|
|
82
|
+
curl -sX POST https://app.hermoso.ai/v1/signup/cs_.../claim \
|
|
83
|
+
-H 'content-type: application/json' \
|
|
84
|
+
-d '{"claim_token":"hsc_..."}'
|
|
85
|
+
# -> { "status": "ready", "api_key": "hmk_...", "credits": 3000 }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That `hmk_` key is the same credential everything else on this page takes: `/v1`, the MCP server, the CLI. Point
|
|
89
|
+
your client at it and the full surface is open.
|
|
90
|
+
|
|
91
|
+
**Paying is something a browser-capable agent can already do itself.** Checkout is Stripe's own hosted page, so
|
|
92
|
+
Claude in Chrome and clients like it complete it unattended today. Everything else is a one-click handoff: send
|
|
93
|
+
`checkout_url` to whoever holds the card. The same shape covers you later, once you are running: `buy_credits`
|
|
94
|
+
and `upgrade_plan` mint a ready-to-pay link for more credits or a bigger plan, and `billing_status` reads the
|
|
95
|
+
balance any time.
|
|
96
|
+
|
|
97
|
+
**The agentic path takes a paid plan.** Any of them. The free plan is there for a person signing up at
|
|
98
|
+
[app.hermoso.ai](https://app.hermoso.ai), and asking for it here returns a refusal that says so. Nothing is
|
|
99
|
+
created until the payment completes, so an unpaid signup leaves no account behind and charges nothing.
|
|
100
|
+
|
|
101
|
+
**One thing still wants a person, and it is worth knowing up front.** Connecting a social or ad account means an
|
|
102
|
+
OAuth consent screen, and a consent screen cannot be completed headlessly on any platform. `list_connectors`
|
|
103
|
+
shows what is already connected and what is not. Everything else runs with no browser at all: research,
|
|
104
|
+
generation, publishing to a channel that is already connected, campaign builds, reporting.
|
|
105
|
+
|
|
106
|
+
Full request and response shapes, plus every other endpoint, are in the OpenAPI document at
|
|
107
|
+
[app.hermoso.ai/openapi.json](https://app.hermoso.ai/openapi.json), served live from the same table that mounts
|
|
108
|
+
the routes.
|
|
109
|
+
|
|
67
110
|
## Instant: the hosted Claude.ai connector
|
|
68
111
|
|
|
69
112
|
Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
|
|
@@ -72,7 +115,8 @@ your Hermoso account, done — the full toolset with your saved brand context, b
|
|
|
72
115
|
## Quickstart for Claude Code (one line)
|
|
73
116
|
|
|
74
117
|
1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
|
|
75
|
-
same ones the web Studio uses.
|
|
118
|
+
same ones the web Studio uses. Or skip the browser entirely and let your agent sign itself up on a paid plan
|
|
119
|
+
with `POST /v1/signup` (above).
|
|
76
120
|
2. **Run one line.** Your browser opens once to sign in. Nothing to paste, and no key lands in `.claude.json`:
|
|
77
121
|
|
|
78
122
|
```bash
|
|
@@ -119,12 +163,12 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
119
163
|
|
|
120
164
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
121
165
|
|
|
122
|
-
### What the
|
|
166
|
+
### What the 718 tools cover
|
|
123
167
|
|
|
124
168
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
125
169
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
126
170
|
social (`search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`, `search_threads`);
|
|
127
|
-
`
|
|
171
|
+
`fetch_social_data`, `mine_angles`, `analyze_video`, `check_ad_policy`, `list_skills` / `get_skill`.
|
|
128
172
|
|
|
129
173
|
**Create** — `draft_brand` → `plan_ad` → `render_ad` (the Studio quality pipeline: composited text, clean speech,
|
|
130
174
|
music, brand end card), or `generate_image` / `generate_video` / `generate_avatar` (UGC creators + lip-sync).
|
|
@@ -221,7 +265,7 @@ Render jobs queue server-side and poll to completion, returning a served URL.
|
|
|
221
265
|
|
|
222
266
|
## 2. CLI — the token-cheap path for terminal agents
|
|
223
267
|
|
|
224
|
-
`bin/hermoso.mjs`
|
|
268
|
+
`bin/hermoso.mjs` exposes the full MCP toolset as subprocess commands, so an agent can shell out instead of carrying a
|
|
225
269
|
fat tool manifest.
|
|
226
270
|
|
|
227
271
|
```bash
|
package/mcp/client.mjs
CHANGED
|
@@ -177,6 +177,31 @@ export function forgetWorkspaceScope() {
|
|
|
177
177
|
const ctx = mcpCtx.getStore();
|
|
178
178
|
if (ctx) delete ctx._storeSuffix;
|
|
179
179
|
}
|
|
180
|
+
// ── WHICH PROVIDERS THIS WORKSPACE HAS ACTUALLY CONNECTED (2026-08-26) ───────────────────────────────────────────
|
|
181
|
+
// Read ONCE per session, at `initialize`, and handed to registerTools so the roster it advertises carries only
|
|
182
|
+
// tools the caller can actually use — see mcp/roster-scope.mjs for the law and applyToolGates for the seam.
|
|
183
|
+
//
|
|
184
|
+
// `/api/connectors/providers` and NOT `/api/connectors`: the full route does a live-token label backfill and a
|
|
185
|
+
// per-provider scope-drift read, i.e. outbound provider calls, and this sits on the handshake every client makes.
|
|
186
|
+
// The lean route answers from the store alone and resolves the workspace exactly the way the Studio's own
|
|
187
|
+
// connector read does (brand-shared from the owner's scope + personal from the caller's).
|
|
188
|
+
//
|
|
189
|
+
// NEVER THROWS, AND THAT IS THE WHOLE CONTRACT. A read that fails for any reason — an older server with no such
|
|
190
|
+
// route, a store blip, no bearer at all — returns `readOk:false`, which makes toolHeldBackByConnectors answer
|
|
191
|
+
// false for every tool and ships the FULL roster. A failed read must never be able to remove a paying customer's
|
|
192
|
+
// tools ([[failed-read-is-not-empty]]); the cost of being wrong in this direction is a slightly larger roster.
|
|
193
|
+
//
|
|
194
|
+
// DELIBERATELY NOT MEMOIZED. It is one call per session; caching it would be the one way a user who connects an
|
|
195
|
+
// account and reconnects their client still does not see the tools, and on the hosted twin a module-level cache
|
|
196
|
+
// would be a cross-tenant leak besides.
|
|
197
|
+
export async function connectedProviders() {
|
|
198
|
+
try {
|
|
199
|
+
const r = await apiGet('/api/connectors/providers');
|
|
200
|
+
const list = Array.isArray(r?.providers) ? r.providers : null;
|
|
201
|
+
if (!list) return { connected: new Set(), readOk: false }; // a shape we do not recognise is a failed read
|
|
202
|
+
return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true };
|
|
203
|
+
} catch { return { connected: new Set(), readOk: false }; }
|
|
204
|
+
}
|
|
180
205
|
// Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
|
|
181
206
|
// the server reads the raw body. Lets an agent post ARBITRARY user files (not just Hermoso renders).
|
|
182
207
|
export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
|
|
12
|
-
import { API_BASE } from './client.mjs';
|
|
12
|
+
import { API_BASE, connectedProviders } from './client.mjs';
|
|
13
13
|
|
|
14
14
|
// instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
|
|
15
15
|
// in tools.mjs, shared with the hosted connector (http.mjs), so every surface tells agents the same breadth.
|
|
@@ -27,7 +27,14 @@ const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
|
27
27
|
// people's configs today. Renaming a variable someone already set is how a working setup goes quiet.
|
|
28
28
|
const _scope = parseToolScope(process.env.HERMOSO_TOOLS || process.env.HEIST_TOOLS);
|
|
29
29
|
if (_scope.error) { console.error(`[hermoso-mcp] ${_scope.error}`); process.exit(1); }
|
|
30
|
-
|
|
30
|
+
// AND SCOPED TO WHAT THIS WORKSPACE HAS CONNECTED, not only to the groups asked for (2026-08-26). A tool bound to
|
|
31
|
+
// a provider nobody has connected can only answer `401 {connector:'<p>'}`, so listing it costs the caller context
|
|
32
|
+
// on every turn and buys them nothing — and a roster far past the 30-50 tool accuracy cliff is what makes a model
|
|
33
|
+
// pick the wrong tool. ONE read, here at startup; it NEVER throws and a failed read ships the FULL roster
|
|
34
|
+
// ([[failed-read-is-not-empty]]). Awaited before registerTools because the gate reads it at registration time —
|
|
35
|
+
// wiring it after would leave `readOk:false`, the gate would fail open, and the change would be silently inert.
|
|
36
|
+
const _conn = await connectedProviders();
|
|
37
|
+
registerTools(server, { only: _scope.groups, connectors: _conn });
|
|
31
38
|
|
|
32
39
|
const transport = new StdioServerTransport();
|
|
33
40
|
await server.connect(transport);
|
package/mcp/http.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
2
2
|
// REMOTE MCP CONNECTOR — DEFERRED. This is the Claude.ai "custom connector" surface (https://<host>/mcp):
|
|
3
|
-
// a Streamable-HTTP MCP transport + OAuth so any Claude.ai / Cursor user can connect Hermoso by URL and sign in
|
|
4
|
-
// exactly like Higgsfield's mcp.higgsfield.ai/mcp.
|
|
3
|
+
// a Streamable-HTTP MCP transport + OAuth so any Claude.ai / Cursor user can connect Hermoso by URL and sign in.
|
|
5
4
|
//
|
|
6
5
|
// It is written so the cloud step is a CONFIG FLIP, not a rewrite — but it is intentionally OFF and will REFUSE
|
|
7
6
|
// to mount until BOTH are true:
|
|
@@ -17,7 +16,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
17
16
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
17
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
18
|
import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
|
|
20
|
-
import { mcpCtx } from './client.mjs';
|
|
19
|
+
import { mcpCtx, connectedProviders } from './client.mjs';
|
|
21
20
|
|
|
22
21
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
23
22
|
// `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
|
|
@@ -209,6 +208,10 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
209
208
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
210
209
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
211
210
|
// only the authenticated path would leave both tools listed in the submission.
|
|
211
|
+
// NO `connectors` HERE, DELIBERATELY. There is no authorization on this request, so there is no workspace to
|
|
212
|
+
// scope to and nothing honest to read — and a registry crawler or an agent deciding whether to connect MUST
|
|
213
|
+
// see the real catalog, not a zero-connector one. registerTools treats an absent `connectors` exactly like a
|
|
214
|
+
// failed read: full roster. Do not "fix" this by reading the workspace off the request; it is forgeable.
|
|
212
215
|
registerTools(server, { only: scope?.groups, widgetHost: isWidgetHost(clientInfoOf(req.body), req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
213
216
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
214
217
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
@@ -247,8 +250,22 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
247
250
|
const scope = scopeFor(req, res);
|
|
248
251
|
if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
|
|
249
252
|
sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
|
|
253
|
+
// ── WHAT THIS CALLER CAN ACTUALLY USE (2026-08-26) ────────────────────────────────────────────────────────
|
|
254
|
+
// One free store read, on the ONE request per session that mints the roster, so the tools we advertise are
|
|
255
|
+
// the tools their workspace can call. A connector-bound tool for an unconnected provider can only answer
|
|
256
|
+
// `401 {connector:'<p>'}`; carrying it costs the caller context every turn and pushes the roster further
|
|
257
|
+
// past the 30-50 tool accuracy cliff. The MCP spec permits exactly this and nothing looser — the tool set
|
|
258
|
+
// "MAY vary by the authorization presented on the request … since credentials are per-request input, not
|
|
259
|
+
// connection state" (rev 2026-07-28, Tools ▸ Capabilities) — which is why it is keyed to the BEARER and not
|
|
260
|
+
// to the connection or to a query parameter.
|
|
261
|
+
//
|
|
262
|
+
// INSIDE `mcpCtx.run`, because that store is what puts this caller's token on the outbound /api call. Read
|
|
263
|
+
// it outside and it would go out unauthenticated, answer 401, and — correctly, by its own contract — fail
|
|
264
|
+
// OPEN with the full roster, so the whole change would be silently inert. Never throws; see
|
|
265
|
+
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
266
|
+
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
250
267
|
const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
251
|
-
registerTools(server, { only: scope.groups, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
268
|
+
registerTools(server, { only: scope.groups, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
252
269
|
const transport = new StreamableHTTPServerTransport({
|
|
253
270
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
254
271
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ── THE CONNECTOR SCOPE — ONE LAW, TWO SURFACES (2026-08-26) ─────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// WHICH CONNECTOR A TOOL NEEDS, and therefore whether carrying it in a roster buys the caller anything. Shipped
|
|
4
|
+
// first for the Studio chat (lib/studio-roster.mjs, which re-exports every symbol below), and lifted here so the
|
|
5
|
+
// MCP twins can apply the IDENTICAL decision. It lives in mcp/ rather than lib/ for one mechanical reason: the
|
|
6
|
+
// published npm package (cli/) ships `cli/mcp/**` and nothing else, so `./roster-scope.mjs` is the only path that
|
|
7
|
+
// resolves from BOTH mcp/tools.mjs and its byte-identical cli/mcp/tools.mjs twin.
|
|
8
|
+
//
|
|
9
|
+
// WHY GATE ON THE CONNECTION AT ALL. A connector-bound tool for a provider the account has not connected can only
|
|
10
|
+
// ever answer `401 {connector:'<p>'}` — "not connected has ONE shape". Carrying it buys the caller nothing, costs
|
|
11
|
+
// them context on every turn, and actively harms them: a roster many times past the 30–50 tool accuracy cliff is
|
|
12
|
+
// exactly what makes a model pick the wrong tool. The MCP spec (rev 2026-07-28, Tools ▸ Capabilities) blesses this
|
|
13
|
+
// precise shape and no other: the tool set "MUST NOT vary per-connection or as a side effect of other requests on
|
|
14
|
+
// the connection. The set MAY vary by the authorization presented on the request — for example, returning only the
|
|
15
|
+
// tools the caller's granted scopes permit — since credentials are per-request input, not connection state."
|
|
16
|
+
//
|
|
17
|
+
// FIVE SAFETY PROPERTIES, each mutation-tested, because the failure mode of getting this wrong is INVISIBLE — a
|
|
18
|
+
// silently missing tool reads as the model refusing, not as a bug:
|
|
19
|
+
//
|
|
20
|
+
// 1. **A failed connector read is never a refusal.** `readOk:false` holds back NOTHING. An unreadable store must
|
|
21
|
+
// never manufacture a capability loss ([[failed-read-is-not-empty]]).
|
|
22
|
+
// 2. **An UNMAPPED tool is never held back.** The rules below are an allow-list of things we can justify, not a
|
|
23
|
+
// classifier. Anything this module cannot confidently attribute to a connector stays in the roster — which is
|
|
24
|
+
// what makes adding a tool safe: tool N+1 keeps working, it just does not get the saving.
|
|
25
|
+
// 3. **Every provider named here must exist in the live connector registry.** A typo'd provider id would match
|
|
26
|
+
// nothing in `connected` and silently drop its whole family FOREVER, on every account. The check asserts the
|
|
27
|
+
// rule table against server.js's own CONNECTOR_INFO keys.
|
|
28
|
+
// 4. **Research is never gated** — see NEVER_GATE below, and note the measured result on the MCP roster: of the
|
|
29
|
+
// 18 tools in the `research` group, the `core` group and the `workspace` group, ZERO are connector-mapped.
|
|
30
|
+
// 5. **A tool we did not register is not ours to filter.** Both callers hand these functions OUR tool names only;
|
|
31
|
+
// a user's own MCP server may call something `search_youtube_transcripts` and it must never be touched.
|
|
32
|
+
//
|
|
33
|
+
// Pure by design so the checks RUN these functions rather than reading the source. NO IMPORTS: the cli twin is
|
|
34
|
+
// rsync'd into a published package that has no lib/ and no repo around it.
|
|
35
|
+
// RESEARCH IS NEVER GATED, AND THIS LIST IS THE REASON THE WHOLE CHANGE IS SAFE.
|
|
36
|
+
//
|
|
37
|
+
// The ad libraries and organic social search run on OUR OWN research key, not on the user's connection — a brand
|
|
38
|
+
// with nothing connected can and must still spy on its competitors' Meta ads. But their NAMES look exactly like
|
|
39
|
+
// connector tools: `search_meta_ads` contains `_meta_`, `search_youtube` contains `youtube`. Six of the nine would
|
|
40
|
+
// have been silently gated by the rules below, which would have broken the product's single most-used feature for
|
|
41
|
+
// every new account — the exact users this change exists to protect.
|
|
42
|
+
//
|
|
43
|
+
// DERIVED, NOT HAND-LISTED. `tools/studio-roster-check.mjs` asserts this set is a SUPERSET of server.js's own
|
|
44
|
+
// `SC_WINDOW_TOOLS` — the set the route already uses to decide which tools take a research-balance window.
|
|
45
|
+
// So a tenth research-backed tool added there fails the suite rather than quietly losing research for zero-connector
|
|
46
|
+
// accounts. Anything our own research key pays for, the user reaches without connecting anything.
|
|
47
|
+
//
|
|
48
|
+
// NOTE what is deliberately NOT here: `search_instagram_hashtag`, `instagram_profile`, `search_threads_keyword`,
|
|
49
|
+
// `discover_tiktok_creators`, `tiktok_creator_info`. Those read the PLATFORM's data through the USER'S token
|
|
50
|
+
// (Business Discovery, the Threads API, TikTok's Creator Marketplace) — free to us, but impossible without the
|
|
51
|
+
// connection, so gating them is correct.
|
|
52
|
+
export const NEVER_GATE = new Set([
|
|
53
|
+
'search_meta_ads', 'search_google_ads', 'search_linkedin_ads', 'search_tiktok',
|
|
54
|
+
'search_instagram', 'search_youtube', 'search_reddit', 'search_threads', 'fetch_social_data',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// Tool-name → connector provider. ORDERED: the first match wins, so the more specific pattern must come first.
|
|
58
|
+
//
|
|
59
|
+
// THE ORDER IS LOAD-BEARING AND IS THE EASIEST THING TO GET WRONG. Six platforms hold TWO independent connections —
|
|
60
|
+
// posting and ads are separate consents with separate tokens (CLAUDE.md records this explicitly for TikTok: "IT IS
|
|
61
|
+
// A SEPARATE CONNECTION FROM THE `tiktok` POSTING CONNECTOR", and the same split is real for X, Pinterest, Reddit,
|
|
62
|
+
// Snapchat and Microsoft). So `*_tiktok_ads_*` must be tested BEFORE the bare `tiktok` rule, or every ads tool
|
|
63
|
+
// would be gated on the posting connector and a user with TikTok Ads connected but not TikTok posting would lose
|
|
64
|
+
// the whole ads family they are paying for.
|
|
65
|
+
export const TOOL_PROVIDER_RULES = [
|
|
66
|
+
// ── ads platforms that are their OWN connection (must precede the posting rules below) ──
|
|
67
|
+
[/_tiktok_ads_|^tiktok_ads_/, 'tiktok_ads'],
|
|
68
|
+
[/_x_ads_|^x_ads_/, 'x_ads'],
|
|
69
|
+
[/_pinterest_ads_|^pinterest_ads_/, 'pinterest_ads'],
|
|
70
|
+
[/_reddit_ads_|^reddit_ads_/, 'reddit_ads'],
|
|
71
|
+
[/_snapchat_ads_|^snapchat_ads_/, 'snapchat_ads'],
|
|
72
|
+
[/_microsoft_ads_|^microsoft_ads_|_microsoft_merchant_/, 'microsoft_ads'],
|
|
73
|
+
[/_apple_ads_|^apple_ads_/, 'apple_ads'],
|
|
74
|
+
[/_openai_ads_|^openai_ads_/, 'openai_ads'],
|
|
75
|
+
// Google Ads owns Merchant Center + the Ads↔Analytics link (both are Google Ads API surfaces, not GA4 ones).
|
|
76
|
+
[/_google_ads_|^google_ads_|_merchant_|^merchant_|^list_merchant_|link_google_ads_to_analytics/, 'google_ads'],
|
|
77
|
+
// ── LinkedIn: ads and posting share ONE connection (the Advertising API grant carries w_organization_social —
|
|
78
|
+
// see [[linkedin-connector-live]]), so both families gate on the single `linkedin` provider. ──
|
|
79
|
+
[/linkedin/, 'linkedin'],
|
|
80
|
+
// ── Meta: ads management AND FB/IG posting are one connector ([[meta-integration]]). Threads is separate. ──
|
|
81
|
+
[/^threads_|_thread$|_threads_|^(list|search|reply_to|repost|delete|hide)_thread/, 'threads'],
|
|
82
|
+
[/_meta_|^meta_|_meta$|instagram|whatsapp/, 'meta'],
|
|
83
|
+
// ── analytics / measurement, each its own connection ──
|
|
84
|
+
[/_analytics_|^analytics_(realtime|report)$|analytics_compatibility|analytics_stream/, 'google_analytics'],
|
|
85
|
+
[/mixpanel/, 'mixpanel'],
|
|
86
|
+
[/tag_manager/, 'google_tag_manager'],
|
|
87
|
+
[/search_console/, 'google_search_console'],
|
|
88
|
+
[/bing_webmaster/, 'bing_webmaster'],
|
|
89
|
+
[/posthog/, 'posthog'],
|
|
90
|
+
[/amplitude/, 'amplitude'],
|
|
91
|
+
// ── posting-only channels ──
|
|
92
|
+
[/youtube/, 'youtube'],
|
|
93
|
+
[/google_business|business_location/, 'google_business'],
|
|
94
|
+
[/_drive_|^list_drive|^get_drive|^create_drive|^save_to_drive$|_doc$|^read_doc|^create_doc|^update_doc|^append_to_doc|sheet/, 'google_drive'],
|
|
95
|
+
[/onedrive/, 'microsoft_onedrive'],
|
|
96
|
+
[/bluesky/, 'bluesky'],
|
|
97
|
+
[/telegram/, 'telegram'],
|
|
98
|
+
[/^post_to_tiktok$|^tiktok_|_tiktok_/, 'tiktok'],
|
|
99
|
+
[/^post_to_x$|^delete_x_post$|^send_x_dm$|^list_x_dms$|^x_(mentions|post)/, 'x'],
|
|
100
|
+
[/pinterest/, 'pinterest'],
|
|
101
|
+
[/reddit/, 'reddit'],
|
|
102
|
+
[/snapchat/, 'snapchat'],
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
// The provider a tool needs, or null when this module cannot attribute it. null ⇒ NEVER dropped (property 2).
|
|
106
|
+
export function toolProvider(name) {
|
|
107
|
+
const n = String(name || '');
|
|
108
|
+
if (!n) return null;
|
|
109
|
+
if (NEVER_GATE.has(n)) return null; // research runs on our key — checked FIRST, before any pattern can claim it
|
|
110
|
+
for (const [re, provider] of TOOL_PROVIDER_RULES) if (re.test(n)) return provider;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// THE ONE DECISION, and the only one either surface is allowed to make. `conn` is `{connected, readOk}` — the
|
|
114
|
+
// shape both the Studio route's connector read and the MCP transports' `/api/connectors/providers` read produce.
|
|
115
|
+
//
|
|
116
|
+
// Returns TRUE only when we KNOW the read succeeded AND we can attribute the tool to a provider AND that provider
|
|
117
|
+
// is not connected. Every other answer is FALSE, i.e. keep it — which is properties 1 and 2 expressed as the
|
|
118
|
+
// default rather than as two branches somebody could forget to write.
|
|
119
|
+
export function toolHeldBackByConnectors(name, conn) {
|
|
120
|
+
if (!conn || !conn.readOk) return false; // property 1 — fail OPEN on an unreadable store
|
|
121
|
+
const p = toolProvider(name);
|
|
122
|
+
if (p === null) return false; // property 2 — unmapped is never held back
|
|
123
|
+
const on = conn.connected instanceof Set ? conn.connected : new Set(conn.connected || []);
|
|
124
|
+
return !on.has(p);
|
|
125
|
+
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -11,6 +11,10 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
11
11
|
// The UTF-16 well-formedness law — see wellFormedServer() below for why it is applied here, and
|
|
12
12
|
// ./well-formed.mjs for why that specifier is the only one that can work in a byte-identical twin.
|
|
13
13
|
import { wellFormedValue, wellFormedString } from './well-formed.mjs';
|
|
14
|
+
// WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
|
|
15
|
+
// re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
|
|
16
|
+
// twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
|
|
17
|
+
import { toolHeldBackByConnectors } from './roster-scope.mjs';
|
|
14
18
|
|
|
15
19
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
16
20
|
const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
|
|
@@ -147,7 +151,7 @@ export const CAPABILITY_MAP = [
|
|
|
147
151
|
'What Hermoso can do — the full agent surface (every tool below runs over this MCP):',
|
|
148
152
|
// SECOND LINE, deliberately: the map below is a menu, and a menu read as a sequence is the whole defect.
|
|
149
153
|
INDEPENDENCE,
|
|
150
|
-
'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) ·
|
|
154
|
+
'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) · fetch_social_data (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
|
|
151
155
|
'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here; each has its own brand, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
152
156
|
'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
|
|
153
157
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this brand).',
|
|
@@ -189,6 +193,19 @@ export const MCP_INSTRUCTIONS = [
|
|
|
189
193
|
'• RAW MODELS, prompt only: generate_image / generate_video with useBrand:false, generate_voice, generate_text, upload_file (any local or external file becomes a URL every publish, schedule and ad tool accepts).',
|
|
190
194
|
'• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_reddit, post_to_bluesky, post_to_telegram, post_to_google_business; schedule_post, list_scheduled, reschedule_post, cancel_scheduled; list_connectors, list_connector_accounts, set_connector_accounts.',
|
|
191
195
|
'• ADS on eleven platforms, their accounts and their money: create_meta_campaign / _adset / _ad, create_google_ads_campaign / _ad_group / _ad and the TikTok, LinkedIn, Pinterest, Reddit, Microsoft and OpenAI equivalents; meta_insights, google_ads_report and the per-platform reports. Everything is created PAUSED and read back before it is described.',
|
|
196
|
+
// ── YOUR ROSTER IS NOT THE PRODUCT (2026-08-26) ──────────────────────────────────────────────────────────────
|
|
197
|
+
// The roster is scoped to the accounts this workspace has connected, because a tool for an unconnected provider
|
|
198
|
+
// can only answer 401. That is a saving, and it has ONE failure mode, which this line exists to prevent: an
|
|
199
|
+
// agent reads its roster as the boundary of what exists and tells the user we do not support their platform
|
|
200
|
+
// ([[prompt-rosters-go-stale]] — our own prose has already refused shipped capability on five surfaces).
|
|
201
|
+
//
|
|
202
|
+
// TWO CONSTRAINTS DECIDE ITS POSITION AND ITS LENGTH, and both are real budgets someone else is paying.
|
|
203
|
+
// POSITION: immediately AFTER the ADS bullet, because some hosts truncate this string to ~2KB and all five areas
|
|
204
|
+
// plus "ADS on eleven platforms" must stay inside that window — put it above and it evicts the ads bullet, which
|
|
205
|
+
// causes the exact failure it exists to stop. LENGTH: §2d of agent-surface-guidance-check caps the whole string,
|
|
206
|
+
// so it says only what is NEW and points at nothing already named below — `list_connectors`, `Workspace ▸
|
|
207
|
+
// Connectors`, `?connect=<provider>` and the CLI escape hatch are all already in here, further down.
|
|
208
|
+
'A tool NOT in your roster is filtered out — that account is not connected here. Never say Hermoso lacks a platform.',
|
|
192
209
|
// SECOND LINE, before the capability map: those four jobs read as four STAGES, and by the time an agent has
|
|
193
210
|
// scrolled the map it has already decided Hermoso is a funnel it must enter at the top. See INDEPENDENCE above.
|
|
194
211
|
// NOT REPEATED HERE ANY MORE: the head above states independence in its own first sentence, and every byte
|
|
@@ -208,7 +225,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
208
225
|
// discoverable is the other half of the fix, so the reasons to call it are spelled out rather than merely permitted.
|
|
209
226
|
'ACT ON THE REQUEST, DO NOT SURVEY IT: when the user asks for something to be made, make it. generate_image, generate_video and render_ad all run with `model` omitted, and an unnamed render goes to the server’s own default model, which is a sound general-purpose pick, so there is nothing you have to look up before rendering. Call hermoso_capabilities (free) when you actually need what it holds: a specific model id, an exact credit cost, a model’s live durations / aspect ratios / resolutions, or whether a capability is enabled on this account. Reporting the model catalog back is never the answer to a request to create something.',
|
|
210
227
|
'Capability map:',
|
|
211
|
-
'• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads;
|
|
228
|
+
'• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
|
|
212
229
|
'• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
|
|
213
230
|
'• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
|
|
214
231
|
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here, each with its own brand, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
|
|
@@ -470,7 +487,7 @@ const HOOK_ATTR = {
|
|
|
470
487
|
hook: z.string().optional().describe('WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.'),
|
|
471
488
|
subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.'),
|
|
472
489
|
};
|
|
473
|
-
//
|
|
490
|
+
// The "Duration to boards" table in one line — fill every act to the model max, remainder LAST, and pull the
|
|
474
491
|
// deficit off the previous act when the remainder would fall under the provider floor (their own 18 -> 14+4). Mirrors
|
|
475
492
|
// hfClipDurations in acts-packing.mjs, which is what actually packs the render; here it only makes the refusal concrete.
|
|
476
493
|
const hfSplitHint = (total, max = VIDEO_SINGLE_CLIP_CEILING, min = 4) => {
|
|
@@ -1894,13 +1911,21 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
1894
1911
|
if (unknown.length) return { content: [{ type: 'text', text: `Unknown tool group${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Valid: ${TOOL_GROUP_NAMES.join(', ')} — or 'all'.` }], isError: true };
|
|
1895
1912
|
const active = ctx.enabledGroups, groupOf = ctx.groupOf, handles = ctx.handleOf;
|
|
1896
1913
|
const added = expand.filter((g) => !active.has(g));
|
|
1897
|
-
let n = 0;
|
|
1914
|
+
let n = 0, heldBack = 0;
|
|
1898
1915
|
for (const g of added) {
|
|
1899
1916
|
active.add(g);
|
|
1900
1917
|
for (const [name, grp] of Object.entries(groupOf)) {
|
|
1901
1918
|
if (grp !== g) continue;
|
|
1902
1919
|
const h = handles[name];
|
|
1903
|
-
if (h)
|
|
1920
|
+
if (!h) continue;
|
|
1921
|
+
// THE GROUP FLIP MUST NOT UNDO THE CONNECTOR GATE (2026-08-26). Enabling a group is a statement about SIZE —
|
|
1922
|
+
// "I am willing to carry these schemas" — not a claim that the workspace has connected eleven ad platforms.
|
|
1923
|
+
// A blind `h.enable()` here would have re-listed every tool applyToolGates had just held back, so the saving
|
|
1924
|
+
// would survive exactly until the first `enable_tools(['ads'])`. Counted, not silently skipped: the reply
|
|
1925
|
+
// says how many and why, because a group that turns on "8 tools" when the agent expected 240 with no
|
|
1926
|
+
// explanation is the [[prompt-rosters-go-stale]] failure — the agent concludes the capability is missing.
|
|
1927
|
+
if (toolHeldBackByConnectors(name, ctx.conn)) { heldBack++; continue; }
|
|
1928
|
+
try { h.enable(); n++; } catch {}
|
|
1904
1929
|
}
|
|
1905
1930
|
}
|
|
1906
1931
|
const enabled = TOOL_GROUP_NAMES.filter((g) => active.has(g));
|
|
@@ -1922,14 +1947,28 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
1922
1947
|
// NOT DELETED, deliberately. It still works on stdio and in the CLI, where the client re-lists, and deleting it
|
|
1923
1948
|
// there would remove the only in-session route to the two opt-in groups. Made honest, not removed.
|
|
1924
1949
|
const fixedRoster = hostRendersWidgets();
|
|
1950
|
+
// NEVER "that platform is not supported". The tools exist, are built and are live; they are simply not listed for
|
|
1951
|
+
// an account that has not connected the platform yet, because they could only answer 401. Say that, and say where
|
|
1952
|
+
// the one-click fix is — the failure this sentence prevents is an agent telling a user we cannot run their ads.
|
|
1953
|
+
const held = heldBack
|
|
1954
|
+
? ` ${heldBack} more tool${heldBack === 1 ? ' is' : 's are'} built and ready but not listed because their account is not connected in this workspace yet — Hermoso supports them all; connect the account under Workspace ▸ Connectors (https://app.hermoso.ai/?connect=<provider>, or list_connectors to see what is linked) and they appear.`
|
|
1955
|
+
: '';
|
|
1925
1956
|
const route = 'reconnect with `?tools=all` on the server URL, or run the `hermoso` CLI, which reaches every'
|
|
1926
1957
|
+ ' tool with no roster at all.';
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1958
|
+
// A CACHED CLIENT AND AN UNCONNECTED ACCOUNT ARE DIFFERENT DIAGNOSES, AND ONLY ONE OF THEM IS EVER TRUE HERE
|
|
1959
|
+
// (2026-08-26). The two branches below tell an agent that if the tools do not appear, its client cached the
|
|
1960
|
+
// roster — correct when we really did enable something. When nothing was enabled BECAUSE nothing is connected,
|
|
1961
|
+
// that sentence sends the agent to reconnect its client, which provably cannot help, and the reconnect it is
|
|
1962
|
+
// told to try will produce the identical roster. So the zero-added case answers with the real cause and the
|
|
1963
|
+
// real fix instead, and says the platforms exist. Same law as the read-back rule: report the measurement.
|
|
1964
|
+
const note = !added.length
|
|
1965
|
+
? `Already on: ${expand.join(', ')}. Nothing changed. Active groups: ${enabled.join(', ')}.`
|
|
1966
|
+
: (n === 0 && heldBack
|
|
1967
|
+
? `Switched on ${added.join(', ')} server-side, but nothing new is listed:${held} Active groups: ${enabled.join(', ')}.`
|
|
1968
|
+
: fixedRoster
|
|
1969
|
+
? `Switched on ${added.join(', ')} server-side — but THIS host fixed its tool list when the connection was made and will not pick up the ${n} new tool${n === 1 ? '' : 's'} until it reconnects, so do not expect to see them in this conversation. To use them, ${route}${held} Active groups: ${enabled.join(', ')}.`
|
|
1970
|
+
: `Switched on ${added.join(', ')} — ${n} more tool${n === 1 ? '' : 's'} are callable now. If they do not appear your client has cached its tool list, in which case ${route}${held} Active groups: ${enabled.join(', ')}.`);
|
|
1971
|
+
return ok(note, { enabled, added, toolsAdded: n, toolsAwaitingConnection: heldBack, note, rosterFixedForThisConnection: fixedRoster });
|
|
1933
1972
|
};
|
|
1934
1973
|
|
|
1935
1974
|
// The per-session scope every roster needs, whether it was built or replayed. ONE builder so the two paths cannot
|
|
@@ -1937,13 +1976,33 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
1937
1976
|
function newToolScope(opts) {
|
|
1938
1977
|
const asked = opts.only ? new Set(opts.only) : new Set(DEFAULT_TOOL_GROUPS);
|
|
1939
1978
|
asked.add('core'); // discovery/credits/billing/jobs must exist in EVERY roster or the connection is unusable
|
|
1940
|
-
|
|
1979
|
+
// `connectors` is `{connected:Set<provider>, readOk:boolean}` from the transport's own free read, or absent.
|
|
1980
|
+
// ABSENT AND `readOk:false` BEHAVE IDENTICALLY, and that is the fail-open law rather than a convenience:
|
|
1981
|
+
// toolHeldBackByConnectors answers false for both, so an unreadable store, an anonymous discovery handshake and
|
|
1982
|
+
// a transport that has not been taught to read yet all advertise the FULL roster ([[failed-read-is-not-empty]]).
|
|
1983
|
+
return {
|
|
1984
|
+
enabledGroups: asked, groupOf: Object.create(null), handleOf: Object.create(null),
|
|
1985
|
+
conn: opts.connectors || null,
|
|
1986
|
+
widgetHost: !!opts.widgetHost,
|
|
1987
|
+
};
|
|
1941
1988
|
}
|
|
1942
|
-
//
|
|
1989
|
+
// THE THREE REASONS A REGISTERED TOOL IS HELD BACK. Applied identically on the build path, the replay path and
|
|
1990
|
+
// `enable_tools`, from ONE function, because a gate applied at two of the three is a gate a group flip undoes.
|
|
1991
|
+
//
|
|
1992
|
+
// (3) IS THE CONNECTOR GATE (2026-08-26). A connector-bound tool for a provider this workspace has not connected
|
|
1993
|
+
// can only ever answer `401 {connector:'<p>'}`, so carrying it buys the caller nothing and costs them context on
|
|
1994
|
+
// every turn — and a roster many times past the 30–50 tool accuracy cliff is what makes a model pick the wrong
|
|
1995
|
+
// tool. The MCP spec (rev 2026-07-28, Tools ▸ Capabilities) permits exactly this and nothing looser: the set
|
|
1996
|
+
// "MUST NOT vary per-connection or as a side effect of other requests on the connection. The set MAY vary by the
|
|
1997
|
+
// authorization presented on the request … since credentials are per-request input, not connection state."
|
|
1998
|
+
// DISABLED, NEVER SKIPPED, like the other two — the handler still exists, so connecting the account and
|
|
1999
|
+
// reconnecting reveals it with no code path of its own, and `tools/call` on it still answers its real 401
|
|
2000
|
+
// rather than the SDK's "unknown tool".
|
|
1943
2001
|
function applyToolGates(h, name, group, ctx, opts) {
|
|
1944
2002
|
if (!h) return;
|
|
1945
2003
|
if (!ctx.enabledGroups.has(group)) { try { h.disable(); } catch {} }
|
|
1946
2004
|
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && opts.widgetHost) { try { h.disable(); } catch {} }
|
|
2005
|
+
if (toolHeldBackByConnectors(name, ctx.conn)) { try { h.disable(); } catch {} }
|
|
1947
2006
|
}
|
|
1948
2007
|
|
|
1949
2008
|
// Replay the cached canon onto a fresh server. This is the whole hot path for every session after the first.
|
|
@@ -1970,7 +2029,7 @@ function replayTools(rawServer, opts, canon) {
|
|
|
1970
2029
|
// THEIR key — with no recourse on their side and, because it never touches our error ledger, no visibility on ours.
|
|
1971
2030
|
// The same tool answers the same way on every retry, so it is a deterministic loop wearing a transient's clothes.
|
|
1972
2031
|
//
|
|
1973
|
-
// AND WE ARE THE PRODUCER, NOT THE VENDOR. 14,579 live
|
|
2032
|
+
// AND WE ARE THE PRODUCER, NOT THE VENDOR. 14,579 live upstream strings held 570 real surrogate PAIRS and
|
|
1974
2033
|
// ZERO lone ones; `clip()` manufactures them by truncating at a CHARACTER COUNT (server.js now truncates on a code
|
|
1975
2034
|
// POINT boundary, which removes the source — this stays as the boundary that catches everything else, including
|
|
1976
2035
|
// the 1,166 other numeric `.slice(0, N)` sites and any vendor that hands us pre-broken text).
|
|
@@ -2122,7 +2181,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2122
2181
|
const h = t.registerTool(name, defForHost(name, finalDef, opts.widgetHost), handler);
|
|
2123
2182
|
handleOf[name] = h;
|
|
2124
2183
|
// DISABLED, NOT SKIPPED — see (1) above. `disable()` is the SDK's own call and removes it from tools/list.
|
|
2125
|
-
|
|
2184
|
+
//
|
|
2185
|
+
// ONE GATE FUNCTION, NOT A SECOND COPY OF THE RULES (2026-08-26). This branch and the widget branch below
|
|
2186
|
+
// used to be spelled out here AND inside applyToolGates, which replayTools calls — two implementations of
|
|
2187
|
+
// the same law, on the two paths a session can take. That is how the build path and the replay path come to
|
|
2188
|
+
// disagree, and it is invisible when they do: the first session in a process takes one, every session after
|
|
2189
|
+
// it takes the other. The third (connector) gate would have had to be written twice for the same reason.
|
|
2190
|
+
applyToolGates(h, name, group, ctx, opts);
|
|
2126
2191
|
// WITHHELD FROM ONE HOST, for that host's rules rather than ours (2026-08-23).
|
|
2127
2192
|
// OpenAI's plugin policy permits commerce only in PHYSICAL goods: "selling digital products or services,
|
|
2128
2193
|
// including subscriptions, digital content, tokens, or credits, is not allowed." buy_credits hands back a
|
|
@@ -2141,7 +2206,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2141
2206
|
// rather than only its `enabled:true` branch, because a tool whose declared schema can arm a charge is what a
|
|
2142
2207
|
// commerce reviewer reads, not the branch it happens to take; turning auto-reload OFF stays available in the
|
|
2143
2208
|
// app and on every other surface.
|
|
2144
|
-
|
|
2209
|
+
// (The disable itself now lives in applyToolGates above, with the other two gates. This comment stays HERE
|
|
2210
|
+
// because it explains WHICH tools are in WITHHELD_FROM_WIDGET_HOSTS and why, which is the part that has to
|
|
2211
|
+
// be read next to the roster rather than next to the mechanism.)
|
|
2145
2212
|
return h;
|
|
2146
2213
|
};
|
|
2147
2214
|
const v = Reflect.get(t, p);
|
|
@@ -3597,6 +3664,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3597
3664
|
poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — attach a poll: {options:["…","…"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post.'),
|
|
3598
3665
|
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply. Omit for everyone, which is the right default for a brand post.'),
|
|
3599
3666
|
madeWithAi: z.boolean().optional().describe('X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf.'),
|
|
3667
|
+
// THE THREE X FIELDS `xPost` HAS ACCEPTED SINCE THE DAY THEY LANDED AND NOTHING COULD SCHEDULE (2026-08-26).
|
|
3668
|
+
// Same publisher-can/scheduler-cannot shape as SCHED_ID_FIELDS and the five Threads options one channel over:
|
|
3669
|
+
// reachable when you publish NOW, unreachable when you schedule, and invisible until the parity sweep named it.
|
|
3670
|
+
xQuotePostId: z.string().optional().describe('X — the numeric id of an X post this one QUOTES: the last part of its URL. X renders that post inside yours and it stands alone on your own timeline, which is what makes a quote different from a reply. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS — a schedule going to both channels would otherwise be silently ambiguous. Billed at X’s higher LINK rate, because X appends the quoted post’s t.co URL to yours.'),
|
|
3671
|
+
communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
|
|
3672
|
+
paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP, the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.'),
|
|
3600
3673
|
collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
|
|
3601
3674
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first, 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.'),
|
|
3602
3675
|
// ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
|
|
@@ -3687,6 +3760,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3687
3760
|
poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — replaces the poll; an empty options list removes it.'),
|
|
3688
3761
|
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply; "" goes back to everyone.'),
|
|
3689
3762
|
madeWithAi: z.boolean().optional().describe('X — the AI-media label; false turns it off.'),
|
|
3763
|
+
xQuotePostId: z.string().optional().describe('X — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule.'),
|
|
3764
|
+
communityId: z.string().optional().describe('X — the community to publish into; an empty string goes back to the main timeline.'),
|
|
3765
|
+
paidPartnership: z.boolean().optional().describe('X — the paid-partnership label; false turns it off.'),
|
|
3690
3766
|
collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet \u2014 an invite already sent cannot be withdrawn from here.'),
|
|
3691
3767
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE', '']).optional().describe('INSTAGRAM \u2014 replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued \u2014 a Reel already published cannot be converted into a trial.'),
|
|
3692
3768
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
@@ -4042,14 +4118,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4042
4118
|
paginationToken: z.string().optional().describe('nextToken from a previous call, to page further back. There is no since/until filter at X — this is the only way to walk history.'),
|
|
4043
4119
|
eventTypes: z.array(z.string()).optional().describe('MessageCreate (default), ParticipantsJoin, ParticipantsLeave. The join/leave events carry no message and are billed like any other, so the default is messages only.'),
|
|
4044
4120
|
},
|
|
4045
|
-
outputSchema: { account: z.string().optional(), count: z.number().optional(), nextToken: z.string().nullable().optional(), costCredits: z.number().optional(), historyNote: z.string().optional(), events: z.array(z.any()).optional(), conversations: z.array(z.any()).optional() },
|
|
4121
|
+
outputSchema: { account: z.string().optional(), count: z.number().optional(), nextToken: z.string().nullable().optional(), costCredits: z.number().optional(), historyNote: z.string().optional(), events: z.array(z.any()).optional(), conversations: z.array(z.any()).optional(), emptyReason: z.string().optional(), emptyNote: z.string().optional(), xLooked: z.boolean().nullable().optional(), xErrors: z.array(z.any()).optional() },
|
|
4046
4122
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
4047
4123
|
}, wrap(async (a) => {
|
|
4048
4124
|
const d = await apiGet('/api/x/dms', { maxResults: a.maxResults, conversationId: a.conversationId, participantId: a.participantId, paginationToken: a.paginationToken, eventTypes: (a.eventTypes || []).join(',') });
|
|
4049
4125
|
const cost = `Cost ${d.costCredits ?? '?'} credits.`;
|
|
4050
4126
|
// AN EMPTY READ IS REPORTED WITH THE WINDOW, NOT AS SILENCE. "No DMs" and "no DMs in the 30 days X will serve"
|
|
4051
4127
|
// are different facts, and only one of them is something we actually know.
|
|
4052
|
-
|
|
4128
|
+
// AN EMPTY READ NOW SAYS WHICH KIND OF EMPTY IT WAS. X can answer 200 with a top-level errors[] and no data
|
|
4129
|
+
// \u2014 a resource this token may not see, an app whose X permission level excludes Direct Messages \u2014 and for a
|
|
4130
|
+
// day that was reported as an empty inbox on an account with a DM in it. `emptyNote` names the three cases
|
|
4131
|
+
// apart; it is absent when events came back, so a normal answer is unchanged.
|
|
4132
|
+
if (!d.count) return ok(`No direct messages came back for ${d.account || 'that account'}. ${d.emptyNote || ''} ${d.historyNote || ''} ${cost}`.replace(/\s+/g, ' ').trim(), d);
|
|
4053
4133
|
const rows = (d.conversations || []).map((c) => `${c.needsReply ? '•' : '✓'} ${c.with || 'someone'} (${c.conversationId}) — ${String(c.lastMessage || '').replace(/\s+/g, ' ').slice(0, 160)}${c.needsReply ? ' ← waiting on a reply' : ' (you replied last)'}`);
|
|
4054
4134
|
return ok(`${d.count} message${d.count === 1 ? '' : 's'} across ${rows.length} conversation${rows.length === 1 ? '' : 's'} for ${d.account}. ${cost}\n${rows.join('\n')}\n\n${d.historyNote || ''}`.trim(), d);
|
|
4055
4135
|
}));
|
|
@@ -5026,7 +5106,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5026
5106
|
}));
|
|
5027
5107
|
server.registerTool('list_bluesky_convos', {
|
|
5028
5108
|
title: 'List Bluesky direct-message conversations',
|
|
5029
|
-
description: 'Read the connected Bluesky account\u2019s DM conversations \u2014 who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no
|
|
5109
|
+
description: 'Read the connected Bluesky account\u2019s DM conversations \u2014 who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no credits, no vendor approval: AT Protocol app passwords are not scoped. It DOES need a PRIVILEGED app password \u2014 one created with direct-message access ticked \u2014 and says so precisely if the saved one cannot chat, which is a property of the password and NOT a broken connection. Filters: readState "unread", status "request" or "accepted", kind "direct" or "group". An unknown filter value is refused by name, never silently dropped.',
|
|
5030
5110
|
inputSchema: {
|
|
5031
5111
|
limit: z.number().optional().describe('how many conversations, 1\u2013100 (default 25)'),
|
|
5032
5112
|
cursor: z.string().optional().describe('walk further back \u2014 pass the cursor from a previous call'),
|
|
@@ -5064,25 +5144,64 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5064
5144
|
to: z.string().optional().describe('the recipient\u2019s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given.'),
|
|
5065
5145
|
text: z.string().describe('the message, up to 1000 characters'),
|
|
5066
5146
|
replyToMessageId: z.string().optional().describe('reply to a specific message in the conversation'),
|
|
5147
|
+
dryRun: z.boolean().optional().describe('CHECK FIRST, SEND NOTHING. Asks Bluesky whether a DM to `to` would be delivered at all and returns canChat plus the reason when it would not \u2014 that account has DMs off, only accepts them from people it follows, has blocked this one, is suspended, or does not exist. Free, and the message is NOT sent. Use it before writing a long DM to someone who has never been messaged, so a refusal costs nothing instead of arriving after the words were written. `text` is still validated for length so a dry run cannot pass on a message the real send would refuse.'),
|
|
5067
5148
|
},
|
|
5068
|
-
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), sentAt: z.string().optional(), text: z.string().optional(), to: z.string().optional(), delivered: z.boolean().optional() },
|
|
5149
|
+
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), sentAt: z.string().optional(), text: z.string().optional(), to: z.string().optional(), delivered: z.boolean().optional(), dryRun: z.boolean().optional(), sent: z.boolean().optional(), canChat: z.boolean().optional(), reason: z.string().optional(), note: z.string().optional() },
|
|
5069
5150
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
5070
5151
|
}, wrap(async (a) => {
|
|
5071
5152
|
const d = await apiPost('/api/bluesky/dm', a);
|
|
5153
|
+
// A DRY RUN MUST NEVER RENDER AS A SEND. The send sentence below reads "Sent the DM ..." and would be a
|
|
5154
|
+
// plain falsehood about a call that deliberately delivered nothing.
|
|
5155
|
+
if (d.dryRun) return ok(d.note || `Pre-flight only \u2014 nothing was sent. canChat: ${d.canChat === true}.`, d);
|
|
5072
5156
|
return ok(`${d.delivered ? 'Sent' : 'Bluesky accepted but returned no message id for'} the DM${d.to ? ` to ${d.to}` : ''} \u2014 conversation ${d.convoId}${d.sentAt ? `, recorded at ${d.sentAt}` : ''}.\n\u201c${d.text}\u201d`, d);
|
|
5073
5157
|
}));
|
|
5074
5158
|
server.registerTool('mark_bluesky_convo_read', {
|
|
5075
|
-
title: 'Mark
|
|
5076
|
-
description: 'Clear the unread count on
|
|
5159
|
+
title: 'Mark Bluesky conversations as read',
|
|
5160
|
+
description: 'Clear the unread count on Bluesky DMs \u2014 ONE conversation, or the WHOLE ACCOUNT when convoId is omitted (Bluesky\u2019s own mark-all, narrowable with status to just the message requests or just the accepted threads). Useful after triaging an inbox so the next list_bluesky_convos does not surface the same threads again. Reports the unread count \u2014 or the number of conversations \u2014 Bluesky reads back, not the one requested. Free.',
|
|
5077
5161
|
inputSchema: {
|
|
5078
|
-
convoId: z.string().describe('from list_bluesky_convos'),
|
|
5162
|
+
convoId: z.string().optional().describe('from list_bluesky_convos. OMIT to mark EVERY conversation on the account read.'),
|
|
5079
5163
|
messageId: z.string().optional().describe('mark read only up to this message; omit to clear the whole conversation'),
|
|
5164
|
+
status: z.enum(['request', 'accepted']).optional().describe('mark-all only: narrow it to just the message requests, or just the accepted conversations. Omit to clear both. An unknown value is refused rather than dropped \u2014 a dropped one would clear everything when you asked to clear only the requests.'),
|
|
5080
5165
|
},
|
|
5081
|
-
outputSchema: { convoId: z.string().optional(), unread: z.number().optional(), with: z.string().optional() },
|
|
5166
|
+
outputSchema: { convoId: z.string().optional(), unread: z.number().optional(), with: z.string().optional(), all: z.boolean().optional(), status: z.string().optional(), updatedCount: z.number().nullable().optional(), note: z.string().optional() },
|
|
5082
5167
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
5083
5168
|
}, wrap(async (a) => {
|
|
5084
5169
|
const d = await apiPost('/api/bluesky/mark-read', a);
|
|
5085
|
-
return ok(`Conversation with ${d.with || d.convoId} now reads ${d.unread} unread.`, d);
|
|
5170
|
+
return ok(d.all ? (d.note || 'Marked read.') : `Conversation with ${d.with || d.convoId} now reads ${d.unread} unread.`, d);
|
|
5171
|
+
}));
|
|
5172
|
+
// \u2500\u2500 THE CONVERSATION ACTIONS (2026-08-26) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5173
|
+
// We LISTED a conversation as a message REQUEST and offered no way to accept it, and reported it as MUTED with
|
|
5174
|
+
// no way to mute or unmute it \u2014 a state drawn on the screen with no handle behind it. Seven lexicons behind
|
|
5175
|
+
// ONE tool rather than seven tools: this roster is past 700 and 30-50 is where instruction-following starts to
|
|
5176
|
+
// degrade, and the repo already spells this pattern manage_meta_post / manage_linkedin_post.
|
|
5177
|
+
server.registerTool('manage_bluesky_convo', {
|
|
5178
|
+
title: 'Accept, mute, lock, leave or prune a Bluesky DM conversation',
|
|
5179
|
+
description: 'Act on one Bluesky DM conversation. ACCEPT a message request \u2014 Bluesky holds DMs from people the account does not follow in a separate requests folder, and until one is accepted it stays there. MUTE / UNMUTE it. LOCK / UNLOCK it (no new messages). LEAVE it. Or DELETE one message from THIS account\u2019s view. TWO THINGS TO SAY OUT LOUD BEFORE USING THEM: deleting is FOR SELF ONLY \u2014 the other person still sees the message, because Bluesky offers no delete-for-everyone in chat \u2014 and leaving a conversation cannot be undone from here. Accepting a request that was already accepted is reported as such rather than as a change, because Bluesky says so by returning no revision. Free, and it needs the same PRIVILEGED app password as every other Bluesky DM tool. Get a convoId from list_bluesky_convos.',
|
|
5180
|
+
inputSchema: {
|
|
5181
|
+
action: z.enum(['accept', 'mute', 'unmute', 'lock', 'unlock', 'leave', 'deleteMessage']).describe('what to do to the conversation'),
|
|
5182
|
+
convoId: z.string().describe('from list_bluesky_convos'),
|
|
5183
|
+
messageId: z.string().optional().describe('required for deleteMessage \u2014 from read_bluesky_dm'),
|
|
5184
|
+
},
|
|
5185
|
+
outputSchema: { action: z.string().optional(), convoId: z.string().optional(), messageId: z.string().optional(), accepted: z.boolean().optional(), alreadyAccepted: z.boolean().optional(), left: z.boolean().optional(), deletedForSelf: z.boolean().optional(), muted: z.boolean().optional(), unread: z.number().optional(), with: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
5186
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
5187
|
+
}, wrap(async (a) => {
|
|
5188
|
+
const d = await apiPost('/api/bluesky/convo-action', a);
|
|
5189
|
+
return ok(d.note || `${a.action} done on ${a.convoId}.`, d);
|
|
5190
|
+
}));
|
|
5191
|
+
server.registerTool('react_to_bluesky_dm', {
|
|
5192
|
+
title: 'React to a Bluesky direct message',
|
|
5193
|
+
description: 'Add or remove an emoji reaction on one message in a Bluesky DM \u2014 the light acknowledgement that does not need a written reply, and the thing to reach for when someone says \u201cthanks\u201d and a paragraph back would be worse than a thumbs-up. A REACTION IS EXACTLY ONE EMOJI: that is Bluesky\u2019s own rule, so a word or a phrase is refused here rather than by the service (where it comes back as an opaque ReactionInvalidValue). Reports the reactions Bluesky reads the message back carrying, not the one requested. Free.',
|
|
5194
|
+
inputSchema: {
|
|
5195
|
+
convoId: z.string().describe('from list_bluesky_convos'),
|
|
5196
|
+
messageId: z.string().describe('from read_bluesky_dm'),
|
|
5197
|
+
value: z.string().describe('exactly one emoji, e.g. \uD83D\uDC4D'),
|
|
5198
|
+
remove: z.boolean().optional().describe('true to take the reaction off instead of putting it on'),
|
|
5199
|
+
},
|
|
5200
|
+
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), value: z.string().optional(), removed: z.boolean().optional(), reactions: z.array(z.string()).optional(), text: z.string().optional(), note: z.string().optional() },
|
|
5201
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
5202
|
+
}, wrap(async (a) => {
|
|
5203
|
+
const d = await apiPost('/api/bluesky/react', a);
|
|
5204
|
+
return ok(d.note || `Reaction ${a.value} ${a.remove ? 'removed' : 'added'}.`, d);
|
|
5086
5205
|
}));
|
|
5087
5206
|
server.registerTool('tiktok_creator_info', {
|
|
5088
5207
|
title: 'Read the connected TikTok creator’s posting options',
|
|
@@ -14018,7 +14137,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14018
14137
|
server.group('create');
|
|
14019
14138
|
server.registerTool('plan_ad', {
|
|
14020
14139
|
title: 'Plan an ad concept',
|
|
14021
|
-
description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends
|
|
14140
|
+
description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends credits.',
|
|
14022
14141
|
inputSchema: {
|
|
14023
14142
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
|
|
14024
14143
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
@@ -14421,11 +14540,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14421
14540
|
|
|
14422
14541
|
server.registerTool('make_explainer', {
|
|
14423
14542
|
title: 'Make an explainer video',
|
|
14424
|
-
description: "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE
|
|
14543
|
+
description: "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE — a slow move on a still shimmers), then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds narration-timed CAPS (see both). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
|
|
14425
14544
|
inputSchema: {
|
|
14426
14545
|
topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
|
|
14427
14546
|
durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
|
|
14428
|
-
frameDensity: z.enum(['standard', 'lean', 'minimal']).optional().describe("how many pictures per second of narration, and therefore what it costs. 'standard' (default) is a frame about every 1.5s — the density
|
|
14547
|
+
frameDensity: z.enum(['standard', 'lean', 'minimal']).optional().describe("how many pictures per second of narration, and therefore what it costs. 'standard' (default) is a frame about every 1.5s — the density a stills film needs to read as a film rather than a slideshow; 'lean' is one about every 2.5s (the longest hold that still reads as a film, ~40% of the frames and ~40% of the cost); 'minimal' is ONE picture per narration section, which is cheapest and is frankly a slideshow. Only drop below the default if the user asked for something cheaper."),
|
|
14429
14548
|
aspectRatio: z.enum(['9:16', '16:9', '1:1', '4:5', '3:4']).optional().describe("'9:16' default"),
|
|
14430
14549
|
style: z.enum(['cinematic', 'editorial_collage', 'flat_vector', 'stickman', 'whiteboard', 'ink_marker', 'silhouette', 'storybook', 'paper_diorama', 'isometric', 'claymation', 'pixel_art', 'watercolor', 'fluffy_toy', 'low_poly', 'stylized_3d', 'studio_3d', 'mannequin']).optional().describe("visual style. 'cinematic' (default) is photoreal; the rest are non-photoreal styled looks — editorial_collage (halftone cutouts + marker accents), flat_vector, stickman, whiteboard, ink_marker, silhouette, storybook (gouache), paper_diorama, isometric, claymation, pixel_art, watercolor, fluffy_toy (felted plush), low_poly, stylized_3d (matte clay render), studio_3d (preschool toy 3D on a white sweep — the Kids default), mannequin (clay-render reenactment figures — a History alternate). Ask the user which they want rather than picking silently; a styled pick costs more (see the cost note)."),
|
|
14431
14550
|
channel: z.enum(['explainer', 'history', 'kids', 'fairytale']).optional().describe("the CHANNEL TYPE — it sets the pacing, the narration register and the default look, and is orthogonal to `style` (a named style always wins): explainer (casual second-person, fast cuts), history (witty chronological retelling / documentary), kids (fastest, question-first, warm teacher), fairytale (slow, atmospheric myth or folklore). Default 'explainer'."),
|
|
@@ -14662,7 +14781,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14662
14781
|
return ok(wireText, { ...j, url });
|
|
14663
14782
|
}));
|
|
14664
14783
|
|
|
14665
|
-
// ---------- skills (
|
|
14784
|
+
// ---------- skills (workflows ship as SKILL.md bundles) ----------
|
|
14666
14785
|
server.group('create');
|
|
14667
14786
|
// The bundle dirs/content may still carry the pre-rename brand — always serve them under the product name.
|
|
14668
14787
|
const brandSkillText = (s) => String(s).replace(/HEIST_/g, 'HERMOSO_').replace(/heist-/g, 'hermoso-').replace(/Heist/g, 'Hermoso').replace(/\bheist\b/g, 'hermoso');
|
|
@@ -15536,7 +15655,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15536
15655
|
server.group('research');
|
|
15537
15656
|
server.registerTool('find_competitors', {
|
|
15538
15657
|
title: 'Find competitors',
|
|
15539
|
-
description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0
|
|
15658
|
+
description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 credits.",
|
|
15540
15659
|
inputSchema: {
|
|
15541
15660
|
domain: z.string().describe('the brand domain, e.g. flourish.com'),
|
|
15542
15661
|
mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
|
|
@@ -15555,7 +15674,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15555
15674
|
server.registerTool('pull_competitor_ads', {
|
|
15556
15675
|
_meta: openaiMeta(AD_SPY_URI, 'Pulling their live ads…', 'Competitor ads pulled'),
|
|
15557
15676
|
title: 'Pull competitor ads',
|
|
15558
|
-
description: 'THE FAST PATH for "show me the ads <brand> is running" \u2014 one named brand\u2019s real live ads from the META (Facebook/Instagram) ad library, deduped, sorted, with the right page resolved. A single call, back in a few seconds. Prefer this over research_ads whenever the brand is named. Meta only, deliberately: it has by far the richest creative and is what people mean by "their ads". For Google or LinkedIn specifically, use search_google_ads or search_linkedin_ads. Spends
|
|
15677
|
+
description: 'THE FAST PATH for "show me the ads <brand> is running" \u2014 one named brand\u2019s real live ads from the META (Facebook/Instagram) ad library, deduped, sorted, with the right page resolved. A single call, back in a few seconds. Prefer this over research_ads whenever the brand is named. Meta only, deliberately: it has by far the richest creative and is what people mean by "their ads". For Google or LinkedIn specifically, use search_google_ads or search_linkedin_ads. Spends credits.',
|
|
15559
15678
|
inputSchema: {
|
|
15560
15679
|
companyName: z.string().optional().describe('the advertiser name'),
|
|
15561
15680
|
domain: z.string().optional().describe('the advertiser domain'),
|
|
@@ -15631,7 +15750,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15631
15750
|
const cards = adSpyCards(rows);
|
|
15632
15751
|
const modelRows = widget ? stripAdMedia(rows) : rows;
|
|
15633
15752
|
// ON A WIDGET HOST, THE ANSWER IS THE CARD — SO STOP ASKING THE MODEL TO RE-TYPE IT (2026-08-24). Measured on
|
|
15634
|
-
// this exact path: our fan-out
|
|
15753
|
+
// this exact path: our fan-out is ~5s, and the user waits ~30. The rest is the model
|
|
15635
15754
|
// reading sixteen ad rows and then WRITING a bullet for every one of them, next to a card that is already
|
|
15636
15755
|
// showing all sixteen with their creative. That enumeration is the latency, it is duplicated effort, and we
|
|
15637
15756
|
// invite it by handing over the rows at all.
|
|
@@ -15663,7 +15782,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15663
15782
|
description: 'Set (or STOP) this workspace\'s standing COMPETITOR WATCH — the weekly job that re-checks each named brand\'s ad libraries and reports what is NEW since last time. The same watch the web app\'s Ad Spy ▸ Watching tab manages, and the same one the weekly digest email is sent from (turn that email on/off with update_settings({watchEmail})). '
|
|
15664
15783
|
+ 'This REPLACES the whole watched list, it does not add to it — pass every brand you want watched, every time. Max 5 brands (the server trims past that). Pass an EMPTY list to stop the watch entirely, which also clears the findings. '
|
|
15665
15784
|
+ 'Give a `domain` wherever you know one: Google Ads Transparency is looked up BY DOMAIN and is skipped for a brand without one, and the domain is what resolves the right Meta page for a brand with an ambiguous name. '
|
|
15666
|
-
+ 'The run itself spends
|
|
15785
|
+
+ 'The run itself spends credits against the ad libraries (roughly 3 per brand on Meta, 1 each on Google and LinkedIn) and is hard-capped per run server-side, so an oversized watch is trimmed rather than allowed to run away. Setting the list is free; only a run spends. '
|
|
15667
15786
|
+ 'runNow:true runs it once IMMEDIATELY (a background job — it spends now) and then keeps the weekly cadence; leave it off and the first check is a week out. '
|
|
15668
15787
|
+ 'The country and the platform mix are NOT settable here — a re-set inherits whatever the pending run already carried (US / Meta for a watch that has never been configured otherwise). Read the findings back with list_watch_findings.',
|
|
15669
15788
|
inputSchema: {
|
|
@@ -15764,7 +15883,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15764
15883
|
server.registerTool('research_ads', {
|
|
15765
15884
|
_meta: openaiMeta(AD_SPY_URI, 'Researching ads…', 'Ad research ready'),
|
|
15766
15885
|
title: 'Research ads',
|
|
15767
|
-
description: 'Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand\u2019s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads \u2014 both are a single call and return in a few seconds. Spends
|
|
15886
|
+
description: 'Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand\u2019s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads \u2014 both are a single call and return in a few seconds. Spends credits — an agentic loop, so a handful rather than the one-call cost of a targeted search.',
|
|
15768
15887
|
inputSchema: {
|
|
15769
15888
|
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
15770
15889
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object to tailor the research to; omit to use the workspace’s saved brand'),
|
|
@@ -15851,7 +15970,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15851
15970
|
server.registerTool('search_meta_ads', {
|
|
15852
15971
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Meta ads…', 'Found Meta ads'),
|
|
15853
15972
|
title: 'Search Meta ads',
|
|
15854
|
-
description: "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends
|
|
15973
|
+
description: "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends a credit or two.",
|
|
15855
15974
|
inputSchema: {
|
|
15856
15975
|
query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
|
|
15857
15976
|
companyName: z.string().optional().describe('one advertiser’s ads by brand name'),
|
|
@@ -15921,7 +16040,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15921
16040
|
server.registerTool('search_linkedin_ads', {
|
|
15922
16041
|
_meta: openaiMeta(AD_SPY_URI, 'Searching LinkedIn ads…', 'Found LinkedIn ads'),
|
|
15923
16042
|
title: 'Search LinkedIn ads',
|
|
15924
|
-
description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends
|
|
16043
|
+
description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends about a credit.",
|
|
15925
16044
|
inputSchema: {
|
|
15926
16045
|
company: z.string().optional().describe('advertiser company name'),
|
|
15927
16046
|
keyword: z.string().optional().describe('keyword across all advertisers'),
|
|
@@ -15956,7 +16075,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15956
16075
|
server.registerTool('search_tiktok', {
|
|
15957
16076
|
_meta: openaiMeta(AD_SPY_URI, 'Searching TikTok videos…', 'Found TikTok videos'),
|
|
15958
16077
|
title: 'Search TikTok',
|
|
15959
|
-
description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends
|
|
16078
|
+
description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends about a credit.",
|
|
15960
16079
|
inputSchema: {
|
|
15961
16080
|
query: z.string().describe('keyword or hashtag (no # needed)'),
|
|
15962
16081
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
@@ -15982,7 +16101,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15982
16101
|
server.registerTool('search_instagram', {
|
|
15983
16102
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Instagram reels…', 'Found Instagram reels'),
|
|
15984
16103
|
title: 'Search Instagram',
|
|
15985
|
-
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search —
|
|
16104
|
+
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — our only IG keyword surface; profile/hashtag pulls go through fetch_social_data with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends about a credit.",
|
|
15986
16105
|
inputSchema: {
|
|
15987
16106
|
query: z.string().describe('keyword to search reels for'),
|
|
15988
16107
|
limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
|
|
@@ -16010,7 +16129,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16010
16129
|
server.registerTool('search_youtube', {
|
|
16011
16130
|
_meta: openaiMeta(AD_SPY_URI, 'Searching YouTube videos…', 'Found YouTube videos'),
|
|
16012
16131
|
title: 'Search YouTube',
|
|
16013
|
-
description: "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends
|
|
16132
|
+
description: "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends about a credit.",
|
|
16014
16133
|
inputSchema: {
|
|
16015
16134
|
query: z.string().describe('keyword to search videos for'),
|
|
16016
16135
|
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
@@ -16031,7 +16150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16031
16150
|
|
|
16032
16151
|
server.registerTool('search_reddit', {
|
|
16033
16152
|
title: 'Search Reddit',
|
|
16034
|
-
description: "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends
|
|
16153
|
+
description: "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends about a credit.",
|
|
16035
16154
|
inputSchema: {
|
|
16036
16155
|
query: z.string().describe('what to search Reddit for'),
|
|
16037
16156
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
@@ -16054,7 +16173,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16054
16173
|
server.registerTool('search_threads', {
|
|
16055
16174
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Threads posts…', 'Found Threads posts'),
|
|
16056
16175
|
title: 'Search Threads',
|
|
16057
|
-
description: "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends
|
|
16176
|
+
description: "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends about a credit.",
|
|
16058
16177
|
inputSchema: {
|
|
16059
16178
|
query: z.string().describe('keyword to search Threads for'),
|
|
16060
16179
|
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
@@ -16078,11 +16197,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16078
16197
|
return adsOut('posts', all.length, all.slice(0, nAds(limit)), '', 'threads');
|
|
16079
16198
|
}));
|
|
16080
16199
|
|
|
16081
|
-
server.registerTool('
|
|
16082
|
-
title: 'Fetch
|
|
16083
|
-
description: "Generic
|
|
16200
|
+
server.registerTool('fetch_social_data', {
|
|
16201
|
+
title: 'Fetch social data',
|
|
16202
|
+
description: "Generic escape hatch for any ALLOWLISTED long-tail social/web endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends credits.",
|
|
16084
16203
|
inputSchema: {
|
|
16085
|
-
path: z.string().describe("exact
|
|
16204
|
+
path: z.string().describe("exact endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
|
|
16086
16205
|
params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
|
|
16087
16206
|
},
|
|
16088
16207
|
outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
|
|
@@ -16115,7 +16234,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16115
16234
|
|
|
16116
16235
|
server.registerTool('draft_brand', {
|
|
16117
16236
|
title: 'Draft brand profile',
|
|
16118
|
-
description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0
|
|
16237
|
+
description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
|
|
16119
16238
|
inputSchema: {
|
|
16120
16239
|
domain: z.string().optional().describe('a website to scrape'),
|
|
16121
16240
|
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
@@ -16218,11 +16337,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16218
16337
|
return ok(`Asset: ${absolute}\nDownload: ${dl}`, { url: absolute, downloadUrl: dl });
|
|
16219
16338
|
}));
|
|
16220
16339
|
|
|
16221
|
-
// ---------- post-production & analysis (
|
|
16340
|
+
// ---------- post-production & analysis (each wraps an EXISTING worker/route) ----------
|
|
16222
16341
|
server.group('create');
|
|
16223
16342
|
server.registerTool('analyze_video', {
|
|
16224
16343
|
title: 'Analyze video',
|
|
16225
|
-
description: "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call
|
|
16344
|
+
description: "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call.",
|
|
16226
16345
|
inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
|
|
16227
16346
|
outputSchema: {
|
|
16228
16347
|
durationSeconds: z.number().optional().describe('the video length in seconds'),
|
|
@@ -16399,7 +16518,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16399
16518
|
|
|
16400
16519
|
server.registerTool('competitor_teardown', {
|
|
16401
16520
|
title: 'Competitor teardown',
|
|
16402
|
-
description: "Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass `competitor` {name, domain?}. CONTRACT: supply `ads` (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit `ads` and this pulls the competitor's real Meta ads first (spends
|
|
16521
|
+
description: "Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass `competitor` {name, domain?}. CONTRACT: supply `ads` (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit `ads` and this pulls the competitor's real Meta ads first (spends a credit or two, longest-running = proven winners). Auto-tailors the white space + counter-plays to YOUR saved brand. Spends credits (free when you pass ads).",
|
|
16403
16522
|
inputSchema: {
|
|
16404
16523
|
competitor: z.object({ name: z.string().describe('the competitor brand name'), domain: z.string().optional().describe('their domain — sharpens the auto-pull page match') }).describe('the competitor to tear down'),
|
|
16405
16524
|
ads: z.array(z.object({}).passthrough()).optional().describe('ad objects to tear down (from pull_competitor_ads / search_meta_ads). Omit to auto-pull their Meta ads first.'),
|
|
@@ -16481,7 +16600,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16481
16600
|
|
|
16482
16601
|
server.registerTool('mine_angles', {
|
|
16483
16602
|
title: 'Mine customer angles',
|
|
16484
|
-
description: "Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. Spends a few
|
|
16603
|
+
description: "Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. Spends a few credits.",
|
|
16485
16604
|
inputSchema: {
|
|
16486
16605
|
brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
|
|
16487
16606
|
},
|
|
@@ -16641,7 +16760,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16641
16760
|
inputSchema: {
|
|
16642
16761
|
channel: z.string().optional().describe('restrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest)'),
|
|
16643
16762
|
authentic: z.boolean().optional().describe('true if the planned ad is an authentic/UGC/creator-register render — on-screen-text hooks are then reported unusable, with the reason'),
|
|
16644
|
-
category: z.string().optional().describe("the product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting
|
|
16763
|
+
category: z.string().optional().describe("the product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting our Location x Tier matrix puts that category in, with the reason"),
|
|
16645
16764
|
tier: z.enum(['luxury', 'premium', 'drugstore']).optional().describe('product tier, used with category — changes the FINISH of the room, never the room. Default premium.'),
|
|
16646
16765
|
},
|
|
16647
16766
|
outputSchema: { hooks: z.array(z.any()).optional(), settings: z.array(z.any()).optional(), patterns: z.array(z.any()).optional(), patternRule: z.string().optional(), suggestedSetting: z.any().optional(), evidence: z.any().optional(), ranked: z.any().optional() },
|
package/mcp/well-formed.mjs
CHANGED
|
@@ -126,7 +126,7 @@ export function wellFormedValue(v) {
|
|
|
126
126
|
* the thing that manufactures lone surrogates in the first place.
|
|
127
127
|
*
|
|
128
128
|
* `.slice(0, n)` counts code units, so it cuts an astral character in half whenever the boundary lands mid-pair.
|
|
129
|
-
* Measured on live vendor text: 14,579
|
|
129
|
+
* Measured on live vendor text: 14,579 upstream strings held 570 real PAIRS and ZERO lone surrogates — the
|
|
130
130
|
* vendor is not emitting broken text, our own truncation is creating it, at 1 caption length in every 21.
|
|
131
131
|
*
|
|
132
132
|
* THE LENGTH CONTRACT IS PRESERVED: the result is never LONGER than `.slice(0, n)` would be — at worst one code
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.162",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio and marketing MCP server with
|
|
5
|
+
"description": "AI ad studio and marketing MCP server with 718 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|
|
@@ -14,7 +14,7 @@ allowed-tools: Bash
|
|
|
14
14
|
Drive the **Hermoso CLI** to go from a brand to a finished ad in three steps. Report the final media URL.
|
|
15
15
|
|
|
16
16
|
## Setup
|
|
17
|
-
- `hermoso version` to confirm the CLI; `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**..
|
|
17
|
+
- `hermoso version` to confirm the CLI; `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.. No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai, no browser needed; see the Hermoso README.
|
|
18
18
|
|
|
19
19
|
## Procedure
|
|
20
20
|
1. **Onboard the brand** (skip if the user already gave full brand details):
|
|
@@ -16,7 +16,7 @@ You drive the **Hermoso CLI** (`hermoso`) to render images and videos. Always re
|
|
|
16
16
|
|
|
17
17
|
## Setup (once)
|
|
18
18
|
1. Ensure the CLI is available. From the Hermoso repo: `node bin/hermoso.mjs version` (or `hermoso version` if globally installed via `npm i -g`).
|
|
19
|
-
2. `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.
|
|
19
|
+
2. `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**. No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai, no browser needed; see the Hermoso README.
|
|
20
20
|
|
|
21
21
|
## Procedure
|
|
22
22
|
1. **Always run `hermoso capabilities` first.** It lists the valid image/video **model ids**, their credit costs, aspect ratios, video durations, and the recipe ids. Never guess a model id.
|
|
@@ -15,7 +15,7 @@ Use Hermoso's reference-image compositing so the real product (label, colours, s
|
|
|
15
15
|
scene around it is generated. Drive the **Hermoso CLI**.
|
|
16
16
|
|
|
17
17
|
## Setup
|
|
18
|
-
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.; run `hermoso capabilities` once to see image model ids + recipes.
|
|
18
|
+
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.; run `hermoso capabilities` once to see image model ids + recipes. No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai, no browser needed; see the Hermoso README.
|
|
19
19
|
|
|
20
20
|
## Procedure
|
|
21
21
|
1. Get the **product image** path/URL from the user. This is the `--ref` — it forces product-accurate compositing.
|
|
@@ -14,7 +14,7 @@ allowed-tools: Bash
|
|
|
14
14
|
This is Hermoso's discovery half (which most generators don't have). Drive the **Hermoso CLI**.
|
|
15
15
|
|
|
16
16
|
## Setup
|
|
17
|
-
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**..
|
|
17
|
+
- `hermoso auth login` (opens your browser once; nothing to paste). On a machine with no browser: `hermoso auth login --token <your key>`, using a key from the app under **MCP & CLI**.. No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai, no browser needed; see the Hermoso README.
|
|
18
18
|
|
|
19
19
|
## Procedure
|
|
20
20
|
Pick the tool that fits the ask:
|
|
@@ -27,5 +27,5 @@ Pick the tool that fits the ask:
|
|
|
27
27
|
4. **Synthesize**: report the strongest hooks, angles, formats, and what's worth copying — be specific (quote the actual headlines/angles). If the user then wants to build one, hand off to `hermoso-ad-from-brand` / `hermoso-generate`.
|
|
28
28
|
|
|
29
29
|
## Notes
|
|
30
|
-
- Research spends
|
|
30
|
+
- Research spends credits (ad-library calls); keep platform scope to what's asked.
|
|
31
31
|
- Add `--json` for the raw ad objects (URLs, copy, run dates) when the user wants the data, not a summary.
|