Appearance
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 byselectPromptSetand stored assource: 'auto', enriched withaiKeywordVolumefor 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.selectPromptSetround-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 (nameInTextfromparse.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): itsquestionrows 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 → parsedai_prompt_resultsrows (mention / own-URL citation / cited domains / competitors / answer excerpt) → oneai_visibility_snapshotsrow (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, butai_prompt_resultsis 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.getResultHistorystill 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_gapson 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 andedit_ai_promptscall): 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
| Method | Path | Purpose |
|---|---|---|
| GET | /:workspaceId/overview | Snapshot (+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/results | Stored-run drill-down (?promptId=&surface=&limit=) |
| POST | /:workspaceId/prompts | Track 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/:promptId | Retire/restore (active) and/or reword (prompt) |
| DELETE | /:workspaceId/prompts/:promptId | Delete the question and its stored answers (FK cascade) — the irreversible option next to retiring |
| POST / GET | /:workspaceId/sync | Kick off scan / is-running |
Crons
- Weekly scan —
0 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
| Kind | Name | What it does |
|---|---|---|
| Tool | get_ai_visibility | Overview: 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' |
| Tool | get_ai_prompt_results | Raw 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 |
| Tool | get_ai_prompts | The 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) |
| Tool | get_ai_prompt_opportunities | Stored 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 |
| Skill | edit_ai_prompts | Curate the set: add / edit (reword) / deactivate (retire, history kept) / remove (hard delete incl. stored answers). Cap-checked. chatOnly |
| Skill | check_ai_answer | Live-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 products — focusAreas 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.