
Web Development · Blog
How to Automate Your AI Visibility Audit in CI/CD: The Developer's Guide to GEO Testing
Most GEO audits are manual checks run once a month in a marketing dashboard. That's the wrong model for a development team — because the things that break AI visibility (a component switching from SSR to CSR, a schema block getting stripped by a build optimization, a robots.txt rule added without review) happen at deploy time, not between marketing meetings. This guide shows you how to run a full AI visibility audit on every deploy using free, open-source tools, fail the build when your AI readiness score drops below a threshold, and catch GEO regressions before they reach production — the same way you gate on Lighthouse scores and bundle size budgets today.
At BalochDev, we run this pipeline on every push to main. Here's exactly how it works.
Why manual GEO audits fail
A Lighthouse score catches performance regressions. An axe-core run catches accessibility regressions. Until recently, nothing caught GEO regressions — the class of change that makes your site invisible to AI crawlers, breaks your JSON-LD schema, or silently removes your llms.txt from the build output.
The four things that silently break AI visibility after a deploy
1. A content component gets the "use client" directive. In Next.js App Router, adding "use client" to a component that wraps your article body moves it from server-rendered to client-rendered. The content disappears from the initial HTML. AI crawlers receive an empty shell. Your Lighthouse score is unaffected. Your Google ranking is unaffected (Googlebot renders JS). Your AI visibility drops to zero.
2. A build optimization strips JSON-LD script tags. Some HTML minifiers and build plugins strip <script> tags with unrecognized types or inline JSON. <script type="application/ld+json"> is the target. The schema you've carefully implemented is gone from production. No error. No warning.
3. A robots.txt rule gets added incorrectly. A developer adds Disallow: /blog to block a staging path and forgets to scope it to a specific user-agent. Every AI crawler is now blocked from your entire blog. This is the most common catastrophic GEO regression in our audit data.
4. llms.txt gets excluded from the build output. Static files that aren't explicitly included in the build output directory get dropped silently. If llms.txt lives in public/ and a deployment script changes the output path, it disappears without any build error.
Why Lighthouse doesn't catch these
Lighthouse runs a Chrome instance that executes JavaScript, renders the full DOM, and evaluates the result. It's designed to audit the user experience. AI crawlers don't execute JavaScript. A Lighthouse score of 100 and a completely client-rendered article body can coexist on the same page. You need a different audit layer specifically for machine readability.
The open-source GEO audit stack
All of these are free and open-source. None require an account for local use.
GEO Optimizer — the CLI core
GEO Optimizer runs 16 CLI commands, 8 scoring categories, 47 research-backed methods, 7 output formats, and 1,720 tests, grounded in the Princeton "GEO" paper (KDD 2024) and AutoGEO (ICLR 2026). It works as a CI/CD pipeline tool, Python library, MCP server, and Astro integration.
Install:
bash
pip install geo-optimizer-skillOr run without installing, via uv:
bash
uvx --from geo-optimizer-skill geo audit --url https://yourdomain.comBasic audit — note the CLI binary is geo, not geo-optimizer, and the URL is a flag, not a positional argument:
bash
geo audit --url https://yourdomain.com --format json > geo-report.jsonThe 8 scoring categories it checks (100 points total): Robots.txt (18 pts), llms.txt (18 pts), Schema JSON-LD (16 pts), Meta Tags (14 pts), Content (12 pts), Brand & Entity (10 pts), Signals (6 pts), AI Discovery (6 pts). Run geo audit --url <your-url> --format json yourself to see the exact current JSON schema — field names have shifted across versions, so treat any example output as illustrative rather than a fixed contract.
Other commands worth knowing:
bash
# Save history and fail CI on regression
geo audit --url https://yourdomain.com --save-history --regression
# What changed since the last snapshot?
geo drift --url https://yourdomain.com --fail-on warning
# Ask real AI engines whether your brand is cited (requires your own API key)
geo citations --brand "YourBrand" --domain yourdomain.com --topic "your product category"geo-lint — the content-level linter
geo-lint is a static linter for your markdown/MDX content — 92 rules split across GEO (35), SEO (32), content quality (14), technical (8), and i18n (3). It's a dev dependency that lints content files against a config, not a tool that scans a built ./dist directory directly.
bash
npm install -D @ijonis/geo-lintCreate geo-lint.config.ts:
typescript
import { defineConfig } from '@ijonis/geo-lint';
export default defineConfig({
siteUrl: 'https://yourdomain.com',
contentPaths: [
{ dir: 'content/blog', type: 'blog', urlPrefix: '/blog/' },
],
});Run it:
bash
npx geo-lint # Human-readable output
npx geo-lint --format=json # Machine-readable, for CIWorks out of the box with .md/.mdx files; other formats need a custom adapter.
foglift-scan — CI-native page scanner
foglift-scan is an MIT-licensed CLI that runs the same 8-dimension AI Readiness engine behind Foglift's hosted product, against any public URL — no account required for local scans.
bash
npm install -g foglift-scan
foglift scan https://yourdomain.com --jsonAdd a threshold to fail CI below a chosen score:
bash
foglift scan https://yourdomain.com --json --threshold=70curl simulation — the five-bot access check
The simplest and most reliable test: simulate what each AI crawler actually receives.
bash
# Check access (should return 200, not 403)
curl -s -o /dev/null -w "%{http_code}" -A "OAI-SearchBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "PerplexityBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "ClaudeBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "GPTBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "Google-Extended" https://yourdomain.com
# Check content (article text should appear in raw HTML)
curl -s -A "OAI-SearchBot" https://yourdomain.com/blog/your-article | grep -c "<h2>"If any bot returns 403, that's a CDN/WAF block that needs immediate investigation regardless of everything else passing. Note that GPTBot access governs training inclusion only — for ChatGPT citation eligibility specifically, OAI-SearchBot's result is the one that matters.
Setting up GEO Optimizer in GitHub Actions
The simplest path — the official Action
GEO Optimizer ships a ready-made GitHub Action that covers most of what a custom workflow would otherwise need to build:
yaml
# .github/workflows/geo.yml
- uses: Auriti-Labs/[email protected]
with:
url: https://yourdomain.com
min-score: 70 # Fail the build if the GEO score drops below 70
format: sarif # Upload findings to the GitHub Security tabThis works with GitHub Actions, GitLab CI, Jenkins, CircleCI, and any CI that runs Python.
A fuller custom workflow
If you want more granular control — separate bot-access checks, rendering verification, and a PR comment — build it out manually:
Add a requirements-geo.txt to your repo root:
txt
geo-optimizer-skill>=4.16.0
requests>=2.31.0Test locally first:
bash
pip install -r requirements-geo.txt
geo audit --url https://yourdomain.com --format jsonSave as .github/workflows/geo-audit.yml:
yaml
name: GEO Visibility Audit
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 9 * * 1' # Weekly Monday 09:00 UTC regardless of deploys
jobs:
geo-audit:
name: AI Visibility Audit
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install GEO Optimizer
run: pip install geo-optimizer-skill
- name: Set audit URL
id: url
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "AUDIT_URL=${{ vars.PRODUCTION_URL }}" >> $GITHUB_ENV
else
echo "AUDIT_URL=${{ vars.PREVIEW_URL }}" >> $GITHUB_ENV
fi
- name: Run GEO audit
id: geo
run: |
geo audit --url "$AUDIT_URL" --format json > geo-report.json
SCORE=$(cat geo-report.json | python3 -c "import sys,json; print(json.load(sys.stdin)['score'])")
echo "GEO_SCORE=$SCORE" >> $GITHUB_ENV
echo "score=$SCORE" >> $GITHUB_OUTPUT
- name: Check AI crawler access
id: bots
run: |
check_bot() {
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -A "$1" "$AUDIT_URL/robots.txt")
if [ "$STATUS" != "200" ]; then
echo "::error::$1 blocked — HTTP $STATUS"
exit 1
fi
echo "$1: $STATUS OK"
}
check_bot "OAI-SearchBot"
check_bot "PerplexityBot"
check_bot "ClaudeBot"
check_bot "GPTBot"
check_bot "Googlebot"
- name: Verify content in server-rendered HTML
id: rendering
run: |
CONTENT=$(curl -s -A "OAI-SearchBot" "$AUDIT_URL/blog/how-to-get-your-business-found-by-ai-search")
if echo "$CONTENT" | grep -q "AI search engines cite"; then
echo "✅ Content present in server-rendered HTML"
else
echo "::error::Article body not found in server-rendered HTML — possible CSR regression"
exit 1
fi
if echo "$CONTENT" | grep -q "application/ld+json"; then
echo "✅ JSON-LD schema present in HTML"
else
echo "::error::JSON-LD schema not found in HTML — schema may be JS-injected"
exit 1
fi
- name: Verify llms.txt
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$AUDIT_URL/llms.txt")
if [ "$STATUS" = "200" ]; then
echo "✅ llms.txt found (HTTP $STATUS)"
else
echo "::warning::llms.txt not found (HTTP $STATUS) — not critical but recommended"
fi
- name: Enforce GEO score gate
run: |
THRESHOLD=70
echo "GEO Score: $GEO_SCORE / 100"
if [ "$GEO_SCORE" -lt "$THRESHOLD" ]; then
echo "::error::GEO score $GEO_SCORE is below threshold $THRESHOLD — deploy blocked"
exit 1
else
echo "✅ GEO score $GEO_SCORE meets threshold $THRESHOLD"
fi
- name: Upload GEO audit report
uses: actions/upload-artifact@v4
if: always()
with:
name: geo-audit-report
path: geo-report.json
retention-days: 30
- name: Comment GEO score on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const score = process.env.GEO_SCORE;
const threshold = 70;
const emoji = score >= threshold ? '✅' : '❌';
const body = `## ${emoji} GEO Visibility Audit\n\n**AI Readiness Score: ${score}/100** (threshold: ${threshold})\n\nDownload the full report from the Actions artifacts above.`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});Setting the AI readiness score gate
ScoreMeaningAction90–100Fully citation-eligibleMaintain70–89Citation-eligible, gaps presentMonitor50–69Partial eligibilityFix before next deployBelow 50Critical failures presentBlock deploy
Start at 70. Once your baseline is above 80, raise the threshold to 80. This prevents score drift over time without blocking deploys on day one.
What the audit actually checks
Gate 1 — robots.txt and crawler access
Checks whether OAI-SearchBot, PerplexityBot, ClaudeBot, Claude-SearchBot, and ChatGPT-User are explicitly allowed or covered by a permissive wildcard, whether training-only crawlers are blocked without also blocking citation crawlers, and whether Googlebot and Google-Extended are configured correctly (they're separate directives).
Gate 2 — Rendering verification
Fetches key pages with OAI-SearchBot as the user-agent and checks that <main>/<article> is present and non-empty, that headings contain real text, and that JSON-LD blocks are present in the raw <head> response. Failure here is the highest-severity finding — everything else is irrelevant if the AI receives an empty page.
Gate 3 — JSON-LD schema completeness
Checks for Organization schema (name, url, description, sameAs), Article schema (headline, datePublished, dateModified, author, publisher), FAQPage schema, and BreadcrumbList schema in the raw HTML response.
Gate 4 — llms.txt presence and validity
Checks for a 200 response at /llms.txt, correct content type, and a valid structure (H1, summary, URL entries). Missing llms.txt is a warning, not a build-blocking failure.
Gate 5 — Content structure signals
Checks for an answer-first opening in the first ~80 words, direct-answer section openings, reasonable passage length, FAQ section presence, and at least one quantified claim per few hundred words.
Generating and validating llms.txt automatically
A minimal valid llms.txt at your domain root:
markdown
# BalochDev
> An AI-first software development studio building products for GCC and international markets.
## Key pages
- [Blog](https://balochdev.com/blog): Technical guides on AI search, GEO, and software engineering
- [Services](https://balochdev.com/services): What we offerGenerate one from your sitemap:
bash
geo llms --base-url https://yourdomain.com --output ./public/llms.txtVerify it made it into the build:
bash
ls -la ./public/llms.txt
npx serve ./out & sleep 2 && curl http://localhost:3000/llms.txtAstro integration
GEO Optimizer's Astro integration is published as astro-geoready and generates llms.txt, /.well-known/ai.txt, and /ai/summary.json from your built routes at build time — without overwriting hand-curated files.
bash
npm install astro-georeadyjavascript
// astro.config.mjs
import { defineConfig } from 'astro/config';
import geoReady from 'astro-geoready';
export default defineConfig({
site: 'https://yourdomain.com',
integrations: [
geoReady({ siteName: 'Your Site' })
]
});GitLab CI
yaml
# .gitlab-ci.yml
geo-audit:
stage: test
image: python:3.11-slim
before_script:
- pip install geo-optimizer-skill
script:
- geo audit --url $PRODUCTION_URL --format json > geo-report.json
- python3 -c "
import json, sys
report = json.load(open('geo-report.json'))
score = report['score']
print(f'GEO Score: {score}/100')
sys.exit(0 if score >= 70 else 1)
"
artifacts:
paths:
- geo-report.json
when: always
only:
- mainWhat to do when the audit fails
Rendering failure — content not in HTML
Diagnosis:
bash
curl -s -A "OAI-SearchBot" https://yourdomain.com/blog/your-article | \
grep -o '<article>.*</article>' | wc -cIf output is 0 or very low: CSR regression.
Fix in Next.js App Router: find the offending component and remove 'use client' if it has no client-side interactivity — it must be a Server Component:
typescript
// Before (broken):
'use client'
export function ArticleBody({ content }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />
}
// After (fixed): remove 'use client'
export function ArticleBody({ content }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />
}Schema validation failure
Diagnosis:
bash
curl -s https://yourdomain.com/blog/your-article | grep -A 50 'application/ld+json'If nothing returns, schema is JS-injected. Move it to <head> in your layout template as a static string:
typescript
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
const schema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"datePublished": post.publishedAt,
"dateModified": post.updatedAt,
};
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
<article>{/* content */}</article>
</>
);
}Crawler block detected
Fix sequence:
Check
robots.txt— is the bot explicitly blocked?Check Cloudflare → Security → Bots → Block AI bots toggle
Check Cloudflare → AI Crawl Control → Search crawlers setting
Check WAF custom rules for user-agent matching
Verify with:
curl -I -A "OAI-SearchBot" https://yourdomain.com
llms.txt missing from build output
Fix: Ensure the generation script runs as part of postbuild and outputs to the correct directory. In Next.js, static files must be in ./public/ to be served at the root URL.
BalochDev's GEO score benchmark
After running this pipeline on balochdev.com for 90 days, the most common regressions we've caught in CI before they reached production: JSON-LD schema stripped by HTML minification (twice), and a blog list page switching from SSG to CSR after a data-fetching refactor (once). Both would have been invisible without the GEO audit gate — Google rankings were unaffected in both cases.
Our current threshold is 75. We'd recommend starting at 70 and increasing by 5 points per quarter as your baseline improves. (These are our own operational figures from running this pipeline, not a third-party benchmark.)
Frequently asked questions
What does a GEO audit check that Lighthouse doesn't? Lighthouse audits the rendered DOM in a headless Chrome instance that executes JavaScript. AI crawlers fetch raw HTML without executing JavaScript. A GEO audit specifically tests the raw HTML response — what the machine receives, not what the browser renders. A site can score 100 on Lighthouse and very low on a GEO audit simultaneously.
Can I use these tools without a CI/CD pipeline? Yes. Run geo audit --url https://yourdomain.com from any terminal, or use uvx --from geo-optimizer-skill geo audit --url https://yourdomain.com with no install at all. The CI/CD integration automates what you'd otherwise run manually before each deploy.
What's a good AI readiness score to gate deploys on? Start at 70. This blocks genuine regressions (CSR failures, blocked crawlers, missing schema) without being so strict it slows down normal development. Once your baseline is consistently above 80, raise the threshold to 80.
Does the GitHub Action slow down build times? The GEO audit typically adds well under two minutes to your CI pipeline — comparable to a Lighthouse audit. Run it as a parallel job rather than sequentially with your build step to avoid adding to total deploy time.
How is GEO Optimizer different from paid tools like Foglift's hosted product or Profound? Paid GEO platforms add automated recurring tracking, historical trends, competitor monitoring dashboards, alerting, and exportable reports. GEO Optimizer's free CLI is a point-in-time audit tool — it tells you your current score and what's broken. Use it in CI to prevent regressions; consider a paid tool when you need ongoing monitoring and competitive intelligence.
Should I run the audit on every commit or only on production deploys? Run the pre-deploy static checks (llms.txt presence, schema in HTML, geo-lint content checks) on every commit — they're fast and catch regressions early. Run the full live audit (bot access, content retrieval, live scoring) only on pushes to main or production branches, since it requires a deployed URL.
Sources & further reading
GEO Optimizer — Auriti Labs, MIT-licensed CLI, Python library, MCP server, and Astro integration
Aggarwal et al., "GEO: Generative Engine Optimization" — KDD 2024
"AutoGEO" — ICLR 2026
geo-lint — IJONIS, open-source content linter for SEO/GEO
foglift-scan — Foglift, open-source CLI on npm