hermoso 0.1.515 → 0.1.518

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/mcp/client.mjs CHANGED
@@ -546,7 +546,8 @@ export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent
546
546
  // ended the follow-up for good. A 404 is re-read for a short window before it is reported as absent; any other answer
547
547
  // returns at once, and an id that really does not exist costs only that window. null = absent after the window.
548
548
  export const JOB_READ_GRACE_MS = 12_000;
549
- export async function getJobSettled(id, { graceMs = JOB_READ_GRACE_MS, intervalMs = 3000, getJobFn = getJob } = {}) {
549
+ // Only a job_-shaped id gets the grace: anything else (a file link, a made-up id) was never a job, so one read settles it.
550
+ export async function getJobSettled(id, { graceMs = /^job_/i.test(String(id ?? '').trim()) ? JOB_READ_GRACE_MS : 0, intervalMs = 3000, getJobFn = getJob } = {}) {
550
551
  const until = Date.now() + Math.max(0, graceMs);
551
552
  for (;;) {
552
553
  try { return await getJobFn(id); }
package/mcp/tools.mjs CHANGED
@@ -3978,6 +3978,28 @@ const TEXT_STYLE_Z = z.union([
3978
3978
  tilt: z.number().optional().describe('degrees, ±45'), cardColor: z.string().optional(),
3979
3979
  }),
3980
3980
  ]);
3981
+ // ── WHAT get_job SAYS WHEN IT FINDS NOTHING (chat review 2026-11-04, fi:c9eb3a08e010) ─────────────────────────────────
3982
+ // Two generate_voice calls were billed and get_job was then polled eight times with an id that was not a job: the voice
3983
+ // tools finish inside their own call and return the file, they never had a job. A real job id is `job_…` and comes back
3984
+ // as `jobId` from a render that runs in the background, or from list_jobs. Anything else is read ONCE (an id the server
3985
+ // knows is never refused on its shape) and, when nothing is there, the reply names the id, says what it looks like and
3986
+ // where real ids come from. PURE, so tools/voice-job-id-check.mjs runs it.
3987
+ export const JOB_ID_RE = /^job_[a-z0-9]+$/i;
3988
+ export const INLINE_RESULT_NOTE = 'Finished in this call: the file above is the result, so there is no job to poll with get_job.';
3989
+ export function jobMissReply(id) {
3990
+ const s = String(id ?? '').trim();
3991
+ if (JOB_ID_RE.test(s)) {
3992
+ return { text: `No job with id "${s}". Nothing is rendering under that id: it does not exist on this workspace (it may have been mistyped, or it may belong to another account or brand). STOP POLLING IT. Do NOT re-run the render on the strength of this: call list_jobs to see this workspace's recent jobs and their real ids.`,
3993
+ error: `No job with id "${s}" on this workspace.` };
3994
+ }
3995
+ const fileLike = /^(https?:)?\/\//i.test(s) || /^\/(generated|api|uploads?)\//i.test(s) || /\.(mp3|wav|m4a|aac|ogg|flac|mp4|mov|webm|png|jpe?g|webp|gif|avif|pdf)(\?.*)?$/i.test(s);
3996
+ return { text: `"${s}" is not a job id, and nothing on this workspace is rendering under it. STOP POLLING IT.`
3997
+ + (fileLike ? ' It is a file link: that file is already finished, so use it as it is.' : '')
3998
+ + ' Real job ids start with job_ and come back as jobId from a render that runs in the background (generate_video, render_ad, edit_video, plan_week and the like), or from list_jobs.'
3999
+ + ' generate_voice and generate_sfx never make a job: they return their MP3 directly in the same reply, so there is nothing to poll for them.'
4000
+ + ' Do NOT re-run anything on the strength of this.',
4001
+ error: `"${s}" is not a job id.` };
4002
+ }
3981
4003
  export function registerTools(rawServer, opts = {}) {
3982
4004
  const server = wellFormedServer(rawServer); // the ONE crossing — see above
3983
4005
  // THE CACHE HOLDS SCHEMAS, SO A FLAG THAT CHANGES A SCHEMA MUST INVALIDATE IT (2026-09-16). `hosted` decides
@@ -9343,6 +9365,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9343
9365
  instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, explore, profile_feed, …'),
9344
9366
  messengerPositions: z.array(z.string()).optional().describe('Messenger placements, e.g. sponsored_messages'), audienceNetworkPositions: z.array(z.string()).optional().describe('Audience Network placement names, as Meta defines them'),
9345
9367
  devicePlatforms: z.array(z.enum(['mobile', 'desktop'])).optional().describe('mobile or desktop'), userOs: z.array(z.enum(['iOS', 'Android'])).optional().describe('iOS or Android'),
9368
+ deviceExpansion: z.boolean().optional().describe('only with devicePlatforms / userOs: true lets Meta also show the ad on OTHER devices and operating systems (its "Reach more people across other mobile devices", Sales campaigns only). Default false keeps delivery to exactly the devices and OS you targeted — Hermoso sends the opt-out explicitly, because Meta turns this on by default for new Sales ad sets from Graph v27'),
9346
9369
  advantageAudience: z.boolean().optional().describe('let Meta expand beyond your audience (Advantage+ audience)'),
9347
9370
  }).loose().optional(); // loose for the same reason as metaGeoShape — the server REFUSES an unknown targeting key by name; this twin must not swallow it first
9348
9371
  const metaAdSetFields = {
@@ -10082,7 +10105,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10082
10105
  }));
10083
10106
  server.registerTool('create_meta_audience', {
10084
10107
  title: 'Create a Meta custom audience or lookalike',
10085
- description: 'Build a retargeting audience on a connected Meta ad account. Four kinds: kind:"website" (people whose visited URL contains urlContains, seen by pixelId — pass the brand’s own domain for "all visitors"; retentionDays up to 180), kind:"engagement" (people who did `event` on the brand’s Facebook Page, or its Instagram business profile with source:"instagram"; retentionDays up to 730), or kind:"lookalike" (sourceAudienceId + country + ratio 0.01–0.20, lookalikeType "similarity" or "reach"). CREATING AN AUDIENCE SPENDS NOTHING — it is a definition; money only moves when an ad set targets it and that campaign is activated through set_meta_campaign_status(confirm:true). Meta needs roughly 30 minutes and ~1,000 people before a new audience can be targeted, so a fresh one reporting no size is normal. kind:"customerfile" is the brand\'s OWN CUSTOMER LIST (emails, phones, names…) for retargeting, suppression or as a lookalike seed: pass customerFileSource (a required attestation of where the data came from: USER_PROVIDED_ONLY, PARTNER_PROVIDED_ONLY or BOTH_USER_AND_PARTNER_PROVIDED), plus schema (the column names in order, from EMAIL, PHONE, FN, LN, FI, CT, ST, ZIP, COUNTRY, DOB, DOBY, GEN, MADID, EXTERN_ID) and rows (arrays in that order, up to 10,000 per call) to upload them in the same call. Hermoso normalizes and SHA-256-hashes every value before it leaves the server, exactly as Meta requires, and reports Meta’s own match counts (a mostly-rejected upload is a format problem and says so). To ADD more people to an existing list, call again with kind:"customerfile" + audienceId + schema + rows (uploads add, they never replace). Meta asks the ad account to accept its Custom Audience Terms once; if it has not, Meta’s refusal says so.',
10108
+ description: 'Build a retargeting audience on a connected Meta ad account. Four kinds: kind:"website" (people whose visited URL contains urlContains, seen by pixelId — pass the brand’s own domain for "all visitors"; retentionDays up to 180), kind:"engagement" (people who did `event` on the brand’s Facebook Page, or its Instagram business profile with source:"instagram"; retentionDays up to 730), or kind:"lookalike" (sourceAudienceId + country + ratio 0.01–0.20 = the top 1%–20% most similar people, lookalikeType "similarity" or "reach", or startingRatio for a band such as 1%–3%). Meta delivers a lookalike to the locations of the AD SET that targets it, so `country` is where you will run it — target the same country in that ad set. CREATING AN AUDIENCE SPENDS NOTHING — it is a definition; money only moves when an ad set targets it and that campaign is activated through set_meta_campaign_status(confirm:true). Meta needs roughly 30 minutes and ~1,000 people before a new audience can be targeted, so a fresh one reporting no size is normal. kind:"customerfile" is the brand\'s OWN CUSTOMER LIST (emails, phones, names…) for retargeting, suppression or as a lookalike seed: pass customerFileSource (a required attestation of where the data came from: USER_PROVIDED_ONLY, PARTNER_PROVIDED_ONLY or BOTH_USER_AND_PARTNER_PROVIDED), plus schema (the column names in order, from EMAIL, PHONE, FN, LN, FI, CT, ST, ZIP, COUNTRY, DOB, DOBY, GEN, MADID, EXTERN_ID) and rows (arrays in that order, up to 10,000 per call) to upload them in the same call. Hermoso normalizes and SHA-256-hashes every value before it leaves the server, exactly as Meta requires, and reports Meta’s own match counts (a mostly-rejected upload is a format problem and says so). To ADD more people to an existing list, call again with kind:"customerfile" + audienceId + schema + rows (uploads add, they never replace). Meta asks the ad account to accept its Custom Audience Terms once; if it has not, Meta’s refusal says so.',
10086
10109
  inputSchema: {
10087
10110
  adAccountId: z.string().describe('ad account id (act_… or digits)'),
10088
10111
  kind: z.enum(['website', 'engagement', 'lookalike', 'customerfile']).describe('which kind of audience to build — customerfile = your own customer list'),
@@ -10095,9 +10118,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
10095
10118
  source: z.enum(['page', 'instagram']).optional().describe('engagement: Facebook Page (default) or the linked Instagram business profile'),
10096
10119
  event: z.string().optional().describe('engagement: page_engaged | page_visited | page_liked | page_messaged | page_cta_clicked | page_or_post_save | page_post_interaction — or ig_business_profile_all | ig_business_profile_engaged | ig_user_messaged_business | ig_business_profile_visit'),
10097
10120
  sourceAudienceId: z.string().optional().describe('lookalike: the existing audience to model (from list_meta_audiences)'),
10098
- country: z.string().optional().describe('lookalike: 2-letter country to build it in'),
10099
- ratio: z.number().optional().describe('lookalike: 0.01–0.20 = the top 1%–20% most similar people in that country (default 0.01)'),
10100
- startingRatio: z.number().optional().describe('lookalike: optional lower bound, must be less than ratio'),
10121
+ country: z.string().optional().describe('lookalike: REQUIRED 2-letter country you will run it in — Meta delivers a lookalike to the locations of the ad set that targets it, so target this country there'),
10122
+ ratio: z.number().optional().describe('lookalike: 0.01–0.20 = the top 1%–20% most similar people (default 0.01)'),
10123
+ startingRatio: z.number().optional().describe('lookalike: optional lower bound for a band (e.g. 0.01 with ratio 0.03 = the 1%–3% band) — at least 0.01 and less than ratio; not combinable with lookalikeType'),
10101
10124
  lookalikeType: z.enum(['similarity', 'reach']).optional().describe('lookalike: similarity (tighter) or reach (broader) — default similarity'),
10102
10125
  prefill: z.boolean().optional().describe('website/engagement: seed it with activity from BEFORE the audience existed (default true)'),
10103
10126
  customerFileSource: z.enum(['USER_PROVIDED_ONLY', 'PARTNER_PROVIDED_ONLY', 'BOTH_USER_AND_PARTNER_PROVIDED']).optional().describe('customerfile: REQUIRED attestation of where the list came from (customers gave it to you = USER_PROVIDED_ONLY)'),
@@ -17617,11 +17640,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
17617
17640
  }));
17618
17641
  server.registerTool('create_reddit_ads_catalog', {
17619
17642
  title: 'Create a Reddit product catalog',
17620
- description: 'Create an empty Reddit PRODUCT CATALOG on the business behind the shared ad account — the product inventory a catalog SALES campaign advertises (dynamic product ads, collection ads). Spends nothing. Name it, give the CURRENCY your prices are in (USD, GBP, CAD, EUR, AUD, JPY, CHF, NZD, SEK or NOK — Reddit’s list) and optionally the language and the Reddit pixel(s) to match products against (list_reddit_ads_pixels; catalog ads optimize on pixel events such as PURCHASE). The currency and language cannot be changed later. Then fill it — upsert_reddit_ads_products for a handful, or create_reddit_ads_product_feed to have Reddit fetch your product file on a schedule — and define which products to advertise with create_reddit_ads_product_set. Reddit requires its Catalog manager role for catalog writes, which Business admin does not include (a login that can run campaigns can still be refused “no permissions to Create Catalog action”) — that refusal is about the role, not the connection, and the reply says how to fix it. Read back from Reddit.',
17643
+ description: 'Create an empty Reddit PRODUCT CATALOG on the business behind the shared ad account — the product inventory a catalog SALES campaign advertises (dynamic product ads, collection ads). Spends nothing. Name it, give the CURRENCY your prices are in (USD, GBP, CAD, EUR, AUD, JPY, CHF, NZD, SEK, NOK or BRL — Reddit’s list) and optionally the language and the Reddit pixel(s) to match products against (list_reddit_ads_pixels; catalog ads optimize on pixel events such as PURCHASE). The currency and language cannot be changed later. Then fill it — upsert_reddit_ads_products for a handful, or create_reddit_ads_product_feed to have Reddit fetch your product file on a schedule — and define which products to advertise with create_reddit_ads_product_set. Reddit requires its Catalog manager role for catalog writes, which Business admin does not include (a login that can run campaigns can still be refused “no permissions to Create Catalog action”) — that refusal is about the role, not the connection, and the reply says how to fix it. Read back from Reddit.',
17621
17644
  inputSchema: {
17622
17645
  adAccountId: z.string().optional().describe('Reddit ad account id (a2_...); omit when only one is shared'),
17623
17646
  name: z.string().describe('catalog name'),
17624
- defaultCurrency: z.enum(['USD', 'GBP', 'CAD', 'EUR', 'AUD', 'JPY', 'CHF', 'NZD', 'SEK', 'NOK']).describe('the currency your product prices are in — fixed once created'),
17647
+ defaultCurrency: z.enum(['USD', 'GBP', 'CAD', 'EUR', 'AUD', 'JPY', 'CHF', 'NZD', 'SEK', 'NOK', 'BRL']).describe('the currency your product prices are in — fixed once created'),
17625
17648
  defaultLanguage: z.enum(['en', 'de', 'es', 'fr', 'it', 'pt']).optional().describe('default en — fixed once created'),
17626
17649
  pixelIds: z.array(z.string()).optional().describe('Reddit pixel ids from list_reddit_ads_pixels to match products against; each must be on the shared ad account'),
17627
17650
  },
@@ -22811,6 +22834,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22811
22834
  credits: z.number().optional().describe('dryRun: the exact credits it would charge'),
22812
22835
  dryRun: z.boolean().optional(),
22813
22836
  creditsUsed: z.number().optional().describe('credits billed for this clip'),
22837
+ nothingToPoll: z.boolean().optional().describe('true: the MP3 in `audio` is the finished result, never call get_job for it'),
22814
22838
  },
22815
22839
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
22816
22840
  }, wrap(async ({ text, engine, voice, lines, dryRun }) => {
@@ -22818,13 +22842,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
22818
22842
  const d = await apiPost('/api/generate/voice', { lines, ...(engine ? { engine } : {}), ...(dryRun ? { dryRun: true } : {}) });
22819
22843
  const cast = (d.cast || []).map((c) => `${c.speaker}: ${c.voice}`).join(', ');
22820
22844
  if (d.dryRun) return ok(`DIALOGUE QUOTE (nothing made, nothing charged): ${d.lines} lines, ${d.characters} characters, ${cast}, on ${d.model}. Price: ${d.credits} credits, exactly what it charges. Call again without dryRun to make it.`, d);
22821
- return ok(`Dialogue ready (${d.durationSeconds ?? '?'}s, ${d.model}; ${cast}, ${d.creditsUsed} credits): ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
22845
+ return ok(`Dialogue ready (${d.durationSeconds ?? '?'}s, ${d.model}; ${cast}, ${d.creditsUsed} credits): ${abs(d.audio)}\n${INLINE_RESULT_NOTE}`, { ...d, audio: abs(d.audio), nothingToPoll: true });
22822
22846
  }
22823
22847
  const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}), ...(dryRun ? { dryRun: true } : {}) });
22824
22848
  if (d.dryRun) return ok(`VOICE QUOTE (nothing made, nothing charged): ${d.characters} characters on ${d.model}. Price: ${d.credits} credits, exactly what it charges.`, d);
22825
22849
  // REPORT WHAT SPOKE (GEN-2). `d.voice` is the DISPATCHED voice and is null on a fixed-voice engine — the server
22826
22850
  // refuses an unknown voice for free rather than rendering in a different one and echoing the ask back.
22827
- return ok(`Voice clip ready — ${d.voice || 'the engine’s own voice'}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}${d.voiceNote ? `\n⚠ ${d.voiceNote}` : ''}`, { ...d, audio: abs(d.audio) });
22851
+ // NO JOB TO POLL (fi:c9eb3a08e010): the clip is finished in this reply, said in words and in structuredContent.
22852
+ return ok(`Voice clip ready — ${d.voice || 'the engine’s own voice'}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}${d.voiceNote ? `\n⚠ ${d.voiceNote}` : ''}\n${INLINE_RESULT_NOTE}`, { ...d, audio: abs(d.audio), nothingToPoll: true });
22828
22853
  }));
22829
22854
 
22830
22855
  // A SOUND EFFECT OR AMBIENCE MADE FROM WORDS (2026-11-03): POST /api/generate/sfx, the same engine as the Studio's generate_sfx.
@@ -22850,12 +22875,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
22850
22875
  credits: z.number().optional().describe('dryRun: the exact credits it would charge'),
22851
22876
  dryRun: z.boolean().optional(),
22852
22877
  creditsUsed: z.number().optional(),
22878
+ nothingToPoll: z.boolean().optional().describe('true: the MP3 in `audio` is the finished result, never call get_job for it'),
22853
22879
  },
22854
22880
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
22855
22881
  }, wrap(async ({ describe, seconds, loop, engine, dryRun }) => {
22856
22882
  const d = await apiPost('/api/generate/sfx', { describe, ...(seconds ? { seconds } : {}), ...(loop ? { loop: true } : {}), ...(engine ? { engine } : {}), ...(dryRun ? { dryRun: true } : {}) });
22857
22883
  if (d.dryRun) return ok(`SOUND QUOTE (nothing made, nothing charged): ${d.seconds}s${d.loop ? ' loopable' : ''} on ${d.model}. Price: ${d.credits} credits, exactly what it charges.`, d);
22858
- return ok(`Sound ready (${d.durationSeconds ?? '?'}s, ${d.model}, ${d.creditsUsed} credits): ${abs(d.audio)}${d.exactLength === false ? '\nThe length was asked for in words, so it may run a little long or short.' : ''}${d.loopNote ? `\n${d.loopNote}` : ''}`, { ...d, audio: abs(d.audio) });
22884
+ return ok(`Sound ready (${d.durationSeconds ?? '?'}s, ${d.model}, ${d.creditsUsed} credits): ${abs(d.audio)}${d.exactLength === false ? '\nThe length was asked for in words, so it may run a little long or short.' : ''}${d.loopNote ? `\n${d.loopNote}` : ''}\n${INLINE_RESULT_NOTE}`, { ...d, audio: abs(d.audio), nothingToPoll: true });
22859
22885
  }));
22860
22886
 
22861
22887
  server.registerTool('generate_music', {
@@ -23869,7 +23895,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23869
23895
  server.registerTool('get_job', {
23870
23896
  title: 'Get render job',
23871
23897
  description: 'Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall. An id that does not exist on this account answers status "not_found" — that is FINAL: stop polling it, and do not re-fire the render (that double-charges).',
23872
- inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
23898
+ inputSchema: { id: z.string().describe('the job id: starts with job_, from a render tool\'s jobId or from list_jobs (generate_voice and generate_sfx return their file directly and have no job)') },
23873
23899
  outputSchema: {
23874
23900
  id: z.string().optional().describe('the job id'),
23875
23901
  status: z.string().optional().describe('queued | running | done | error | not_found (no job with this id on this account)'),
@@ -23903,7 +23929,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
23903
23929
  // guessing from prose. The sentence below is unambiguous for the model; `status:'not_found'` is unambiguous for
23904
23930
  // the card; and both say STOP rather than leaving a poller to grind for six minutes.
23905
23931
  const j = await getJobSettled(id); // a 404 is re-read for a few seconds first: a revision taking over mid-render has not yet seen the job (getJobSettled)
23906
- if (!j) return ok(`No job with id "${id}". Nothing is rendering under that id — it does not exist on this workspace (it may have been mistyped, or it may belong to another account or brand). STOP POLLING IT. Do NOT re-run the render on the strength of this: call list_jobs to see this workspace's recent jobs and their real ids.`, { id, status: 'not_found', progress: 0, error: `No job with id "${id}" on this workspace.`, url: null });
23932
+ // The sentence says what the id LOOKS like (jobMissReply): a voice clip's file link polled as a job is told it is a
23933
+ // finished file, and where real job ids come from (fi:c9eb3a08e010). A non-job_ id is read once (getJobSettled).
23934
+ if (!j) { const miss = jobMissReply(id); return ok(miss.text, { id, status: 'not_found', progress: 0, error: miss.error, url: null }); }
23907
23935
  const res = jobResult(j);
23908
23936
  const url = abs(res?.video || res?.image || res?.url);
23909
23937
  // A RESUMED RENDER READS THE SAME SENTENCE AS AN INLINE ONE. `stillRendering` makes this tool the ordinary way a
@@ -25770,7 +25798,7 @@ function memoryNoteVerdict(text) {
25770
25798
  server.registerTool('search_instagram', {
25771
25799
  _meta: openaiMeta(AD_SPY_URI, 'Searching Instagram reels…', 'Found Instagram reels'),
25772
25800
  title: 'Search Instagram',
25773
- 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.",
25801
+ 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. It matches captions worldwide and cannot be limited to a country or language: for one market, search in the words people there use, or read named local accounts' Reels through fetch_social_data's Instagram profile endpoint, which is the better first step for a local market. Spends about a credit.",
25774
25802
  inputSchema: {
25775
25803
  query: z.string().describe('keyword to search reels for'),
25776
25804
  limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.515",
3
+ "version": "0.1.518",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1175 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 10 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",