hermoso 0.1.537 → 0.1.541
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 +6 -4
- package/mcp/openai-extensions.mjs +4 -3
- package/mcp/tools.mjs +55 -26
- package/package.json +1 -1
- package/skills/hermoso-launch-app/SKILL.md +62 -0
package/README.md
CHANGED
|
@@ -133,8 +133,9 @@ just download it. Use the one piece you need, or all of it together.
|
|
|
133
133
|
## Install in one command
|
|
134
134
|
|
|
135
135
|
This repo is a plugin marketplace, a Gemini CLI extension and a skills package at once, so a coding agent takes
|
|
136
|
-
Hermoso in one line. Every install brings the same
|
|
137
|
-
`hermoso-ad-from-brand`, `hermoso-product-photoshoot`, `hermoso-marketing`, `hermoso-edit-timeline
|
|
136
|
+
Hermoso in one line. Every install brings the same seven skills (`hermoso-research`, `hermoso-generate`,
|
|
137
|
+
`hermoso-ad-from-brand`, `hermoso-product-photoshoot`, `hermoso-marketing`, `hermoso-edit-timeline`,
|
|
138
|
+
`hermoso-launch-app`), and the skills drive the `hermoso`
|
|
138
139
|
CLI through `npx`. No
|
|
139
140
|
tool list is loaded into your session: a CLI command costs nothing until it runs, and it reaches every tool.
|
|
140
141
|
The first time, your agent runs `npx -y hermoso auth login`, which opens a browser to sign in.
|
|
@@ -286,7 +287,7 @@ Connect, approve with your Hermoso account, and you are done: the full toolset w
|
|
|
286
287
|
1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai). The free tier is included; plans and credits
|
|
287
288
|
are the same ones the web Studio uses. Or skip the browser entirely and let your agent sign itself up on a paid
|
|
288
289
|
plan with `POST /v1/signup` (above).
|
|
289
|
-
2. **Install the plugin.** It adds the
|
|
290
|
+
2. **Install the plugin.** It adds the seven Hermoso skills, which drive the `hermoso` CLI through `npx`:
|
|
290
291
|
|
|
291
292
|
```bash
|
|
292
293
|
claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
|
|
@@ -514,7 +515,7 @@ the package, so they need no key, no network and no sign-in.
|
|
|
514
515
|
|
|
515
516
|
## 3. Skills: what a coding agent installs
|
|
516
517
|
|
|
517
|
-
`skills/` holds
|
|
518
|
+
`skills/` holds seven installable skills:
|
|
518
519
|
|
|
519
520
|
| Skill | What it does |
|
|
520
521
|
| --- | --- |
|
|
@@ -524,6 +525,7 @@ the package, so they need no key, no network and no sign-in.
|
|
|
524
525
|
| `hermoso-product-photoshoot` | A real product photo composited into studio, lifestyle or hero scenes |
|
|
525
526
|
| `hermoso-marketing` | The whole loop: research, create, publish and schedule, paid campaigns, measure |
|
|
526
527
|
| `hermoso-edit-timeline` | The manual for composing any video edit or transition with `edit_timeline` |
|
|
528
|
+
| `hermoso-launch-app` | Launch and market an app or site you just built: brand, launch ads, a week of scheduled posts |
|
|
527
529
|
|
|
528
530
|
They are what every one-command install at the top of this page adds, in Claude Code, Codex, Gemini CLI and
|
|
529
531
|
through `npx skills add`. From a clone, copying works too:
|
|
@@ -1276,9 +1276,10 @@ export const neutralNote = (t) => {
|
|
|
1276
1276
|
// FAILED read is reported as a note and never as an empty list ([[failed-read-is-not-empty]]).
|
|
1277
1277
|
// OUR OWN HOUSEKEEPING IS NOT A RENDER. /api/jobs lists every job on the account, including idle nudges, retention
|
|
1278
1278
|
// sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
|
|
1279
|
-
// tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1
|
|
1280
|
-
//
|
|
1281
|
-
|
|
1279
|
+
// tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1 by each type's PUBLIC
|
|
1280
|
+
// name (the one /api/jobs returns, lib/job-types.mjs publicJobType), and the check fails if that list gains a type this
|
|
1281
|
+
// one lacks. A job queued for LATER is not in progress either.
|
|
1282
|
+
export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'latelanding', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'modelwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
|
|
1282
1283
|
const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
|
|
1283
1284
|
const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
|
|
1284
1285
|
const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
package/mcp/tools.mjs
CHANGED
|
@@ -435,7 +435,7 @@ export const MCP_INSTRUCTIONS_WIDGET = [
|
|
|
435
435
|
"Hermoso runs a brand's marketing: research competitors' ads, make image and video ads, post to their accounts, run paid campaigns, read results. Each area works on its own.",
|
|
436
436
|
'Asked to make something, make it; render tools pick a sound default model (hermoso_capabilities lists models and costs).',
|
|
437
437
|
'• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
|
|
438
|
-
'• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static,
|
|
438
|
+
'• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, plan_video_remake; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in background; poll get_job.',
|
|
439
439
|
"• FILES & SCHEDULING: upload_file turns a file into a URL; schedule_post queues a post; list_scheduled shows the queue; list_connectors shows what is connected; list_library lists past creations.",
|
|
440
440
|
'• PUBLISH: post_to_* (post_to_meta, post_to_tiktok...) post now; list_inbox and reply_to_inbox_item answer comments and messages.',
|
|
441
441
|
'• PAID ADS (Meta, Google, LinkedIn, TikTok, Reddit, Snapchat, ChatGPT): create_* builds campaigns paused; set_*_status and set_*_budget control spend.',
|
|
@@ -674,7 +674,13 @@ export function noteBrandOf(tool, args) {
|
|
|
674
674
|
for (const k of HERMOSO_BRAND_KEYS.get(tool) || []) { const v = args?.[k]; if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 120); }
|
|
675
675
|
return '';
|
|
676
676
|
}
|
|
677
|
-
|
|
677
|
+
// …AND AT MOST ONE NEXT STEP, WHICHEVER IT IS (2026-11-14, lib/agent-nudge.mjs). `nudge: 1` tells the server this twin reads
|
|
678
|
+
// `next` off the answer: the one step it chose for this account (save a brand first, then connect a channel), with the
|
|
679
|
+
// sentence to say, each offered at most once in seven days and never once done. The server words the text, the step and
|
|
680
|
+
// the hint, so a step added later needs no new twin; a server that predates this answers the connect prompt as before.
|
|
681
|
+
// `raw` marks a call on the raw model lane, which gets no note of ours about a brand; `asked` says this session's
|
|
682
|
+
// first-run note already asked for their website, so the same session is not asked twice.
|
|
683
|
+
export async function withConnectPrompt(tool, r, post = apiPost, brand = '', opts = {}) {
|
|
678
684
|
try {
|
|
679
685
|
const kind = finishedRenderKind(tool, r);
|
|
680
686
|
if (!kind) return r;
|
|
@@ -683,10 +689,16 @@ export async function withConnectPrompt(tool, r, post = apiPost, brand = '') {
|
|
|
683
689
|
const sc = r.structuredContent || {};
|
|
684
690
|
const job = String(sc.jobId || sc.id || '').slice(0, 80);
|
|
685
691
|
const client = store ? String(store.client || '') : 'hermoso';
|
|
686
|
-
const d = await Promise.race([Promise.resolve(post('/api/mcp/connect-prompt', { tool, kind, job, client, ...(brand ? { brand } : {}) })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), CONNECT_PROMPT_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
|
|
692
|
+
const d = await Promise.race([Promise.resolve(post('/api/mcp/connect-prompt', { tool, kind, job, client, nudge: 1, ...(opts && opts.raw ? { raw: true } : {}), ...(opts && opts.asked ? { asked: true } : {}), ...(brand ? { brand } : {}) })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), CONNECT_PROMPT_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
|
|
687
693
|
if (!d || d.show !== true || typeof d.text !== 'string' || !d.text) return r;
|
|
688
|
-
const
|
|
689
|
-
|
|
694
|
+
const next = d.next && typeof d.next === 'object' && typeof d.next.step === 'string' && d.next.step ? d.next : null;
|
|
695
|
+
const hint = d.hint && typeof d.hint === 'object' && typeof d.hint.do === 'string' && d.hint.do ? d.hint : null;
|
|
696
|
+
if (next && next.step !== 'connect_channel') {
|
|
697
|
+
const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...sc, next } };
|
|
698
|
+
return hint ? withHints(out, [...hintsOf(r), hint]) : out;
|
|
699
|
+
}
|
|
700
|
+
const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...sc, connectPrompt: { channels: Array.isArray(d.channels) ? d.channels : [] }, ...(next ? { next } : {}) } };
|
|
701
|
+
return withHints(out, [...hintsOf(r), hint || { do: 'offer to post this; hand the user one of the connect links in this reply', why: 'no publishing channel is connected, and connecting needs a browser sign-in on the platform' }]);
|
|
690
702
|
} catch { return r; }
|
|
691
703
|
}
|
|
692
704
|
// THE WEEK-PLAN NOTE (2026-11-02, lib/week-plan.mjs): the offer after a first connection, and "your week is finished". The
|
|
@@ -699,6 +711,7 @@ const WEEK_PLAN_NOTE_SKIP = new Set(['plan_week', 'get_job', 'list_jobs']);
|
|
|
699
711
|
export async function withWeekPlanNote(tool, r, post = apiPost, brand = '') {
|
|
700
712
|
try {
|
|
701
713
|
if (!r || r.isError || !Array.isArray(r.content) || WEEK_PLAN_NOTE_SKIP.has(tool)) return r;
|
|
714
|
+
if (r.structuredContent && r.structuredContent.next && typeof r.structuredContent.next.step === 'string') return r; // one next step a reply (a paging cursor called `next` is not one): this note is not asked for, so it is not spent, and the next reply carries it
|
|
702
715
|
const store = mcpCtx.getStore();
|
|
703
716
|
if (store && (store.connectPromptQuiet || (store.remote && store.client === undefined))) return r;
|
|
704
717
|
const client = store ? String(store.client || '') : 'hermoso';
|
|
@@ -872,6 +885,7 @@ const wrap = (fn) => {
|
|
|
872
885
|
_fr.said = true;
|
|
873
886
|
const _n = ((_st.apiCharged || 0) + (_st.jobCharged || 0)) > 0 ? null : firstRunNote(_tool, _fr, r);
|
|
874
887
|
if (_n) {
|
|
888
|
+
if (/draft_brand/.test(String(_n.hint?.do || ''))) _fr.brandAsked = true; // this session has asked for their website: its next finished creation does not ask again (withConnectPrompt `asked`)
|
|
875
889
|
const _last = [...r.content].reverse().find((b) => b && b.type === 'text' && typeof b.text === 'string');
|
|
876
890
|
if (_last) _last.text += `\n\n${_n.text}`; else r.content.push({ type: 'text', text: _n.text });
|
|
877
891
|
return noPitchOnWidget(withHints(r, [_n.hint]));
|
|
@@ -879,7 +893,7 @@ const wrap = (fn) => {
|
|
|
879
893
|
}
|
|
880
894
|
} catch {}
|
|
881
895
|
const _noteBrand = noteBrandOf(_tool, args); // the brand this call acted on, so both notes are about it
|
|
882
|
-
return noPitchOnWidget(await withWeekPlanNote(_tool, await withConnectPrompt(_tool, withWidgetResultNote(_tool, r, hostRendersWidgets()), apiPost, _noteBrand), apiPost, _noteBrand));
|
|
896
|
+
return noPitchOnWidget(await withWeekPlanNote(_tool, await withConnectPrompt(_tool, withWidgetResultNote(_tool, r, hostRendersWidgets()), apiPost, _noteBrand, { raw: args?.raw === true, asked: mcpCtx.getStore()?.firstRun?.brandAsked === true }), apiPost, _noteBrand));
|
|
883
897
|
}
|
|
884
898
|
catch (e) {
|
|
885
899
|
try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
|
|
@@ -3059,7 +3073,9 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
|
|
|
3059
3073
|
// After publication OpenAI rescans daily, so a tool ChatGPT should have is added to its list by name instead.
|
|
3060
3074
|
// 2. "Tool names should be … specific, and descriptive of what the tool actually does." The scan found `clone_video`
|
|
3061
3075
|
// unclear. Renaming it everywhere would break every agent that calls it, so a widget host lists the SAME tool
|
|
3062
|
-
// (same definition, same handler) as `
|
|
3076
|
+
// (same definition, same handler) as `plan_video_remake`, and `clone_video` stays the name everywhere else.
|
|
3077
|
+
// (It was `remake_video_for_brand` until the 2026-11-14 scan read that name as promising a finished video, which
|
|
3078
|
+
// the tool does not make: it plans one. The old name stays registered, unlisted and callable: WIDGET_HOST_RETIRED_NAMES.)
|
|
3063
3079
|
// A direct call to `clone_video` from a widget host still answers (an older scan holds that name).
|
|
3064
3080
|
// 3. ANNOTATIONS FOLLOW THE DOC'S DEFINITIONS (developers.openai.com/plugins/deploy/app-review.md). openWorldHint is TRUE
|
|
3065
3081
|
// on every generation and post-production tool: each sends the caller's prompt or media to an independently
|
|
@@ -3069,9 +3085,13 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
|
|
|
3069
3085
|
// caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
|
|
3070
3086
|
// bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
|
|
3071
3087
|
export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
|
|
3072
|
-
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: '
|
|
3088
|
+
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'plan_video_remake', click_to_ad: 'plan_video_ad_from_product_link', delete_openai_ads_object: 'archive_openai_ads_object', make_from_preset: 'plan_video_from_preset', make_short: 'plan_short_video' }); // make_from_preset + make_short (2026-11-09 scan, "name appears promotional, exaggerated, or inconsistent with its behavior"): both PLAN and render nothing, render_ad makes the video // delete_openai_ads_object (2026-11-03 scan, "name … inconsistent with its behavior"): OpenAI has no delete, the tool ARCHIVES // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
|
|
3073
3089
|
export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
|
|
3074
|
-
|
|
3090
|
+
// A WIDGET-HOST NAME THAT WAS REPLACED, and the canonical tool it still runs. ChatGPT keeps the roster it last published
|
|
3091
|
+
// until a scan publishes the new one, so the old name stays registered on every widget host, never listed, and a call to
|
|
3092
|
+
// it runs the same handler.
|
|
3093
|
+
export const WIDGET_HOST_RETIRED_NAMES = Object.freeze({ remake_video_for_brand: 'clone_video' });
|
|
3094
|
+
export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link', archive_openai_ads_object: 'Archive a ChatGPT Ads campaign, ad group or ad', plan_video_from_preset: 'Plan a video from a saved preset', plan_short_video: 'Plan a short video from a format' });
|
|
3075
3095
|
// ── ONE OPERATION, ONE TOOL, ON A WIDGET HOST (2026-10-18) ─────────────────────────────────────────────────────
|
|
3076
3096
|
// OpenAI's daily scan HELD 18 tools that take an `action` enum and do different things per value: "This tool appears
|
|
3077
3097
|
// to execute operations that are not individually exposed. Please expose each model-callable operation upfront as a
|
|
@@ -3319,7 +3339,7 @@ export const WIDGET_SPLIT_OF = Object.freeze(Object.fromEntries(Object.entries(W
|
|
|
3319
3339
|
// Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
|
|
3320
3340
|
// `app` (optional) is the focused app of the session: a tool that app offers (WIDGET_APP_OFFERS) is not hidden there.
|
|
3321
3341
|
export const widgetHostHides = (name, widgetHost, app = null) => (widgetHost
|
|
3322
|
-
? ((WITHHELD_FROM_WIDGET_HOSTS.has(name) && !widgetAppOffers(name, app)) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
|
|
3342
|
+
? ((WITHHELD_FROM_WIDGET_HOSTS.has(name) && !widgetAppOffers(name, app)) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RETIRED_NAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
|
|
3323
3343
|
: (Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name) || Object.prototype.hasOwnProperty.call(WIDGET_SPLIT_OF, name)));
|
|
3324
3344
|
// HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
|
|
3325
3345
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
@@ -3755,16 +3775,13 @@ const APP_ADS_ALSO = /_post_ad_authorization$|^tiktok_spark_recommendations$/;
|
|
|
3755
3775
|
// Manager, and the read-only post-performance tools written in `create` stay reachable in Social (they generate
|
|
3756
3776
|
// nothing; DIRECTORY_BANNED_GROUPS would otherwise hold them by group alone).
|
|
3757
3777
|
export const APP_DIRECTORY_CREATE_ALLOW = new Set([...APP_SOCIAL_FROM_CREATE].filter((n) => n !== 'plan_week').concat(['list_library', 'fetch_asset', 'get_job', 'list_skills', 'get_skill']));
|
|
3758
|
-
// ON THE CHATGPT APPS
|
|
3759
|
-
//
|
|
3760
|
-
//
|
|
3761
|
-
|
|
3762
|
-
export const
|
|
3763
|
-
|
|
3764
|
-
const
|
|
3765
|
-
const APP_WIDGET_DESCRIPTIONS = Object.freeze({
|
|
3766
|
-
plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 120s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
|
|
3767
|
-
});
|
|
3778
|
+
// ON THE CHATGPT APPS A WIDGET-HOST NAME CAN BE RENAMED AGAIN (APP_WIDGET_RENAMES; empty today). The one it held,
|
|
3779
|
+
// remake_video_for_brand -> plan_video_remake (2026-11-06 scan), became the widget-host name on every ChatGPT roster after
|
|
3780
|
+
// the 2026-11-14 scan flagged the all-in-one app's name too (WIDGET_HOST_RENAMES, WIDGET_HOST_DESCRIPTIONS).
|
|
3781
|
+
export const APP_WIDGET_RENAMES = Object.freeze({});
|
|
3782
|
+
export const APP_WIDGET_ALIAS_OF = Object.freeze({});
|
|
3783
|
+
const APP_WIDGET_TITLES = Object.freeze({});
|
|
3784
|
+
const APP_WIDGET_DESCRIPTIONS = Object.freeze({});
|
|
3768
3785
|
// OPENAI'S FIRST SCAN OF THE TWO NEW APPS (2026-11-06) gave stated reasons on tools ChatGPT had never listed before. On a
|
|
3769
3786
|
// ChatGPT app (widget host) only: the hint OpenAI read as wrong is corrected, a sentence that compares us with other
|
|
3770
3787
|
// products is dropped, and the three bulk tools that take an operations list ("operations that are not individually
|
|
@@ -3806,7 +3823,7 @@ export const APP_DESC_LEADS = Object.freeze({
|
|
|
3806
3823
|
});
|
|
3807
3824
|
export const APP_WIDGET_UNLISTED = new Set(['microsoft_ads_bulk_download', 'microsoft_ads_bulk_upload', 'pinterest_ads_bulk']);
|
|
3808
3825
|
const appWidgetUnlisted = (name) => Object.prototype.hasOwnProperty.call(APP_WIDGET_RENAMES, name) || APP_WIDGET_UNLISTED.has(name);
|
|
3809
|
-
const appBaseName = (name) => APP_WIDGET_ALIAS_OF[name] || WIDGET_HOST_ALIAS_OF[name] || WIDGET_SPLIT_OF[name] || name;
|
|
3826
|
+
const appBaseName = (name) => APP_WIDGET_ALIAS_OF[name] || WIDGET_HOST_ALIAS_OF[name] || WIDGET_HOST_RETIRED_NAMES[name] || WIDGET_SPLIT_OF[name] || name;
|
|
3810
3827
|
export function appScopeHas(app, name, group, directory = false) {
|
|
3811
3828
|
if (!app) return true;
|
|
3812
3829
|
const n = appBaseName(name);
|
|
@@ -3948,6 +3965,9 @@ export function stripOutputSchema(def) {
|
|
|
3948
3965
|
// unclear. Every other host keeps the longer text, which carries real operating detail; ChatGPT gets the same behaviour
|
|
3949
3966
|
// described plainly. Each override is a complete description: what the tool does, its inputs, its cost.
|
|
3950
3967
|
export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
|
|
3968
|
+
// 2026-11-14 scan (all-in-one app): "name appears promotional, exaggerated, or inconsistent with its behavior". It plans and
|
|
3969
|
+
// renders nothing, so the name and the first words both say so.
|
|
3970
|
+
plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 120s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
|
|
3951
3971
|
search_console_performance: "Google Search Console's performance report for the brand's site: the search queries people used, with impressions, clicks, click-through rate and average position, by query, page, country, device, search appearance, date or hour. startDate and endDate are required, as YYYY-MM-DD (Pacific time). Rows are the top ones Google returns; anonymised queries are withheld, so per-query clicks will not add up to the site total; the default 'final' data lags 2 to 3 days and 'all' adds fresh partial data. Up to 16 months of history. Read-only, 0 credits.",
|
|
3952
3972
|
list_linkedin_events: "The events a LinkedIn company Page organizes: name, link, start and end times, online or in person, visibility and state. Only Pages shared with this profile can be read. It needs LinkedIn's Events permission; if a connection made before that permission was enabled is refused, the user reconnects LinkedIn once. Read-only, 0 credits. Needs LinkedIn connected.",
|
|
3953
3973
|
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook or X post link (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).); in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point before the edit. Costs the flat post_edit price (about 2 credits); a 'generate' segment, which renders a new transition shot with a video model, costs more and is used only when the user asks for one.",
|
|
@@ -4085,7 +4105,7 @@ const widgetProxyHit = (d) => WIDGET_PROXY_REFS.some(([re]) => { re.lastIndex =
|
|
|
4085
4105
|
// definition goes back through OpenAI's scan review: remake_video_for_brand's live text names make_short, and renaming
|
|
4086
4106
|
// that one word to plan_short_video put it back in review, where it was held ("name appears promotional"). Its text
|
|
4087
4107
|
// stays byte-identical to the published one; the make_short handler still answers that name.
|
|
4088
|
-
export const WIDGET_RENAME_KEEP = Object.freeze({ remake_video_for_brand
|
|
4108
|
+
export const WIDGET_RENAME_KEEP = Object.freeze({}); // remake_video_for_brand's entry went with its name (2026-11-14): plan_video_remake carries its own WIDGET_HOST_DESCRIPTIONS text
|
|
4089
4109
|
function renamedForWidgetHost(def, name) {
|
|
4090
4110
|
const keep = (name && WIDGET_RENAME_KEEP[name]) || null;
|
|
4091
4111
|
const d = def && typeof def.description === 'string' ? def.description : '';
|
|
@@ -4610,6 +4630,15 @@ function registerWidgetHostAliases(rawServer, ctx, opts) {
|
|
|
4610
4630
|
ctx.handleOf[to] = h; ctx.groupOf[to] = ctx.groupOf[from]; ctx.srcOf[to] = { def, handler: src.handler };
|
|
4611
4631
|
applyToolGates(h, to, ctx.groupOf[to], ctx, opts);
|
|
4612
4632
|
}
|
|
4633
|
+
for (const [old, from] of Object.entries(WIDGET_HOST_RETIRED_NAMES)) {
|
|
4634
|
+
const src = ctx.srcOf[from];
|
|
4635
|
+
if (!src || !src.def || typeof src.handler !== 'function' || ctx.handleOf[old]) continue;
|
|
4636
|
+
const to = WIDGET_HOST_RENAMES[from]; const title = (to && WIDGET_HOST_ALIAS_TITLES[to]) || src.def.title;
|
|
4637
|
+
const def = { ...src.def, title, ...(src.def.annotations ? { annotations: { ...src.def.annotations, title } } : {}) };
|
|
4638
|
+
const h = rawServer.registerTool(old, defForApp(defForHost(old, def, true), ctx.app, ctx.directory, old, true), src.handler);
|
|
4639
|
+
ctx.handleOf[old] = h; ctx.groupOf[old] = ctx.groupOf[from]; ctx.srcOf[old] = { def, handler: src.handler };
|
|
4640
|
+
applyToolGates(h, old, ctx.groupOf[old], ctx, opts); // widgetHostHides keeps it unlisted; a direct call runs it
|
|
4641
|
+
}
|
|
4613
4642
|
if (!ctx.app) return;
|
|
4614
4643
|
// A ChatGPT app renames once more (APP_WIDGET_RENAMES); the name it replaces stays registered and callable but unlisted.
|
|
4615
4644
|
for (const [alias, to] of Object.entries(APP_WIDGET_RENAMES)) {
|
|
@@ -6203,7 +6232,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6203
6232
|
text: z.string().describe('The reply, exactly as it should appear publicly.'),
|
|
6204
6233
|
},
|
|
6205
6234
|
outputSchema: { ok: z.boolean(), source: z.string().optional(), id: z.string().optional(), note: z.string() },
|
|
6206
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6235
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6207
6236
|
}, wrap(async ({ id, text }) => {
|
|
6208
6237
|
const body = String(text || '').trim();
|
|
6209
6238
|
if (!body) return { content: [{ type: 'text', text: 'Nothing to post — pass the reply text.' }], isError: true };
|
|
@@ -21513,7 +21542,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21513
21542
|
maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
|
|
21514
21543
|
confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
|
|
21515
21544
|
},
|
|
21516
|
-
annotations: {"readOnlyHint": false, "destructiveHint":
|
|
21545
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true},
|
|
21517
21546
|
}, wrap(async (a) => {
|
|
21518
21547
|
const d = await apiPost('/api/meta/rules/limits', a);
|
|
21519
21548
|
return ok(adRuleText(d), d);
|
|
@@ -21651,7 +21680,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21651
21680
|
maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
|
|
21652
21681
|
confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
|
|
21653
21682
|
},
|
|
21654
|
-
annotations: {"readOnlyHint": false, "destructiveHint":
|
|
21683
|
+
annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false},
|
|
21655
21684
|
}, wrap(async (a) => {
|
|
21656
21685
|
const d = await apiPost('/api/google-ads/rules/limits', a);
|
|
21657
21686
|
return ok(adRuleText(d), d);
|
|
@@ -25667,7 +25696,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
25667
25696
|
creative: z.object({}).passthrough().optional().describe('an edited screenplay (the creative this tool returned, changed) to use instead'),
|
|
25668
25697
|
lengthSeconds: z.number().optional().describe('re-outline at this length (throws away the screenplay and boards)'),
|
|
25669
25698
|
},
|
|
25670
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
25699
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
25671
25700
|
}, wrap(async (a) => {
|
|
25672
25701
|
const id = encodeURIComponent(a.runId);
|
|
25673
25702
|
let d = (a.creative || +a.lengthSeconds > 0) ? await apiPost(`/api/story/runs/${id}/edit`, { ...(a.creative ? { creative: a.creative } : {}), ...(+a.lengthSeconds > 0 ? { lengthSeconds: +a.lengthSeconds } : {}) }) : await apiGet(`/api/story/runs/${id}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.541",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1233 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 5 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hermoso-launch-app
|
|
3
|
+
description: >-
|
|
4
|
+
Launch and market an app or site you just built, using Hermoso. Sets up the brand from the app, makes launch
|
|
5
|
+
ads and posts, schedules them on the owner's own social channels and, if asked, builds paused ad campaigns.
|
|
6
|
+
Use when the user says "launch this", "market my app", "get me users", "make launch posts", "promote what we
|
|
7
|
+
just built", or finishes a build in an app builder (Lovable, Replit, Bolt, v0, Base44) and asks what is next.
|
|
8
|
+
NOT for: building the app itself, or a single image from a prompt (use hermoso-generate).
|
|
9
|
+
argument-hint: "[the app's URL or one line about what it does, e.g. 'https://myapp.com, a habit tracker for runners']"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Hermoso: launch the app you just built
|
|
13
|
+
|
|
14
|
+
You built something. This gets it in front of people: a saved brand, launch creative, a week of scheduled posts
|
|
15
|
+
and, only if the user asks, paid campaigns created paused.
|
|
16
|
+
|
|
17
|
+
## Before you start
|
|
18
|
+
- Hermoso has to be connected to this host as an MCP server (URL `https://app.hermoso.ai/mcp`, sign in once).
|
|
19
|
+
If its tools are not available, send the user to https://hermoso.ai/connect/ and pick their host
|
|
20
|
+
(Lovable, Replit, Bolt, v0 and Base44 each have a page with the exact clicks).
|
|
21
|
+
- In a terminal there is no MCP server to connect: the same tools run through the Hermoso CLI. Sign in once with
|
|
22
|
+
`npx -y hermoso auth login` (opens a browser; nothing to paste). On a machine with no browser:
|
|
23
|
+
`npx -y hermoso auth login --token <your key>`, using a key from app.hermoso.ai under **MCP & CLI**. Then
|
|
24
|
+
`hermoso tools --search <what you want>` finds a tool, `hermoso tools <name>` prints its arguments and
|
|
25
|
+
`hermoso call <name> --json '{...}'` runs it (after `npm install -g hermoso`, or prefix each with `npx -y`).
|
|
26
|
+
No account at all? An agent can sign itself up on a paid plan with `POST /v1/signup` at app.hermoso.ai; see
|
|
27
|
+
the Hermoso README.
|
|
28
|
+
- A tool you do not see in your list still exists: `find_tools` searches every Hermoso tool and `call_tool`
|
|
29
|
+
runs any of them by name.
|
|
30
|
+
- The connector works while you build. It is not part of the published app, so never wire Hermoso calls into
|
|
31
|
+
the app's own code unless the user asks for an API integration.
|
|
32
|
+
|
|
33
|
+
## The launch, in order
|
|
34
|
+
Do only the steps the user wants. Say what you are about to do before anything that spends credits or posts.
|
|
35
|
+
|
|
36
|
+
1. **Know the product.** Use what you already know from the build: what the app does, who it is for, its name,
|
|
37
|
+
its live URL. Ask only for what is missing. One line is enough.
|
|
38
|
+
2. **Save the brand.** If the app has a public URL, call `draft_brand` with it so every ad matches the app's
|
|
39
|
+
look and voice. No URL yet? Call `draft_brand` with a short description instead. `get_brand` shows what is
|
|
40
|
+
already saved; do not draft twice.
|
|
41
|
+
3. **See what works in this market (optional, costs credits).** `find_competitors` then `research_ads`, and
|
|
42
|
+
report the two or three hooks that keep showing up. Skip this if the user just wants posts.
|
|
43
|
+
4. **Make the launch creative.** Three is a good first set: a static launch ad, a carousel or second static
|
|
44
|
+
with a different angle, and one short vertical video if the user wants video. `generate_image` and
|
|
45
|
+
`render_ad` pick sound defaults when you do not name a model. Video takes a few minutes: read the job with
|
|
46
|
+
`get_job` and never call it done before it finishes. Show the user each result.
|
|
47
|
+
5. **Check the channels.** `list_connectors` says which social accounts are connected. None connected is not
|
|
48
|
+
an error: give the user the connect link from the tool result, or tell them to open Hermoso and connect
|
|
49
|
+
the channels they want under Connectors. Do not post anywhere the user did not name.
|
|
50
|
+
6. **Schedule the week.** Show the exact caption and the exact account for each post and get a yes. Then
|
|
51
|
+
`schedule_post`, one a day, at the times the user picks or mornings in their time zone. `list_scheduled`
|
|
52
|
+
reads the queue back. Never rewrite or shorten a caption the user wrote.
|
|
53
|
+
7. **Paid ads, only if asked.** Campaigns are created paused on the user's own ad accounts and read back
|
|
54
|
+
before you describe them. Turning one on is the user's decision, stated in their own words.
|
|
55
|
+
8. **Report.** What was made (with links), what is scheduled and when, what is waiting on the user (a channel
|
|
56
|
+
to connect, a campaign to turn on), and the one thing to do next week: read what performed with
|
|
57
|
+
`post_performance` and make more of that.
|
|
58
|
+
|
|
59
|
+
## Rules
|
|
60
|
+
- Nothing is published, scheduled or spent on ads without the user saying so in this conversation.
|
|
61
|
+
- If the free credits run out, say so plainly and stop. Do not swap in a cheaper version of what was asked.
|
|
62
|
+
- Use the app's real name, screens and URL. Do not invent features, prices, reviews or numbers for the ads.
|