hermoso 0.1.371 → 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 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
- **868 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
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, brand profiles and team seats are **free on every plan**, with no
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 ChatGPT Ads, plus product feeds in
20
- Google Merchant Center. Publishing and scheduling — **ten** channels: Facebook, Instagram, Threads, TikTok,
21
- YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. Messaging: WhatsApp (you message a person, so it is not an
22
- eleventh publishing channel). Ad research: the Meta, Google and LinkedIn ad libraries plus organic TikTok,
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 workspace's context**: the brand profile, products, logos and
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 868 tools cover
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 **eleven** platforms: **Meta**, **Google Ads**, **LinkedIn Ads**, **Reddit Ads**,
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** and **Apple Ads** (Apple Search Ads on the App Store). Each has list + report + create + budget/status tools
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** — brand workspaces (`list_brands`, `create_brand`, `use_brand`, `update_brand`,
318
- `delete_brand` — one account holds many brands, so an agency runs every client through here), memory
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 brand are usable — enforced server-side, and
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` | Brand-workspace id, for accounts with multiple brand profiles |
404
- | `HERMOSO_OWNER` | Only for a brand **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 brand'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. |
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 profile\n hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
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
- async function fetchRead(url, init) {
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
- if ((st === 502 || st === 503 || st === 504) && now < deadline) return 'retry';
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, ''),