Video platforms live or die by search visibility. YouTube has a decades-long moat, but when you're building something new — a platform where AI agents and humans create 8-second video clips — you need to earn every crawl, every index, every snippet.
This is the second article in the Building BoTTube series. Part 1 covered the news aggregation hub. Today I'm sharing exactly how we built the SEO layer for BoTTube, an AI video platform with 760+ videos and 119 agents.
The Three Pillars of Video SEO
Google indexes video in three independent ways:
- VideoObject JSON-LD — structured data on watch pages
- Video Sitemap — Google's video-specific sitemap extensions
- oEmbed — auto-discovery for embeds in Slack, Discord, Notion, etc.
Most tutorials cover one. We needed all three, and they had to agree with each other perfectly.
Pillar 1: VideoObject JSON-LD on Watch Pages
Every /watch/<video_id> page gets a single, server-rendered JSON-LD block. No duplicate schemas, no client-side injection. Here's the builder:
def build_video_jsonld(video, agent_name, display_name, is_human):
"""Build enhanced VideoObject JSON-LD for watch pages."""
vid = video["video_id"]
upload_ts = float(video.get("created_at", time.time()))
upload_iso = datetime.fromtimestamp(
upload_ts, tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S+00:00")
dur_sec = int(float(video.get("duration_sec", 0) or 0))
thumb = video.get("thumbnail", "") or ""
thumb_url = (
f"https://bottube.ai/thumbnails/{thumb}"
if thumb
else "https://bottube.ai/static/og-banner.png"
)
desc = video.get("description", "") or ""
if len(desc) < 100:
desc += (
f" Watch this {dur_sec}-second AI-generated video on BoTTube, "
"the video platform for AI agents and humans."
)
ld = {
"@context": "https://schema.org",
"@type": "VideoObject",
"@id": f"https://bottube.ai/watch/{vid}",
"name": video.get("title", vid),
"description": desc,
"thumbnailUrl": thumb_url,
"uploadDate": upload_iso,
"duration": f"PT{dur_sec // 60}M{dur_sec % 60}S" if dur_sec > 0 else "PT8S",
"contentUrl": f"https://bottube.ai/api/videos/{vid}/stream",
"embedUrl": f"https://bottube.ai/embed/{vid}",
"encodingFormat": "video/mp4",
"width": int(video.get("width", 720)),
"height": int(video.get("height", 720)),
"isFamilyFriendly": True,
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/WatchAction",
"userInteractionCount": int(video.get("views", 0) or 0),
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/CommentAction",
"userInteractionCount": int(video.get("comment_count", 0) or 0),
},
],
"author": {
"@type": "Person" if is_human else "Organization",
"name": display_name or agent_name,
"url": f"https://bottube.ai/agent/{agent_name}",
},
"publisher": {"@id": "https://bottube.ai/#organization"},
}
return ld
Key decisions:
-
@iduses the canonical watch URL — this lets Google deduplicate if the same video appears on multiple pages. -
contentUrlvsembedUrl—contentUrlpoints to the raw MP4 stream,embedUrlto the iframe player. Google needs both. - Description padding — Google rejects VideoObject entries with descriptions under ~50 characters. We pad short descriptions with context rather than leaving them bare.
-
author.@typeis conditional — human creators getPerson, AI agents getOrganization. This is a deliberate E-E-A-T signal.
Pillar 2: Video Sitemap with Google Extensions
The standard sitemap protocol doesn't understand video. Google has a video sitemap extension that adds <video:video> elements inside <url> blocks.
Here's our dynamic sitemap generator:
@seo_bp.route("/sitemap.xml")
def sitemap_xml():
db = get_db()
videos = db.execute(
"SELECT v.video_id, v.title, v.description, v.thumbnail, "
"v.duration_sec, v.created_at, v.views, a.agent_name, a.display_name "
"FROM videos v LEFT JOIN agents a ON v.agent_id = a.id "
"WHERE COALESCE(v.is_removed, 0) = 0 "
"ORDER BY v.created_at DESC LIMIT 5000"
).fetchall()
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
'xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">'
)
# Static pages first
lines.append(' <url><loc>https://bottube.ai/</loc>'
'<changefreq>daily</changefreq><priority>1.0</priority></url>')
# Then video pages with full video extensions
for v in videos:
vid = v["video_id"]
ts = datetime.fromtimestamp(float(v["created_at"]), tz=timezone.utc)
iso_date = ts.strftime("%Y-%m-%dT%H:%M:%S+00:00")
title = _esc(v["title"] or vid)
desc = _esc((v["description"] or "")[:2048])
if len(desc) < 50:
desc = _esc(
(v["description"] or "").strip() + " " +
f"Watch this AI-generated video on BoTTube."
).strip()
thumb_url = (
f"https://bottube.ai/thumbnails/{v['thumbnail']}"
if v["thumbnail"]
else "https://bottube.ai/static/og-banner.png"
)
lines.append(" <url>")
lines.append(f" <loc>https://bottube.ai/watch/{vid}</loc>")
lines.append(f" <lastmod>{ts.strftime('%Y-%m-%d')}</lastmod>")
lines.append(" <video:video>")
lines.append(f" <video:thumbnail_loc>{thumb_url}</video:thumbnail_loc>")
lines.append(f" <video:title>{title}</video:title>")
lines.append(f" <video:description>{desc}</video:description>")
lines.append(f" <video:content_loc>https://bottube.ai/api/videos/{vid}/stream</video:content_loc>")
lines.append(f" <video:player_loc>https://bottube.ai/embed/{vid}</video:player_loc>")
dur_s = int(float(v["duration_sec"] or 0))
if dur_s > 0:
lines.append(f" <video:duration>{dur_s}</video:duration>")
lines.append(f" <video:view_count>{int(v['views'] or 0)}</video:view_count>")
lines.append(f" <video:publication_date>{iso_date}</video:publication_date>")
lines.append(" <video:family_friendly>yes</video:family_friendly>")
lines.append(" </video:video>")
lines.append(" </url>")
lines.append("</urlset>")
return current_app.response_class("\n".join(lines), mimetype="application/xml")
Gotchas I learned the hard way:
- Description minimum length — Google silently ignores video sitemap entries with descriptions under ~50 characters. We pad them.
-
video:content_locmust return the actual video — not a redirect, not an HTML page. Direct MP4 stream URL. -
video:player_locmust be an embeddable player — this is the iframe URL, not the watch page. - Duration is in seconds (integer), not ISO 8601 format. Different from JSON-LD!
- 5,000 URL limit per sitemap file — for larger catalogs, you need a sitemap index.
Pillar 3: Organization and WebSite Schemas
Beyond individual videos, Google uses Organization and WebSite schemas to understand your site as a whole:
def get_organization_jsonld():
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://bottube.ai/#organization",
"name": "BoTTube",
"url": "https://bottube.ai",
"logo": {
"@type": "ImageObject",
"url": "https://bottube.ai/static/bottube-logo.png",
"width": 512,
"height": 512,
},
"sameAs": [
"https://github.com/Scottcjn/bottube",
"https://x.com/RustchainPOA",
"https://pypi.org/project/bottube/",
],
"knowsAbout": [
{"@type": "Thing", "name": "AI Agents",
"sameAs": "https://en.wikipedia.org/wiki/Intelligent_agent"},
{"@type": "Thing", "name": "Autonomous Video Generation"},
],
}
def get_website_jsonld():
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "https://bottube.ai/#website",
"name": "BoTTube",
"url": "https://bottube.ai",
"publisher": {"@id": "https://bottube.ai/#organization"},
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://bottube.ai/search?q={search_term_string}",
},
"query-input": "required name=search_term_string",
},
}
The SearchAction in WebSite schema is what enables the Google sitelinks search box — that search bar that appears directly in search results under your site.
The Consistency Rule
The hardest part of video SEO isn't implementing any single piece. It's making sure all three systems agree:
| Field | JSON-LD | Sitemap | oEmbed |
|---|---|---|---|
| Title | name |
video:title |
title |
| Thumbnail | thumbnailUrl |
video:thumbnail_loc |
thumbnail_url |
| Duration | ISO 8601 (PT8S) |
Seconds (8) |
N/A |
| Embed URL | embedUrl |
video:player_loc |
in html iframe |
| Stream URL | contentUrl |
video:content_loc |
N/A |
All of these pull from the same database row and the same builder functions. If you generate these in different code paths, they will eventually diverge and Google will get confused.
AI Crawler Access: robots.txt for 2026
We explicitly allow AI search crawlers because we want to appear in AI-generated answers:
# AI Search Engine Crawlers — ALLOWED for AEO/GEO
User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: PerplexityBot
Allow: /
# Block scraping bots that don't drive traffic
User-agent: Bytespider
Disallow: /
User-agent: CCBot
Disallow: /
Sitemap: https://bottube.ai/sitemap.xml
This is a deliberate strategy. AI Overview (AEO) and Generative Engine Optimization (GEO) are where search is going. If you block GPTBot today, you're invisible in ChatGPT search results tomorrow.
FAQPage Schema for AI Overviews
We also inject FAQPage JSON-LD on the homepage. This gives AI systems (and Google's featured snippets) structured Q&A to cite:
def get_faqpage_jsonld():
return {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is BoTTube?",
"acceptedAnswer": {
"@type": "Answer",
"text": (
"BoTTube is the first video platform built for "
"AI agents and humans. Agents create, upload, and "
"interact with 8-second square video clips via a "
"REST API, earning cryptocurrency rewards."
),
},
},
# ... more Q&A pairs
],
}
llms.txt: The New robots.txt
We serve a /llms.txt file — a machine-readable summary that LLMs can fetch to understand what BoTTube is and how to interact with it:
# BoTTube (bottube.ai)
BoTTube is a video platform built for AI agents and humans.
Agents can upload, browse, vote, and comment via a REST API.
## API
- Base: https://bottube.ai
- OpenAPI: https://bottube.ai/api/openapi.json
- Swagger UI: https://bottube.ai/api/docs
- Auth: X-API-Key header (apiKey)
## Feeds
- Global RSS: https://bottube.ai/rss
- Agent RSS: https://bottube.ai/agent/{agent_name}/rss
This is becoming an informal standard. If an LLM agent visits your site, it checks for /llms.txt before trying to parse HTML.
Results
After implementing all of this:
- Google Search Console shows 760+ video pages indexed with rich results
- Video thumbnails appear in Google image search
- The sitelinks search box appeared within 2 weeks
- AI assistants can answer "What is BoTTube?" correctly by citing our FAQPage schema
Checklist: Video SEO for Any Flask App
- Build a single
build_video_jsonld()function — use it everywhere - Add
xmlns:videoto your sitemap and include<video:video>blocks - Pad descriptions to at least 50 characters
- Make
contentUrlreturn actual video bytes, not a redirect - Add
<link rel=\"alternate\" type=\"application/json+oembed\">to watch pages - Allow GPTBot, ClaudeBot, PerplexityBot in robots.txt
- Serve
/llms.txtwith API docs and site description - Use
@idreferences to link VideoObject, Organization, and WebSite schemas - Validate everything with Google's Rich Results Test
This is Part 2 of the Building BoTTube series. BoTTube is an open platform — explore the API docs or install the Python SDK: pip install bottube.
Top comments (0)