hermoso 0.1.372 → 0.1.373
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 +26 -15
- package/bin/hermoso.mjs +1 -1
- package/mcp/client.mjs +38 -3
- package/mcp/http.mjs +7 -2
- package/mcp/openai-extensions.mjs +2110 -0
- package/mcp/roster-scope.mjs +2 -0
- package/mcp/tool-cost.mjs +1 -1
- package/mcp/tools.mjs +662 -269
- package/package.json +2 -2
- package/skills/hermoso-marketing/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -5,21 +5,22 @@ 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
|
+
**892 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
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
12
|
-
and insights, comments and DMs, connectors,
|
|
12
|
+
and insights, comments and DMs, connectors, profiles and team seats are **free on every plan**, with no
|
|
13
13
|
per-post or per-channel fee and no seat pricing. Credits are spent only on running an AI model (image, video,
|
|
14
14
|
voice, text, planning, post-production) and on Ad Spy research, and posting an ad you already rendered is never a
|
|
15
15
|
second charge. The one exception is X (Twitter), where posting and reads bill a few credits per call because X
|
|
16
16
|
charges per API request.
|
|
17
17
|
|
|
18
18
|
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
19
|
-
Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and
|
|
20
|
-
Google Merchant Center. Publishing and scheduling — **ten** channels: Facebook, Instagram,
|
|
21
|
-
YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. Messaging: WhatsApp (you message a
|
|
22
|
-
eleventh publishing channel)
|
|
19
|
+
Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus
|
|
20
|
+
product feeds in Google Merchant Center. Publishing and scheduling — **ten** channels: Facebook, Instagram,
|
|
21
|
+
Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. Messaging: WhatsApp (you message a
|
|
22
|
+
person, so it is not an eleventh publishing channel) and Instagram DM automations (a keyword comment, a DM, a
|
|
23
|
+
story reply or an ig.me link gets an automatic private reply). Ad research: the Meta, Google and LinkedIn ad libraries plus organic TikTok,
|
|
23
24
|
Instagram, YouTube, Threads and Reddit. Analytics: Google Analytics 4, Google Search Console and every
|
|
24
25
|
connected platform's own post and campaign insights. Files: Google Drive, Sheets, Docs and OneDrive.
|
|
25
26
|
|
|
@@ -200,7 +201,7 @@ into your sessions. If you want the connector: `claude mcp add --transport http
|
|
|
200
201
|
and `claude mcp list` reports `! Needs authentication` until you run `claude mcp login hermoso` once and approve in
|
|
201
202
|
your browser (`--no-browser` prints the link on a headless machine). Measured against Claude Code 2.1.282 on 2026-09-25.
|
|
202
203
|
|
|
203
|
-
Your agent now has the full studio **with your
|
|
204
|
+
Your agent now has the full studio **with your profile's context**: the brand details, products, logos and
|
|
204
205
|
learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
|
|
205
206
|
`plan_ad`/`plan_variations` to use it). Renders bill your Hermoso credits, at the same prices as the Studio. Only AI model runs and Ad Spy research spend credits; publishing, scheduling, ads management and analytics are free on every plan (posting to X and reading X data are the one per-call exception, managing X ads is free).
|
|
206
207
|
|
|
@@ -230,7 +231,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
230
231
|
|
|
231
232
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
232
233
|
|
|
233
|
-
### What the
|
|
234
|
+
### What the 892 tools cover
|
|
234
235
|
|
|
235
236
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
236
237
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -280,10 +281,20 @@ anything pending: **Hermoso does not receive WhatsApp webhooks, so there is no m
|
|
|
280
281
|
not an inbox surface and `list_inbox` does not cover it — and **outside the 24-hour window that opens when the
|
|
281
282
|
customer messages first, WhatsApp accepts an APPROVED template and nothing else.**
|
|
282
283
|
|
|
284
|
+
**Automate Instagram DMs** — `save_instagram_dm_automation` sets up a rule that answers people for the brand: a
|
|
285
|
+
comment with a keyword on one post, the next post or any post gets a private reply, and a DM, a story reply or an
|
|
286
|
+
ig.me link gets an answer, with optional link buttons, a public comment reply, a follow gate and a dry run.
|
|
287
|
+
`test_instagram_dm_automation` shows exactly what a sample comment or DM would send without sending it, and
|
|
288
|
+
`list_instagram_dm_automations` / `delete_instagram_dm_automation` read each rule's stats and send log or remove
|
|
289
|
+
it. Each person gets one reply per post, Meta allows a private reply within 7 days of the comment, and no AI runs
|
|
290
|
+
on the send path, so it costs no credits.
|
|
291
|
+
|
|
283
292
|
**Run the ads** — full campaign trees, built paused and read back before anything is reported, with every spend
|
|
284
|
-
change confirm-gated, on **
|
|
293
|
+
change confirm-gated, on **twelve** platforms: **Meta**, **Google Ads**, **LinkedIn Ads**, **Reddit Ads**,
|
|
285
294
|
**Pinterest Ads**, **Microsoft Advertising**, **ChatGPT Ads** (OpenAI's Advertiser API), **X Ads**, **TikTok Ads**,
|
|
286
|
-
**Snapchat Ads
|
|
295
|
+
**Snapchat Ads**, **Apple Ads** (Apple Search Ads on the App Store) and **AppLovin Ads** (reporting and the website
|
|
296
|
+
pixel on the keys every account has; campaign building once AppLovin enables Campaign Management API access on the
|
|
297
|
+
account, and server-side conversion events with the Conversion API key AppLovin issues on request). Each has list + report + create + budget/status tools
|
|
287
298
|
(e.g. `list_google_ads_campaigns`, `google_ads_report`, `create_google_ads_campaign`, `set_google_ads_budget`,
|
|
288
299
|
`set_google_ads_status`). *Snapchat needs one extra step the others do not: an ad points at a CREATIVE, and every
|
|
289
300
|
Snapchat creative must carry a Public Profile id — build it with `upload_snapchat_ads_creative`.*
|
|
@@ -314,8 +325,8 @@ property holds 50 event-scoped ones.*
|
|
|
314
325
|
`create_drive_folder`), Google Sheets (`create_sheet`, `append_to_sheet`, `read_sheet`), Google Docs
|
|
315
326
|
(`create_doc`, `append_to_doc`), and OneDrive (`save_to_onedrive` + full CRUD).
|
|
316
327
|
|
|
317
|
-
**Workspace & account** —
|
|
318
|
-
`delete_brand
|
|
328
|
+
**Workspace & account** — profiles (`list_brands`, `create_brand`, `use_brand`, `update_brand`,
|
|
329
|
+
`delete_brand`; one account holds many profiles, each a brand, a client, a creator or a personal workspace, so an agency runs every client through here), memory
|
|
319
330
|
(`remember`, `forget`, `list_memory`), custom skills (`save_skill`, `get_skill`, `list_skills`,
|
|
320
331
|
`delete_skill` — the one library, which absorbed the old AI-Employee personas), team (`list_team`, `invite_member`, `remove_member`,
|
|
321
332
|
`set_role`), settings (`get_settings`, `update_settings` — including the **language** every ad, script and plan
|
|
@@ -324,7 +335,7 @@ is written in), connectors (`list_connectors`, `list_connector_accounts`, `set_c
|
|
|
324
335
|
`set_auto_reload`), plus `list_jobs` / `get_job` for async renders.
|
|
325
336
|
|
|
326
337
|
**Connector accounts are picked, not guessed.** One person often administers several Facebook Pages, Google Ads
|
|
327
|
-
customers or LinkedIn company Pages. Only the accounts ticked for a
|
|
338
|
+
customers or LinkedIn company Pages. Only the accounts ticked for a profile are usable — enforced server-side, and
|
|
328
339
|
an empty selection shares nothing. Linking a *new* account is the one step that is not headless (it is an OAuth
|
|
329
340
|
consent screen, so the user does it in the app).
|
|
330
341
|
|
|
@@ -400,8 +411,8 @@ The skills pick themselves. These are whole prompts, not commands:
|
|
|
400
411
|
| --- | --- |
|
|
401
412
|
| `HERMOSO_API_BASE` | The Hermoso API origin (default `https://app.hermoso.ai` — set `http://localhost:3000` if you run the app yourself) |
|
|
402
413
|
| `HERMOSO_TOKEN` | Bearer agent key (`hmk_…`) — required against the hosted app |
|
|
403
|
-
| `HERMOSO_PROFILE` |
|
|
404
|
-
| `HERMOSO_OWNER` | Only for a
|
|
414
|
+
| `HERMOSO_PROFILE` | Profile id, for accounts with multiple profiles |
|
|
415
|
+
| `HERMOSO_OWNER` | Only for a profile **another account shared with you** (a team workspace): the owning account id. Set it together with `HERMOSO_PROFILE`, and set `HERMOSO_PROFILE` to that workspace's **profileUuid** (a profile's short slug is refused). Run `list_brands` (or `hermoso list_brands` from the CLI) to print both values for every workspace you can enter. The server re-authorizes the pair on every request, so a wrong value is refused, never trusted. |
|
|
405
416
|
|
|
406
417
|
`mcp/http.mjs` is the hosted remote-connector transport (paste-a-URL into Claude.ai → Connectors). It ships in
|
|
407
418
|
this repo for transparency and refuses to mount without authenticated identity — no anonymous spend, ever.
|
package/bin/hermoso.mjs
CHANGED
|
@@ -129,7 +129,7 @@ async function main() {
|
|
|
129
129
|
// profile" and it died with the `brand draft` usage line instead — the first command a new user copies off
|
|
130
130
|
// the CLI page. It is get_brand, run through the same handler the MCP twins use.
|
|
131
131
|
if (!sub) { const reg = await import('../mcp/registry.mjs'); return await runTool(reg, 'get_brand', flags, []); }
|
|
132
|
-
if (sub !== 'draft') return die('usage: hermoso brand the saved brand
|
|
132
|
+
if (sub !== 'draft') return die('usage: hermoso brand the saved profile (a brand, creator or personal workspace)\n hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
|
|
133
133
|
const body = flags.domain ? { domain: flags.domain } : flags.description ? { description: flags.description } : flags.social ? { socialHandle: flags.social, platform: flags.platform || 'instagram' } : null;
|
|
134
134
|
if (!body) return die('give --domain, --description, or --social');
|
|
135
135
|
const d = await api.apiPost('/api/brand/draft', body); const p = d.profile || d;
|
package/mcp/client.mjs
CHANGED
|
@@ -226,7 +226,40 @@ export async function apiGet(p, query) {
|
|
|
226
226
|
const TRANSPORT_RE = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|socket hang up|network|terminated/i;
|
|
227
227
|
const isTransport = (e) => !!e && e.name === 'TypeError' && TRANSPORT_RE.test(String(e.message || '') + ' ' + String(e.cause?.code || e.cause?.message || ''));
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
// ── THE PLATFORM'S OWN 429 NEVER REACHED US, SO IT IS RETRIED HERE (2026-10-04) ───────────────────────────────
|
|
230
|
+
// Polling a job every 10 seconds from the CLI answered HTTP 429, and it was not a limit of ours: the app has no limiter
|
|
231
|
+
// on GET /api/jobs/:id, and /v1 reads allow 300 a minute per key. Every one of those 429s in the Cloud Run request log
|
|
232
|
+
// (36 between 18:35 and 18:39Z, on /api/credits, GET / and webhooks as much as on job polls) is the platform's "The
|
|
233
|
+
// request was aborted because there was no available instance": a plain-text body, latency 0s, refused before the
|
|
234
|
+
// container saw it, while the one instance's CPU sat pegged by local renders at 2-7 concurrent requests. The browser's
|
|
235
|
+
// api() has retried that answer since 2026-09-06; the CLI and the stdio MCP read it as a failed call, so one busy
|
|
236
|
+
// second ended a render wait. A refusal that never reached the app ran nothing, so it is retried for ANY method; the
|
|
237
|
+
// app's own refusals always answer JSON and pass through untouched (their rules are theirs). A read also rides out a
|
|
238
|
+
// plain-text 502/503/504 (a rollover seen from outside); a write does not, because those can come back after the app
|
|
239
|
+
// already had the request. Waits are env-tunable so a check can run the real loop in milliseconds.
|
|
240
|
+
export const FRONT_DOOR_WAITS_MS = [2000, 5000, 10000];
|
|
241
|
+
const frontDoorWaits = () => { const v = String(process.env.HERMOSO_FRONT_DOOR_WAITS_MS || '').split(',').map(Number).filter((n) => Number.isFinite(n) && n >= 0); return v.length ? v : FRONT_DOOR_WAITS_MS; };
|
|
242
|
+
/** Did the PLATFORM refuse this before it reached the app? Pure: our routes always answer JSON. */
|
|
243
|
+
export function isFrontDoorRefusal(status, contentType, { read = false } = {}) {
|
|
244
|
+
const st = Number(status);
|
|
245
|
+
if (/json/i.test(String(contentType || ''))) return false;
|
|
246
|
+
return st === 429 || (read && (st === 502 || st === 503 || st === 504));
|
|
247
|
+
}
|
|
248
|
+
export const FRONT_DOOR_MESSAGE = 'Hermoso\u2019s server was briefly too busy to take the request and turned it away before the app saw it (HTTP 429). It was retried and the server was still busy. Nothing ran and nothing was charged: try again in a minute.';
|
|
249
|
+
async function throughFrontDoor(send, { read }) {
|
|
250
|
+
let res = await send();
|
|
251
|
+
for (const ms of frontDoorWaits()) {
|
|
252
|
+
if (!isFrontDoorRefusal(res.status, res.headers?.get?.('content-type'), { read })) return res;
|
|
253
|
+
try { await res.arrayBuffer(); } catch { /* drain, so the socket is reused */ }
|
|
254
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
255
|
+
res = await send();
|
|
256
|
+
}
|
|
257
|
+
if (isFrontDoorRefusal(res.status, res.headers?.get?.('content-type'), { read })) throw Object.assign(new Error(FRONT_DOOR_MESSAGE), { status: res.status, _frontDoor: true });
|
|
258
|
+
return res;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function fetchRead(url, init) { return throughFrontDoor(() => fetchReadOnce(url, init), { read: true }); }
|
|
262
|
+
async function fetchReadOnce(url, init) {
|
|
230
263
|
try { return await fetch(url, init); }
|
|
231
264
|
catch (e) {
|
|
232
265
|
if (!isTransport(e)) throw e;
|
|
@@ -239,7 +272,8 @@ async function fetchRead(url, init) {
|
|
|
239
272
|
}
|
|
240
273
|
}
|
|
241
274
|
|
|
242
|
-
async function fetchWrite(url, init) {
|
|
275
|
+
async function fetchWrite(url, init) { return throughFrontDoor(() => fetchWriteOnce(url, init), { read: false }); }
|
|
276
|
+
async function fetchWriteOnce(url, init) {
|
|
243
277
|
try { return await fetch(url, init); }
|
|
244
278
|
catch (e) {
|
|
245
279
|
if (!isTransport(e)) throw e;
|
|
@@ -469,7 +503,8 @@ export const JOB_MISS_GRACE_MS = 30_000;
|
|
|
469
503
|
export function pollMissVerdict(status, { startedAt, now = Date.now(), deadline = Infinity, graceMs = JOB_MISS_GRACE_MS } = {}) {
|
|
470
504
|
const st = Number(status);
|
|
471
505
|
if (st === 404) return (now - startedAt < graceMs && now < deadline) ? 'retry' : 'final';
|
|
472
|
-
|
|
506
|
+
// a throttle is not news about the render: a poll that was turned away waits and asks again (2026-10-04)
|
|
507
|
+
if ((st === 429 || st === 502 || st === 503 || st === 504) && now < deadline) return 'retry';
|
|
473
508
|
return 'throw';
|
|
474
509
|
}
|
|
475
510
|
export function jobMissMessage(id) {
|
package/mcp/http.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { createRequire } from 'node:module';
|
|
|
17
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
18
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
19
|
import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
|
|
20
|
+
import { chatgptExtAsked } from './openai-extensions.mjs';
|
|
20
21
|
import { mcpCtx, connectedProviders } from './client.mjs';
|
|
21
22
|
|
|
22
23
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
@@ -380,6 +381,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
380
381
|
// `?src=<surface>` on the published URL (registry / glama / smithery / cursor / readme …) — attribution only. It may
|
|
381
382
|
// not change auth, scope or spend, and an unrecognisable value is simply dropped. The OAuth resource metadata is
|
|
382
383
|
// path-based (RFC 9728), so a query on the pasted URL changes nothing about the handshake.
|
|
384
|
+
// `?ext=1` asks for the ChatGPT extensions app (the Library panel + sidebar). Only a REQUEST: registerTools turns it
|
|
385
|
+
// on only for a widget host on a process with HERMOSO_CHATGPT_EXT=1 (chatgptExtOn in openai-extensions.mjs), so on
|
|
386
|
+
// the published URL, or with the flag unset, the roster is exactly what it was. Presentation only, like `?tools=`.
|
|
387
|
+
const extAsked = (req) => chatgptExtAsked(req.query?.ext);
|
|
383
388
|
const srcOf = (req) => { const v = String(req.query?.src || '').trim().toLowerCase(); return /^[a-z0-9][a-z0-9_.-]{0,39}$/.test(v) ? v : null; };
|
|
384
389
|
const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
|
|
385
390
|
|
|
@@ -402,7 +407,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
402
407
|
// every one of those the roster IS the product description. Serving them the core set would publish Hermoso as
|
|
403
408
|
// a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
|
|
404
409
|
// default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
|
|
405
|
-
registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost, hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
410
|
+
registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost, hosted: true, ext: extAsked(req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
406
411
|
// WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
|
|
407
412
|
// not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
|
|
408
413
|
// tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
|
|
@@ -517,7 +522,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
517
522
|
// ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
|
|
518
523
|
const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
|
|
519
524
|
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
|
|
520
|
-
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost, hosted: true, client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
525
|
+
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost, hosted: true, ext: extAsked(req), client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
521
526
|
const transport = new StreamableHTTPServerTransport({
|
|
522
527
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
523
528
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|