@theinfluencecompany/ai-cmo
v0.10.0
Published
GeoVouch client — measure whether AI assistants name your brand, know when the evidence can carry a decision, and prove whether what you shipped worked. Bring an API key; the storage, the engines and the statistics are ours.
Readme
@theinfluencecompany/ai-cmo
Measure whether AI assistants name your brand when buyers ask them questions — know when that measurement can carry a decision, and prove whether what you shipped actually worked.
npm i @theinfluencecompany/ai-cmoimport { AiCmo } from "@theinfluencecompany/ai-cmo";
const cmo = new AiCmo({ apiKey: process.env.GEOVOUCH_API_KEY! });
const { engines } = await cmo.scoreboard(); // per engine, never averaged
const { actionable } = await cmo.gaps(); // only what the evidence can carry
const brief = await cmo.brief(actionable[0].question);
// …your agent writes and ships the page, in your stack …
await cmo.recordAction({ question: actionable[0].question, kind: "answer_page",
url: "https://you.com/blog/x", publishedAt: new Date() });
const { assets } = await cmo.lift(); // did it work?Read a configured market
Market IDs belong to the selected project; retrieve them before choosing a market. These methods read existing observations and do not start a scan or spend provider quota.
const { markets } = await cmo.markets();
const market = markets.find((entry) => entry.id === "tw");
if (market) {
const scoreboard = await cmo.scoreboard(market.id);
const gaps = await cmo.gaps(market.id);
const recommendations = await cmo.recommend(market.id);
// A brief can explain that more evidence is needed; it is not an instruction to publish.
const question = gaps.actionable[0]?.question ?? gaps.researchOnly[0]?.question;
if (question) await cmo.brief(question, market.id);
// After the corresponding page actually goes live, retain the assignment's bank version.
if (gaps.actionable[0]) await cmo.recordAction({
question: gaps.actionable[0].question, kind: "answer_page",
url: "https://you.com/zh-Hant/blog/guide", publishedAt: new Date(),
expectedBankVersion: market.measurementScope.bankVersion,
}, market.id);
const lift = await cmo.lift(market.id);
}Each response identifies its measurement selection. A disabled market can retain readable history;
being configured does not mean sampling is active or that enough evidence exists. An unknown market
is a server refusal, never a fallback to another market. A scoped response that omits or changes the
requested market is rejected by the client. Leaving out the argument preserves legacy
reads and receipts whose locale and geography were not recorded. Scoped receipts require the
assignment's expectedBankVersion; a changed bank is refused instead of attaching the publication
to a different measurement. Receipt deduplication and lift reads use the selected scope. Keep the
published date and source bank version when retrying a receipt; do not substitute the latest bank.
Read daily Search Console data
const latest = await cmo.searchConsoleDaily({ property: "sc-domain:example.com" });
const previous = await cmo.searchConsoleDaily({
property: "sc-domain:example.com",
day: "2026-09-17",
});The connected property must belong to the authorized project. history contains bounded collection
metadata; days contains at most the four grains for selectedDay. Omitting day selects the latest
stored day on the server. Dates use Search Console's Pacific-day labels. The method reads stored final
web-search observations; it neither imports Google data nor requires a Google access token. Null sync,
empty results, capped coverage and failed attempts stay explicit. The report is not a separate
generative-AI traffic report, and page/query rows must not be summed into site totals.
The client validates selectors and returned data with the shared schemas. HTTP failures and malformed
successful responses throw AiCmoError; invalid selectors fail locally before a request is sent. The
API key is sent only in the authorization header.
Read per-page evidence (0.10.0)
const evidence = await cmo.pageEvidence(); // measurement:read, project's own window
const korea = await cmo.pageEvidence({ market: "ko-kr", days: 28, limit: 10 });
const one = await cmo.pageEvidence({ url: "https://www.example.com/blog/post/?utm_source=x" });
for (const page of evidence.pages) {
// most unnamed citations first
console.log(page.url, page.ai.unnamedAnswers, page.ai.topRivalsInUnnamed, page.gsc, page.bing, page.naver);
}For each page on your own domains it returns the AI answers in the window that cited it, how many of
them named you, per engine, and the rivals named in the ones that did not. ai.engines carries each
engine's denominator: answers measured in the window and answersWithPageEvidence, the answers that
recorded cited pages. Compare citations only with the second: answers recorded before the server
started capturing pages are not backfilled. These denominators are the stored answers in this request's
window, not the scoreboard's numbers, which come from each scan's saved summary. Beside that sit Search
Console clicks, impressions and impressions-weighted position; Bing clicks, impressions and Bing's own
position integer (its scale is undocumented, so compare it only with other Bing values); and Naver
provider positions. sources carries each search source's freshness: its last snapshot, last attempt and
failure, and for Bing the row dates inside the window and any later empty refresh.
Pages are ordered by unnamed citations and capped by limit (default 20, at most 50); ai.truncated
says when more pages were cited. url reads one page in any spelling — the server normalizes it with
the exported normalizePageUrl, the same function that keys the evidence, and refuses a URL outside the
project's domains. market scopes the AI answers only: Search Console, Bing and Naver are site-level and
never attributed to a market. days defaults to the selected measurement's window.
Nothing here is a zero it did not measure. A source that is not configured, never collected, has
nothing dated inside the window, was only partly observed, or could not be read is unmeasured with a
reason (read_failed for the last; the other sources still answer). no_rows means the source was
completely collected for the window and reported no row for the page, which is not a measured zero
either. When a bound cut the stored rows, lowerBound: true marks sums that may be missing rows, and a
page without rows is unmeasured with beyond_read_bound. Counts are descriptive: a page cited by
answers that did not name you is an observation, not a cause, and no rate or lift is computed here. The
method only reads.
Bing page stats are collected daily on the server through the product's own Bing Webmaster reader;
no Bing key reaches this package or GeoVouch. Until the reader is deployed, Bing reads as unmeasured.
Compatibility. Additive: 0.9.0 clients keep working against this server, and every existing
response is unchanged. pageEvidence() needs this package at 0.10.0 or later (schema 0.9.0).
What this package deliberately does not expose
No database, no migrations, no vendor token, no engine list to configure, no statistical parameter to tune. Those are ours — and keeping them ours is what lets the numbers get better without you redeploying.
Three things the API refuses to blur
unmeasured is never 0. An engine that answered without retrieving has told you nothing about
whether your pages were a source, so sourcedRatePct is null rather than zero. A lift verdict with
no control is unmeasured, not "no effect".
Engines are never averaged. They diverge by roughly 46×, so a single blended "AI visibility score" is the mean of four different instruments.
A gap you may act on is not a gap you can see. gaps() returns two lists. actionable cleared a
precision floor, sat fully below the 50% win/loss line, and passed an anytime-valid familywise
threshold — so you can check every morning without inflating your error rate. researchOnly did not,
and spending on it is spending on a draw.
The loop
scoreboard → gaps → brief → you act, in your own stack → recordAction → lift
For CMS and website work, your agent already has the access, the conventions and the review rules, and any connector we built would be a worse version of what you already own. The receipt is the seam — it is what makes the last step possible for work we did not do.
MIT © The Influence Company
Opt-in shared X publication (0.7.0)
socialConnections() returns non-secret account readiness and publishing capacity. publishX() submits a project-scoped intent; socialRelease(id) retrieves its durable receipt; socialReleases() returns the latest 100 project-scoped receipts for backlog reconciliation. These methods require content:read / content:publish, independent of measurement permissions. An operator must enable policy.xPublication and promote a verified account first. No OAuth tokens appear in requests or responses.
const status = await geo.socialConnections();
const account = status.connections.find(item => item.ready)?.connection;
if (status.publishing.available && account) {
const source = { id: "release-source", title: "The exact verified article headline", url: "https://your-brand.example/blog/article", observedAt: new Date().toISOString() };
const receipt = await geo.publishX({
connectionId: account.id,
idempotencyKey: "stable-source-event-key",
text: `${source.title}\n\nSource: ${source.url}`,
source,
});
// Only receipt.release.target.outcome === "delivered" proves acceptance with a real post ID.
}The current lane validates headline, excerpt or FAQ-question formats against authoritative published article data supplied by a registered Cloudflare service binding. No user-supplied URL is fetched. Use the exported formatXPublicationCaption(source, format) so client and server share the exact rendering. It refuses unpublished articles, unmatched excerpt/question text, expired observations, unsupported extra claims and overlength text. The source observation timestamp must come from the original collection; retrying must not refresh it. Repeating an idempotency key or the same canonical source/account reads the existing receipt. Unknown provider outcomes remain held and consume capacity; no retry repeats a provider POST. Other websites need a registered source reader. Freely authored text still requires a reviewed-artifact owner. The additive 0.8.0 video lane is described below.
Reviewed native video (0.8.0)
publishXVideo({ connectionId, idempotencyKey, text, source, media: { assetId } }) requests a native video post from an already approved product asset. The source remains a published article; text must equal the asset's approved caption and end with the article URL. The server obtains bytes through the registered product reader, checks the digest, owner approval, recording provenance and duration, and verifies the account's media scope. A new Workflow performs upload, processing checks and the one-shot final post with durable receipts. The response may be pending/unknown until an exact post ID arrives; use socialRelease(id) for its status.
socialConnections().publishing.video separately reports video readiness. socialReleases({sourceUrl}) finds the bounded receipts for an exact article even when older than the global recent list. includeMedia:false requests text-only history. An unknown upload is never repeated; a known media ID can be polled without uploading again. Actual video authorization must come from the broker's grant, not a manually invented scope in a local row.
The shared ApprovedVideoEvidence, ApprovedVideoAsset and X_VIDEO_MAX_BYTES exports define the product reader contract. The reader returns only a globally approved owned asset, never an arbitrary recording, agency-private asset or URL supplied for download. Product asset approval and storage remain in the existing product library.
Naver search observations and project configuration (0.9.0)
Naver collection is off for every project until its automation policy selects it. The opt-in lives in
the existing versioned policy, so enabling it is a compare-and-set updatePolicy call.
Use this package at 0.9.0 or later (schema 0.8.0) for every client that reads the project once Naver
is enabled. In 0.8.0 and earlier, AutomationPolicy.discovery and DiscoveryRun are strict objects and
sources is a three-value enum, so policy() (and the CLI's ready) and discoveryRun(id) throw a
ZodError on a project whose policy selects Naver.
Enable it in two steps, so one collection is inspected before any cadence starts:
const cmo = new AiCmo({ apiKey: process.env.GEOVOUCH_API_KEY!, project: "prelulu" });
const current = await cmo.policy(); // context:read
if (!current.policy) throw new Error("Save a policy (and business context) first");
const { version, policy: before } = current.policy; // keep `before`: it is the rollback
const naver = {
queries: ["prelulu", "AI 영상통화", "AI 캐릭터 채팅"],
sampling: { surface: "integrated", device: "desktop", pagesPerQuery: 1, sortBy: "relevance", dateRange: "any", includeAds: false, language: "ko", saveHtml: true, openApiEnrichment: false },
maxChargeMicros: 25_000,
scheduled: false, // step 1: explicit runs only
} as const;
const { policy: enabled } = await cmo.updatePolicy({ // policy:write
expectedVersion: version,
policy: {
...before,
discoveryEnabled: true,
maxConcurrentJobs: Math.max(before.maxConcurrentJobs, 2),
discovery: { ...before.discovery, sources: [...before.discovery.sources, "naver"], naver },
},
});
if (!enabled) throw new Error("the policy write returned no policy");
const { run } = await cmo.discover({ idempotencyKey: "naver-first-run", source: "naver" }); // discovery:run
const page = await cmo.naverObservations({ run: run.id }); // discovery:read
const naverRuns = await cmo.discoveryRuns({ source: "naver" }); // discovery:read
// step 2, after inspecting `page` and the run's spend: let the trigger collect at pollIntervalMinutes
await cmo.updatePolicy({
expectedVersion: enabled.version,
policy: { ...enabled.policy, discovery: { ...enabled.policy.discovery, naver: { ...naver, scheduled: true } } },
});Every sampling option is a literal: it is the reviewed sample, and a different one is refused rather
than silently collected. maxChargeMicros must cover every configured page at the pinned Actor build's
prices (21,050 micros for three one-page queries); a smaller ceiling is refused, because the Actor would
stop part-way and the missing pages would be unobserved, not cheaper. sources must list "naver"
exactly when naver is present. scheduled: false means only an explicit discover({ source: "naver" })
collects; true lets the two-hour trigger collect whenever pollIntervalMinutes has elapsed.
A queued or running collection occupies one of the project's maxConcurrentJobs slots for the few
minutes it runs. The trigger admits AI measurement before Naver in each tick, so a scheduled collection
never takes the slot its own tick's measurement needs; a manual collection that overlaps a tick can,
which is why the example raises the limit to 2. discover() answers 409 discovery_blocked while the
poll interval since the last Naver collection (manual or scheduled) has not elapsed, or when the day's
budget or a concurrency slot is unavailable; read discoveryRuns({ source: "naver" }) rather than
retrying.
Rollback is updatePolicy with the before document read in step 1 (at the then-current version):
removing only "naver" and naver would leave discoveryEnabled: true, which keeps editorial discovery
startable for keys with discovery:run. History, usage reconciliation and reads remain.
naverObservations() only reads; it never starts a collection. Each observation is one query page with
its provider run, build, dataset and key-value-store identifiers and the raw-HTML key. Read a page by
its status: complete (every reported row parsed), partial (valid rows kept, absence unproven) or
unobserved (missing, malformed, duplicated, collected with other settings, or from an unreviewed
build). Only a complete page can support "no owned destination on this page", and even then it says
nothing about whether Naver indexed the site. providerRank is the Actor's position on its parse of
the page, not an organic rank, and an owned match is an exact configured host or subdomain — never a
title, breadcrumb, query string or unresolved Naver redirect. These snapshots are not AI answers or
citations; they never enter share of voice, citation rates, action eligibility or lift.
lastAttempt / lastSuccess carry page coverage, and actualMicros: null means the cost is still
reserved and unknown, never zero.
projectConfig() returns the project's measurement configuration and a revision.
updateProjectConfig({ expectedRevision, displayName, config }) (requires project:manage) applies only
if nothing changed since that read; a stale revision answers 409 version_conflict, and a write that
would delete stored fields this server version does not recognise answers 409
unrecognized_stored_fields. A field this package does not know fails locally before a request. The
Naver lane matches destinations against config.brand.domains, pinned when each collection is reserved.
