Skip to content

AI Visibility

Module id: ai-visibility (frozen) · Mounted at: /api/modules/ai-visibility · Empty settings row · Available to every workspace (the per-workspace enablement switch was removed 2026-08-14).

Purpose

Measures whether the workspace's brand is mentioned — and its pages cited — when buyers ask AI assistants. A tracked prompt set (≤15, cap lowered from 25 on 2026-08-18) runs weekly through the four REAL surfaces: the ChatGPT and Gemini UIs (DataForSEO LLM scrapers — API answers ≠ UI answers), the Google AI Overview block, and Google AI Mode (/v3/serp/google/ai_mode). A monthly LLM Mentions intel pass adds the category picture. Trends over single-run noise. All four surfaces verified live in every fleet locale (en-US/GB, nl-NL/BE, fr-FR) 2026-08-04.

Features

  • Prompt bootstrap (ensurePromptSet, first run only): ONE Haiku call from the workspace profile — 40 buyer questions, each TAGGED with its intent (problem/category/hiring/cost/comparison/inhouse/local) — parsed, deduped, keyword-shaped items dropped (a tracked prompt is a whole question: ≥20 chars, ≥5 words), then 15 picked by selectPromptSet and stored as source: 'auto', enriched with aiKeywordVolume for display. The mix is mechanical, not hoped-for (2026-08-18): the model was asked for a spread and the code took its first 15, which gave Byve 11 of 15 "which video agency should I hire" reworded — every one returning the same AI answer, so it cost 10 slots and 40 surface calls a week to learn one thing. selectPromptSet round-robins the intent buckets and refuses a candidate whose content words overlap ≥60% (Jaccard, language-agnostic — the set is Dutch as often as English) with one already picked; a second round-robin sweep takes duplicates back rather than return a short set. The brand-name rule is enforced in code too (nameInText from parse.js): the system prompt forbids naming the business, and the model broke it anyway ("Forest Forward versus andere duurzaamheidsbureaus") — a brand question is trivially won and measures nothing. Competitor names are NOT filtered; a comparison question naming a rival is exactly where the workspace should appear. Never regenerates silently. The LLM Mentions DB is not a pool here (removed 2026-08-17): its question rows are category keywords ("digital asset management", "tiktok agency") carrying keyword-scale volume, so on the old shared volume axis they outranked every draft and filled a live workspace's whole set with 2–4 word fragments (Byve). It stays what it always was — category intel offered for curation, below.
  • Weekly scan (runAiVisibilitySync, in-flight dedupe, concurrency 5 — 4 surface calls per prompt ≈ the core client's 20-slot semaphore): every active prompt × 4 surfaces → parsed ai_prompt_results rows (mention / own-URL citation / cited domains / competitors / answer excerpt) → one ai_visibility_snapshots row (per-surface prompts/answered/mentioned/cited + mention/citation rates, competitor counts, top 15 domains, category intel). Notifies on mention-count change (ai_visibility_change); a changed surface set re-baselines instead of firing a fake delta. Nothing is pruned (the ~12-week prune was removed 2026-08-15): the snapshot holds the rollup, but ai_prompt_results is the only record of which prompt was cited on which surface on which day — the citation history itself, and the least replaceable thing this module produces. It was 548 rows fleet-wide when the prune came out, so this was never a storage question. getResultHistory still reads a 70-day window by default; the rows behind it now survive. Scan cost ~$0.27 at cap. Gated on workspace readiness at fire time since v3 (see crons reference).
  • Category intel pass (buildSourceGaps, ≥28d apart — gated on the STORED snapshot's intel age, not the in-memory cache): llmMentionsTopCited (domains + pages, lite tier ~$0.20/call) + llmMentionsQuestions (~$0.105/call, one call for all terms) → source_gaps on the snapshot: most-cited domains/pages in the category (market-scoped: language always, location when it has rows) + top untracked real questions (brand/dedupe/language-guarded). Carried forward between refreshes; failures keep the previous intel. ~$0.50/month.
  • Curation (prompts.js#editPrompt — the ONE rule both the page and edit_ai_prompts call): a stored run belongs to the question it was asked with, so a reword of a never-measured prompt is an in-place rename, while a reword of a measured one is a REPLACE — the old wording is retired with its answers and the new wording starts its own trend. Net zero against the cap. Alongside it: retire (active:false, reversible, history kept), restore, and hard delete (row + stored answers via the FK cascade) — the only way a question actually leaves. The page lists retired questions in a collapsed Not tracked block so retiring is visibly reversible instead of a black hole.
  • Cap (MAX_ACTIVE_PROMPTS, queries.js): 15, enforced on add and on re-activate only — never on the scan. Workspaces bootstrapped under the old cap of 25 (Byve, marcom.wisdom) keep measuring their existing set; a silent trim would delete a running trend. They simply cannot add until they are back under 15. The number travels to the page on the overview payload (cap) — the UI never hardcodes it.
  • Manual Scan now (detached) + running-state polling.

Routes

MethodPathPurpose
GET/:workspaceId/overviewSnapshot (+previous) + trend + cap + per-prompt status grid. The grid's 400-row result budget is fetched activeOnly (2026-08-18): a workspace that had retired a whole set (Byve: 25 retired + 25 tracked) spent it on dead questions and drew one dot per prompt instead of four
GET/:workspaceId/resultsStored-run drill-down (?promptId=&surface=&limit=)
POST/:workspaceId/promptsTrack one prompt (10–200 chars, cap enforced; re-adding a retired question restores it with its history — an already-tracked one is a 400, not a silent no-op)
PATCH/:workspaceId/prompts/:promptIdRetire/restore (active) and/or reword (prompt)
DELETE/:workspaceId/prompts/:promptIdDelete the question and its stored answers (FK cascade) — the irreversible option next to retiring
POST / GET/:workspaceId/syncKick off scan / is-running

Crons

  • Weekly scan0 7 * * 2 (Tuesdays 07:00, jittered), runs for every workspace, (the tool's never-scanned message says the first scan happens on this cron ONLY while the workspace's scheduled work is on — AUTOMATION STATE in the chat context / get_workspace_profile.pipeline — so a paused workspace's empty view is explained, not promised away), gated by the scheduler's fire-time automation gate — setup complete + agent not paused, decided once for every workspace cron (see crons reference).
  • Operator trigger aiVisibilitySync ("AI visibility scan").

Agent tools & skills

KindNameWhat it does
Toolget_ai_visibilityOverview: snapshot/previous/trend/prompts, per-surface rates (4 surfaces), competitors, top domains, cap. Leads with a scan {running, last_scan} block — a scan in progress reads as "results pending", never "no presence". trend_weeks (default 26, max 104 — TREND_DEFAULT_WEEKS/TREND_MAX_WEEKS in queries.js; the trend was a silent 26-snapshot window) and trend_weeks echoed in the response. include_presence_history + history_days (default 70, max 365) adds the per prompt × surface {run_at, mentioned, cited} series for "when did we lose/gain this mention", presence_history_truncated when the 3000-row read filled. Run dots carry indicative (documented: a model-API row, reserved — every stored run today is a real-surface run). Zero cost Conventions (2026-08-23): never-scanned → `{ empty: true, reason 'never_scanned'
Toolget_ai_prompt_resultsRaw stored answers (excerpt ≤1200 chars — UI parity, excerpt_truncated flagged; sources ≤20), filterable by prompt/surface/mentioned/cited/active_only; since/until bound run_at to one scan or month; limit up to 200 + offset (one full scan = 15 prompts × 4 surfaces = 60 rows). Since 2026-08-23 the response is an envelope { results, total, offset, returned, truncated }countResults head-counts the SAME predicates (applyResultFilters, one place) so total always describes the rows being paged
Toolget_ai_promptsThe tracked set (+ activeCount, cap); each prompt carries runCount (countResultsByPrompt — stored runs, 0 = never measured, the rename-vs-replace line) and the description explains locale/location_code (no updated_at exists — measured text never changes)
Toolget_ai_prompt_opportunitiesStored category intel (source_gaps): most-cited domains/pages + untracked real questions, plus the tracked-set gap prompts. refreshes (1–6, default 1; renamed from months 2026-08-23 — it counts distinct captures, not calendar months) returns that many DISTINCT past intel refreshes — refreshes>1 adds intel_history for "which domains newly entered the category answers". gapPrompts = active prompts absent on EVERY surface's latest run (2026-08-23 — it tested the last 4 runs per surface while the description promised the latest scan; the description won). Zero cost
Skilledit_ai_promptsCurate the set: add / edit (reword) / deactivate (retire, history kept) / remove (hard delete incl. stored answers). Cap-checked. chatOnly
Skillcheck_ai_answerLive-probe ONE prompt on ONE surface (chatgpt default | gemini | google_aio | ai_mode) and store the run (~$0.005) — outcome verification after publishing. Google surfaces are week-cached

Signals (agency pass §2 + §2b, 2026-08-03)

Step 6 (2026-09-01): the absent-prompt card carries who wins instead, and why. classifyUnansweredAiPrompts aggregates cited_domains from the absent prompts' latest runs into evidence.cited_instead (domain × prompts-won, top 8; per-prompt lists on evidence.prompts[].cited_domains), and the crawler's citability counts on the key pages (spine getKeyPageCitability over site_pages.statistics_count/quotation_count/citation_count) ride as evidence.citability — with a note in the detail when fewer than half the measured key pages contain a single statistic, the positions-4-20 passage-quality mechanism. Best-effort: a failed spine read costs the card its facts, never the finding.

Two mechanical detectors (agent-signals.js, manifest signals hook), both zero-API-cost stored-data reads, both pure + asserted in backend/scripts/test-autopilot-safety.mjs.

lost_citations (classifyLostCitations over getResultHistory, §2b — the defend twin): a prompt × surface where the brand was present (mentioned OR cited) on ≥2 consecutive runs and is now absent on the latest ≥2 consecutive runs. The double streak requirement exists because single AI answers drift 40–60%/month — one odd answer in either direction never flags. Deactivated prompts never flag (the user retired the question). Severity warning, category performance, cap 3 by longest hold, fingerprint ai-visibility:lost:<promptId>:<surface>.

competitor_presence (classifyCompetitorPresence): a competitor is the AI answer on your buyer prompts. Reads the stored scan results over a 15-day window (two weekly scans, so one odd answer can't flag), keeps only the LATEST run per prompt × surface, and counts per competitor the appearances on runs where this brand is absent (mentioned: false). Floors: ≥3 absent appearances = opportunity, ≥6 = warning; category demand (the outward-looking one); cap 3 competitors, per-competitor fingerprints, pre-cap presence for the reconciler. Zero API cost — stored rows only — and self-guarding: no stored results (never scanned yet) simply returns nothing. No how — the play is content work through chat, grounded in the answer drill-down. Pure and asserted in backend/scripts/test-autopilot-safety.mjs.

Data

Writes ai_prompts, ai_prompt_results (surface CHECK: chatgpt / google_aio / gemini / ai_mode / claude / perplexity — ai_mode added by migration 2026-08-04), ai_visibility_snapshots (source_gaps carries the intel); reads workspaces + the '1a-blog-publisher' settings row (companyName, companyDescription, industry, products, idealClient, visibilityTargets, competitors, serviceAreas, languages; intelTerms = industry + the comma-split productsfocusAreas retired 2026-08-28).

Key files

index.js, sync.js (write path + intel), prompts.js (bootstrap + discovery + intelTerms), parse.js (parseLlmScrapeItems — ChatGPT AND Gemini items, parseAiOverview — AIO AND AI Mode), queries.js, scheduler.js, routes.js, agent-tools.js, agent-skills.js, agent-signals.js. Core wrappers: llmScrapeChatGpt, llmScrapeGemini, serpAiOverview, serpAiMode, llmMentionsQuestions, llmMentionsTopCited, aiKeywordVolume in core/services/dataforseo.js.