hermoso 0.1.262 → 0.1.263
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 +1 -1
- package/bin/hermoso.mjs +1 -1
- package/mcp/client.mjs +14 -1
- package/mcp/http.mjs +1 -1
- package/mcp/public-url.mjs +102 -0
- package/mcp/tools.mjs +30 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,7 +174,7 @@ claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermos
|
|
|
174
174
|
Codex, Gemini CLI and the rest are in [Install in one command](#install-in-one-command).
|
|
175
175
|
3. **Sign in once.** `npx -y hermoso auth login` opens your browser; your agent also runs it by itself the first
|
|
176
176
|
time it needs Hermoso. On a machine with no browser, use `npx -y hermoso auth login --token hmk_…` with a key
|
|
177
|
-
from **
|
|
177
|
+
from the **MCP & CLI** tab, under **Terminal & API keys**.
|
|
178
178
|
4. **Ask for what you want**, in your normal prompts. Claude Code picks the Hermoso skill for the job and runs the
|
|
179
179
|
commands. You type none of them.
|
|
180
180
|
|
package/bin/hermoso.mjs
CHANGED
|
@@ -77,7 +77,7 @@ async function main() {
|
|
|
77
77
|
if (isLocal) { await saveConfig({ apiBase, token: '', profile }); return console.log(`✓ Local dev — no auth required. API: ${apiBase}`); }
|
|
78
78
|
if (sub === 'login') { // browser sign-in: spin a loopback server, open the app's /?cliauth page, receive a minted key
|
|
79
79
|
const key = await browserLogin(apiBase);
|
|
80
|
-
if (!key) return die(`Sign-in didn’t complete. Re-run "hermoso auth login", or paste a key: hermoso auth login --token <key> (create one in the app under
|
|
80
|
+
if (!key) return die(`Sign-in didn’t complete. Re-run "hermoso auth login", or paste a key: hermoso auth login --token <key> (create one in the app on the MCP & CLI tab, under Terminal & API keys).`);
|
|
81
81
|
await saveConfig({ apiBase, token: key, profile });
|
|
82
82
|
return console.log(`✓ Signed in — key stored (~/.hermoso/config.json). API: ${apiBase}`);
|
|
83
83
|
}
|
package/mcp/client.mjs
CHANGED
|
@@ -365,6 +365,16 @@ export async function submitJob(type, input, { label = '' } = {}) {
|
|
|
365
365
|
export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
|
|
366
366
|
export function jobResult(job) { const r = job?.result; return r && Object.prototype.hasOwnProperty.call(r, 'data') ? r.data : r; }
|
|
367
367
|
|
|
368
|
+
// HOW LONG A RENDER TOOL HOLDS ITS CALLER, in ms. `cap` is the transport's own maximum (45s hosted, 10min local) and
|
|
369
|
+
// stays the answer for anything that is not a usable number — absent, negative, NaN — so a garbled ask can never
|
|
370
|
+
// lengthen a wait or turn into "forever". 0 means "do not wait at all". PURE, so a check runs it.
|
|
371
|
+
export function jobWaitMs(requested, cap) {
|
|
372
|
+
if (requested === undefined || requested === null || requested === '') return cap;
|
|
373
|
+
const n = Number(requested);
|
|
374
|
+
if (!Number.isFinite(n) || n < 0) return cap;
|
|
375
|
+
return Math.min(cap, Math.floor(n));
|
|
376
|
+
}
|
|
377
|
+
|
|
368
378
|
export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick } = {}) {
|
|
369
379
|
const deadline = Date.now() + timeoutMs;
|
|
370
380
|
for (;;) {
|
|
@@ -373,7 +383,10 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
|
|
|
373
383
|
if (job.status === 'done') return { job, result: jobResult(job) };
|
|
374
384
|
if (job.status === 'error') throw new Error(job.error || 'Render failed');
|
|
375
385
|
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
|
|
376
|
-
|
|
386
|
+
// NEVER SLEEP PAST THE DEADLINE. A 3s interval made every wait 3s-granular: a caller who asked for 1s was held 3s,
|
|
387
|
+
// and one who asked for 29s was held 30s, which is the whole 30-second step budget the ask exists to stay inside.
|
|
388
|
+
// The defaults (45s, 10min) are whole multiples of the interval, so they poll exactly as they always have.
|
|
389
|
+
await new Promise(r => setTimeout(r, Math.min(intervalMs, Math.max(50, deadline - Date.now()))));
|
|
377
390
|
}
|
|
378
391
|
}
|
|
379
392
|
|
package/mcp/http.mjs
CHANGED
|
@@ -130,7 +130,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
130
130
|
how_to_connect: {
|
|
131
131
|
oauth: `Open ${BASE}/oauth/authorize through your client's own connector flow (a browser is required).`,
|
|
132
132
|
no_browser_stdio: `Add Hermoso as a STDIO command instead of a URL: run \`npx -y hermoso mcp\` with the environment variable HERMOSO_TOKEN set to a key from ${BASE}/#developers. THE TOKEN IS NOT OPTIONAL HERE: a sandbox that could not open a browser for OAuth cannot open one for \`hermoso auth login\` either, so plain stdio with no token connects and then has no account to act on. (On your own machine, where a browser exists, \`npx -y hermoso auth login\` once replaces the token.)`,
|
|
133
|
-
no_browser_header: `Keep this URL and send an \`Authorization: Bearer <key>\` header. Create the key at ${BASE}/#developers (
|
|
133
|
+
no_browser_header: `Keep this URL and send an \`Authorization: Bearer <key>\` header. Create the key at ${BASE}/#developers (the MCP & CLI tab, under Terminal & API keys). A key in the URL query does NOT work and is not supported.`,
|
|
134
134
|
},
|
|
135
135
|
// Named so an agent can branch on it rather than parsing prose.
|
|
136
136
|
retry_will_not_help: true,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// THE ONE ABSOLUTISER — a served path becomes a URL the CALLER can open (2026-09-20)
|
|
3
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// WHAT WENT WRONG. A render that has not moved to the durable asset host is recorded as a served path
|
|
6
|
+
// (`/generated/…`), and every tool turned that into a link by prefixing `API_BASE`. `API_BASE` is the address the
|
|
7
|
+
// tool layer uses to CALL the app, and on the hosted transport and the `/v1` passthrough that is a loopback
|
|
8
|
+
// self-call (`http://127.0.0.1:<port>`) so a tool's own `/api` calls never leave the instance. Correct for
|
|
9
|
+
// calling; wrong for a link handed to somebody on the other side of the internet. `list_library` answered
|
|
10
|
+
// `http://127.0.0.1:8080/generated/…` to a REST caller on 2026-09-20, and it had its own hand-written copy of the
|
|
11
|
+
// prefixing, which is why fixing `abs()` alone would not have closed it.
|
|
12
|
+
//
|
|
13
|
+
// THE RULE. The base used to CALL the API and the origin used to LINK to an asset are two different facts:
|
|
14
|
+
// • a LOCAL caller (stdio, the CLI) shares a machine with whatever `API_BASE` names, so that base is the right
|
|
15
|
+
// link, loopback included (a self-hoster on localhost gets a localhost link, which is what they can open);
|
|
16
|
+
// • a REMOTE caller (hosted MCP, `/v1`) can never reach a loopback or private address, so one is NEVER emitted:
|
|
17
|
+
// a served path is joined to the PUBLIC origin, and an already-absolute URL that names an unroutable host has
|
|
18
|
+
// its origin replaced. That second half is what makes this a guarantee rather than a fix for one call site: a
|
|
19
|
+
// row stored with a loopback origin months ago is repaired on the way out too.
|
|
20
|
+
//
|
|
21
|
+
// PURE, NO IMPORTS. This file is a byte-twin of `cli/mcp/public-url.mjs` (every `.mjs` in both directories is),
|
|
22
|
+
// and `lib/public-api-v1-mount.mjs` imports it directly, so `/v1` and the tools cannot disagree about what a
|
|
23
|
+
// public link is. It deliberately does NOT live in `client.mjs`: that module reads the API base at import time,
|
|
24
|
+
// and importing it from the server before `HEIST_API_BASE` is set would pin every tool's self-call to the default.
|
|
25
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
/** Where a public link points when nothing better is configured. The app's own origin serves `/generated/…`. */
|
|
28
|
+
export const PUBLIC_ORIGIN_DEFAULT = 'https://app.hermoso.ai';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* TRUE for a hostname no caller outside this machine or network can reach: loopback, the unspecified address,
|
|
32
|
+
* RFC 1918 private ranges, link-local (which includes the cloud metadata address), CGNAT, IPv6 loopback /
|
|
33
|
+
* unique-local / link-local, and the conventional internal suffixes. An unparseable host counts as unroutable:
|
|
34
|
+
* a link we cannot read is not one to hand out. PURE.
|
|
35
|
+
*/
|
|
36
|
+
export function isUnroutableHost(hostname) {
|
|
37
|
+
let h = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
38
|
+
if (!h) return true;
|
|
39
|
+
if (h.startsWith('::ffff:')) h = h.slice(7); // an IPv4-mapped IPv6 literal is judged as the IPv4 it carries
|
|
40
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.lan')) return true;
|
|
41
|
+
if (h === '::' || h === '::1' || /^f[cd][0-9a-f]{2}:/.test(h) || /^fe[89ab][0-9a-f]:/.test(h)) return true;
|
|
42
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
|
|
43
|
+
if (m) {
|
|
44
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
45
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
46
|
+
if (a === 169 && b === 254) return true;
|
|
47
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
48
|
+
if (a === 192 && b === 168) return true;
|
|
49
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// A bare single-label name ("app", "hermoso-internal") only resolves inside a private network.
|
|
53
|
+
if (!h.includes('.') && !h.includes(':')) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A configured origin, or null when it is absent, unparseable, not http(s), or unroutable. PURE. */
|
|
58
|
+
function usableOrigin(candidate) {
|
|
59
|
+
const s = String(candidate ?? '').trim();
|
|
60
|
+
if (!s) return null;
|
|
61
|
+
let u; try { u = new URL(s); } catch { return null; }
|
|
62
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
63
|
+
if (isUnroutableHost(u.hostname)) return null;
|
|
64
|
+
return u.origin;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* THE PUBLIC ORIGIN. Configured (`APP_PUBLIC_URL`, the name server.js reads, then the two older spellings),
|
|
69
|
+
* never asserted by a caller, and never a request's Host header. A configured value that is itself unroutable is
|
|
70
|
+
* IGNORED rather than honoured: this function's one job is that its answer can be opened from outside. PURE.
|
|
71
|
+
*/
|
|
72
|
+
export function publicOrigin(env = {}) {
|
|
73
|
+
return usableOrigin(env?.APP_PUBLIC_URL) || usableOrigin(env?.APP_URL) || usableOrigin(env?.PUBLIC_BASE) || PUBLIC_ORIGIN_DEFAULT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Absolutise an asset reference for the caller who will open it.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} u a served path (`/generated/x.mp4`), or an already-absolute URL
|
|
80
|
+
* @param {object} o
|
|
81
|
+
* @param {string} o.base the base the tool layer CALLS the API on (may be loopback)
|
|
82
|
+
* @param {boolean} o.remote true when the caller is not on this machine (hosted MCP, `/v1`)
|
|
83
|
+
* @param {object} o.env where a configured public origin is read from
|
|
84
|
+
*
|
|
85
|
+
* Anything that is not a string, is empty, or is not http(s) / a served path (a `data:` URI, a bare id) is
|
|
86
|
+
* returned untouched: this decides where a link points, never whether a value is a link. PURE.
|
|
87
|
+
*/
|
|
88
|
+
export function absolutizeAssetUrl(u, { base = '', remote = false, env = {} } = {}) {
|
|
89
|
+
if (typeof u !== 'string' || !u) return u;
|
|
90
|
+
const pub = publicOrigin(env);
|
|
91
|
+
if (u.startsWith('/') && !u.startsWith('//')) {
|
|
92
|
+
const b = String(base || '').replace(/\/+$/, '');
|
|
93
|
+
let baseHost = null; try { baseHost = new URL(b).hostname; } catch { baseHost = null; }
|
|
94
|
+
// No usable base at all is treated like an unroutable one: a bare path is not a link for anybody.
|
|
95
|
+
const useBase = b && baseHost !== null && !(remote && isUnroutableHost(baseHost));
|
|
96
|
+
return (useBase ? b : pub) + u;
|
|
97
|
+
}
|
|
98
|
+
if (!remote || !/^https?:\/\//i.test(u)) return u;
|
|
99
|
+
let parsed; try { parsed = new URL(u); } catch { return u; }
|
|
100
|
+
if (!isUnroutableHost(parsed.hostname)) return u;
|
|
101
|
+
return pub + parsed.pathname + parsed.search + parsed.hash;
|
|
102
|
+
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
|
|
5
5
|
// needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import {
|
|
7
|
+
import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
|
|
8
|
+
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
|
|
8
9
|
import { readFile } from 'node:fs/promises';
|
|
9
10
|
import { createHash } from 'node:crypto';
|
|
10
11
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -23,7 +24,13 @@ import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './too
|
|
|
23
24
|
import { withHints } from './tool-hints.mjs';
|
|
24
25
|
|
|
25
26
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
26
|
-
|
|
27
|
+
// /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
|
|
28
|
+
// transport and the `/v1` passthrough that is a loopback self-call — so prefixing it handed a remote caller
|
|
29
|
+
// `http://127.0.0.1:8080/generated/…` (live on /v1 2026-09-20). The rule lives in public-url.mjs, the ONE absolutiser:
|
|
30
|
+
// a local caller keeps the base it shares a machine with, a remote one never receives a loopback or private host.
|
|
31
|
+
// EVERY link this file builds goes through here. Never prefix `API_BASE` onto a path by hand: `list_library` did, which
|
|
32
|
+
// is exactly how the one tool that lists served paths was the one that leaked.
|
|
33
|
+
const abs = (u) => absolutizeAssetUrl(u, { base: API_BASE, remote: isRemote(), env: process.env });
|
|
27
34
|
// Null-valued keys are STRIPPED from structuredContent (2026-07-20): the SDK validates results against outputSchema
|
|
28
35
|
// server-side, and zod .optional() rejects null — a single null field (e.g. editCredits:null on a key-less deploy)
|
|
29
36
|
// bricked the whole tool result with a protocol-level validation error. Every field in our schemas is optional, so
|
|
@@ -307,7 +314,12 @@ async function imageBlock(url) {
|
|
|
307
314
|
// render inherits the behaviour instead of having to remember it. Fails OPEN: an unknown client keeps the block.
|
|
308
315
|
if (hostRendersWidgets()) return null;
|
|
309
316
|
try {
|
|
310
|
-
|
|
317
|
+
// The link the CALLER gets is public (abs()); the bytes WE read to inline it are on this instance. Reading our own
|
|
318
|
+
// served file back through the public edge would be a round trip out and in for a file on local disk, so a URL
|
|
319
|
+
// under the public origin is fetched on the base this layer already calls the app on. The link is never rewritten.
|
|
320
|
+
const own = publicOrigin(process.env) + '/';
|
|
321
|
+
const src = typeof url === 'string' && url.startsWith(own) && isRemote() ? API_BASE + '/' + url.slice(own.length) : url;
|
|
322
|
+
const r = await fetch(src); if (!r.ok) return null;
|
|
311
323
|
const ct = (r.headers.get('content-type') || 'image/jpeg').split(';')[0];
|
|
312
324
|
if (!/^image\//.test(ct)) return null;
|
|
313
325
|
const buf = Buffer.from(await r.arrayBuffer());
|
|
@@ -478,9 +490,20 @@ const publishWrap = (fn) => {
|
|
|
478
490
|
// RESUMABLE handle instead of dying (the agent polls get_job, which now attaches the poster on done).
|
|
479
491
|
async function renderJob(type, input, label) {
|
|
480
492
|
const job = await submitJob(type, input, { label });
|
|
481
|
-
const
|
|
493
|
+
const ctx = mcpCtx.getStore(); // AsyncLocalStorage ctx only exists on the remote transport
|
|
494
|
+
const remote = !!ctx;
|
|
495
|
+
// THE ONE PLACE A RENDER TOOL WAITS, SO THE ONE PLACE A CALLER'S WAIT IS HONOURED (2026-09-20). `/v1/tools` puts a
|
|
496
|
+
// caller's `?wait=` on the context as `waitMs`; nothing else sets it, so with it absent this is the same 45s / 10min
|
|
497
|
+
// it always was. It can only SHORTEN the wait (jobWaitMs caps it at the transport's own maximum), and it is read
|
|
498
|
+
// AFTER submitJob on purpose: the job is queued through the identical POST /api/jobs — same spend gate, same
|
|
499
|
+
// pre-queue credit check, same reserve and settle in the worker — whether or not anybody waits for it. Waiting was
|
|
500
|
+
// never part of what a render costs, so not waiting cannot change it, and there is no second submit path to drift.
|
|
501
|
+
const waitMs = jobWaitMs(ctx?.waitMs, remote ? 45_000 : JOB_TIMEOUT);
|
|
502
|
+
// wait=0: answer the moment the job is queued. Not even one poll — pollJob sleeps between reads, and a caller with a
|
|
503
|
+
// 30s step budget (an automation platform) asked for the handle, not for a status.
|
|
504
|
+
if (waitMs <= 0) return { jobId: job.id, url: null, stillRendering: true, raw: null };
|
|
482
505
|
try {
|
|
483
|
-
const { result } = await pollJob(job.id, { timeoutMs:
|
|
506
|
+
const { result } = await pollJob(job.id, { timeoutMs: waitMs });
|
|
484
507
|
const url = abs(result?.video || result?.image || result?.url);
|
|
485
508
|
// creditsUsed AND the delivered geometry ride out here deliberately. The widget's own registered
|
|
486
509
|
// description promises "the model that rendered it and credits spent", and the pill only ever appeared
|
|
@@ -19123,7 +19146,7 @@ function memoryNoteVerdict(text) {
|
|
|
19123
19146
|
const kind = a.kind && a.kind !== 'all' ? a.kind : null;
|
|
19124
19147
|
const lim = Math.min(60, Math.max(1, +a.limit || 20));
|
|
19125
19148
|
const assets = list.filter(x => x && x.url && (!kind || x.kind === kind)).slice(0, lim)
|
|
19126
|
-
.map(x => ({ url:
|
|
19149
|
+
.map(x => ({ url: abs(x.url), kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
|
|
19127
19150
|
if (!assets.length) return ok('The Library is empty for this workspace — render something first.', { assets: [] });
|
|
19128
19151
|
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x, i) => ` ${i + 1}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
19129
19152
|
}));
|
|
@@ -19139,7 +19162,7 @@ function memoryNoteVerdict(text) {
|
|
|
19139
19162
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
19140
19163
|
}, wrap(async ({ url, name }) => {
|
|
19141
19164
|
const absolute = abs(url);
|
|
19142
|
-
const dl =
|
|
19165
|
+
const dl = abs(`/api/download?url=${encodeURIComponent(url)}${name ? `&name=${encodeURIComponent(name)}` : ''}`);
|
|
19143
19166
|
return ok(`Asset: ${absolute}\nDownload: ${dl}`, { url: absolute, downloadUrl: dl });
|
|
19144
19167
|
}));
|
|
19145
19168
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.263",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 847 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|