Schema Markup skill for Codex
JSON-LD that matches the page and passes Google. Codex maps every structured data property to a visible field before writing code, builds JSON-LD from the same data that renders the template, and links entities with @id in one graph. A bundled linter catches invalid JSON, missing required properties, priced strings, non-ISO dates and values that never appear on the page, and Codex skips rich result types Google has retired.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- schema-markup Installs to ~/.agents/skills/schema-markup/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install schema-markup The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project schema-markup Installs to .agents/skills/schema-markup/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/schema-markup.zipunzip -o schema-markup.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) schema-markup PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $schema-markup, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Adds and fixes schema.org JSON-LD structured data — Product and Offer, Article, BreadcrumbList, Organization, LocalBusiness, Event, JobPosting, VideoObject, Review — generated from the same data that renders the page, linted locally and confirmed in the Rich Results Test. Use when asked to add structured data, rich snippets or JSON-LD, when Search Console shows structured data errors or warnings, or when a template change may have broken existing markup.
Use it when
- Adding JSON-LD to product, article, event, job or video templates
- Search Console Enhancements reports structured data errors
- Auditing markup after a theme, plugin or framework change
- Deciding whether a type still earns a rich result
Use something else when
- You need Google Business Profile and location page work: use local-seo
- The question is general page titles and content for a query: use on-page-seo
How to check the result
Run check_jsonld.py on built pages with zero errors, then the Rich Results Test on a deployed URL shows the target type as valid.
The mistake it prevents
Marking up ratings, prices or FAQs that are not visible on the page, which breaks Google's policies and can cost rich results sitewide.
Done well: every property in the markup is visible on the page, comes from the same variable that renders it, uses a type Google currently supports for a rich result (or a clear entity reason), and validates with zero errors in the Rich Results Test.
Hard rules
- Markup must describe content the user can see on that page. Prices, ratings, dates, FAQs or authors that are not visible violate Google’s structured data policies and can earn a manual action that removes rich results sitewide.
- Never invent reviews or aggregate ratings, and never mark up reviews of your own business on its own pages (self-serving LocalBusiness/Organization reviews are not eligible for review stars).
- Structured data earns eligibility, not a guarantee, and is not a ranking factor by itself. Set that expectation with the user.
- Prefer JSON-LD in a
<script type="application/ld+json">in the server-rendered HTML. JS-injected markup works for Google but is fragile and invisible to some other consumers.
Choose the type
Rich result types change. Check Google’s Search Gallery before promising a visual result. As of 2026:
| Page | Type | Rich result? |
|---|---|---|
| Product detail | Product + Offer (+ AggregateRating/Review if real and visible) | Yes: product snippets, merchant listings |
| Blog post / news | Article, BlogPosting, NewsArticle | Enhanced article info; helps author/date understanding |
| Any page with breadcrumb UI | BreadcrumbList | Yes |
| Homepage / about | Organization (logo, sameAs, contact) | Knowledge panel signals, logo |
| Physical business location | LocalBusiness subtype (Dentist, Restaurant…) | Supports local understanding; see local-seo |
| Event detail | Event | Yes |
| Job detail | JobPosting | Yes (Google for Jobs) |
| Video watch page | VideoObject | Yes (video features, key moments) |
| Software / app | SoftwareApplication | Yes, with rating or review |
| Recipe | Recipe | Yes |
| Forum thread / profile | DiscussionForumPosting, ProfilePage | Yes |
| FAQ section | FAQPage | No longer shown (retired 2026); optional for meaning |
| How-to steps | HowTo | No (removed 2023) |
If a type has no rich result and adds nothing about the main entity, skip it. Do not stack every vaguely relevant type on every page.
Workflow
- [ ] 1. Read AGENTS.md; find templates, existing markup and SEO components
- [ ] 2. Map each template to a type and list the visible fields that back each property
- [ ] 3. Build markup from the page's own data with one helper per type
- [ ] 4. Connect entities with @id in a single @graph
- [ ] 5. Lint locally with scripts/check_jsonld.py
- [ ] 6. Confirm in the Rich Results Test and Search Console
1. Inspect
grep -rn "application/ld+json" --exclude-dir=node_modules --exclude-dir=.next . | head -50
grep -rnE "schema-dts|next-seo|jsonLd|structuredData" --exclude-dir=node_modules . | head
Find duplicates: SEO plugins (Yoast, Rank Math), theme code and app code often each emit
an Organization or Product. Two conflicting Product nodes for one page confuse parsers.
2. Map properties to visible fields
Write the mapping before code. Example for a product template:
| Property | Source | Visible where |
|---|---|---|
name | product.title | H1 |
image | product.images[].url (absolute) | gallery |
offers.price | variant.price (number) | price block |
offers.priceCurrency | store currency (ISO 4217) | price block |
offers.availability | stock status → https://schema.org/InStock / OutOfStock | stock badge |
aggregateRating | review service totals | stars under title |
If a field is not shown, either show it or drop the property.
3. Generate from data
TypeScript with schema-dts (types only, no runtime) keeps property names honest:
import type { Product, WithContext } from "schema-dts";
export function productJsonLd(p: ProductView, siteUrl: string): WithContext<Product> {
return {
"@context": "https://schema.org",
"@type": "Product",
"@id": `${siteUrl}/p/${p.slug}#product`,
name: p.title,
description: p.summary,
image: p.images.map((i) => new URL(i.src, siteUrl).toString()),
sku: p.sku,
brand: { "@type": "Brand", name: p.brand },
offers: {
"@type": "Offer",
url: `${siteUrl}/p/${p.slug}`,
price: p.price.toFixed(2), // "19.99": digits and dot only, no symbol
priceCurrency: p.currency, // "USD"
availability: p.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
},
...(p.reviewCount > 0 && {
aggregateRating: { "@type": "AggregateRating", ratingValue: p.rating, reviewCount: p.reviewCount },
}),
};
}
// Render: escape "<" so a product title cannot close the script tag.
export function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
/>
);
}
Install schema-dts as a dev dependency only if the project uses TypeScript (needs
network/approval in a sandbox); otherwise plain objects are fine.
4. Connect entities with @id
One @graph per page, stable @ids (URL + fragment), and references instead of copies:
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "Organization", "@id": "https://www.example.com/#org", "name": "Example Co",
"url": "https://www.example.com/", "logo": "https://www.example.com/logo.png",
"sameAs": ["https://www.linkedin.com/company/example", "https://github.com/example"] },
{ "@type": "WebSite", "@id": "https://www.example.com/#website", "url": "https://www.example.com/",
"name": "Example", "publisher": { "@id": "https://www.example.com/#org" } },
{ "@type": "BlogPosting", "@id": "https://www.example.com/blog/x#article",
"headline": "How we cut build times by 60%", "datePublished": "2026-10-02T09:00:00+00:00",
"dateModified": "2026-10-20T14:30:00+00:00", "image": ["https://www.example.com/og/x.png"],
"author": { "@type": "Person", "name": "Dana Ruiz", "url": "https://www.example.com/team/dana" },
"publisher": { "@id": "https://www.example.com/#org" } },
{ "@type": "BreadcrumbList", "itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Blog", "item": "https://www.example.com/blog" },
{ "@type": "ListItem", "position": 2, "name": "How we cut build times by 60%" } ] }
]
}
The Sitelinks search box (WebSite + SearchAction) was retired in 2024; plain
WebSite with name still helps site-name display.
5. Lint locally
python3 scripts/check_jsonld.py dist/products/blue-widget/index.html
python3 scripts/check_jsonld.py $(find out -name '*.html' | head -200) --summary
python3 scripts/check_jsonld.py https://staging.example.com/p/blue-widget # needs network
It reports invalid JSON, missing required properties for common Google types, non-ISO
dates, prices with symbols, relative URLs, retired types, and name/headline/price
values that do not appear in the visible text. Exit code 1 means errors. Treat
“not found in visible text” warnings as policy risks to inspect, not noise.
6. Confirm with Google
- Rich Results Test (search.google.com/test/rich-results) on a deployed URL or pasted code: zero errors for the target type. Warnings are optional properties; fill those you can back with visible data.
- Schema Markup Validator (validator.schema.org) for schema.org-level syntax on types Google does not test.
- After deploy, Search Console → Enhancements shows per-type valid/invalid counts; use Validate fix after correcting a template.
Gotchas
pricemust be a number or numeric string ("1299.00"), never"$1,299". UsepriceSpecificationfor ranges or unit prices. Merchant listings also wantshippingDetailsandhasMerchantReturnPolicy, which can be set once at Organization level in Search Console or markup.- Dates need a timezone offset when they include a time; a bare local time is ambiguous.
ratingValuewithoutreviewCount/ratingCountis an error; zero-review products must omitaggregateRatingentirely.BreadcrumbListpositions start at 1 and must match the visible breadcrumb trail.JSON.stringifyoutput inside a script tag must escape<; a product named</script>otherwise breaks the page and opens an XSS hole.- Client-only frameworks that inject JSON-LD after hydration: verify with URL Inspection’s rendered HTML that the markup is present for Googlebot.
EventpastendDateor cancelled events must updateeventStatus; stale events produce errors and misleading results.
Output
**Templates covered:** product (Product+Offer+AggregateRating+BreadcrumbList), article
(BlogPosting+BreadcrumbList), sitewide (Organization+WebSite in layout).
**Source of truth:** lib/seo/jsonld.ts helpers, fed by the same props as the page.
**Removed:** duplicate Organization from theme header.
**Checks:** check_jsonld.py on 40 built pages: 0 errors; Rich Results Test on 3 staging
URLs: Product snippets and Breadcrumbs valid.
**Not marked up:** FAQ block (no rich result since 2026, kept as plain HTML). What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Schema Markup"
short_description: "JSON-LD that matches the page and passes Google"
default_prompt: "$schema-markup Add Product and Breadcrumb structured data to our product page template" scripts/check_jsonld.py
#!/usr/bin/env python3
"""Extract and lint JSON-LD from HTML files or URLs. Standard library only.
python3 scripts/check_jsonld.py page.html [more.html ...]
python3 scripts/check_jsonld.py https://www.example.com/product/1 # needs network
python3 scripts/check_jsonld.py out/**/*.html --summary # many build files
Checks: JSON syntax, @context, @graph flattening, Google-required properties for common
rich result types, absolute URLs, ISO 8601 dates, retired rich result types, and whether
key strings (name/headline/price) appear in the page's visible text.
This is a fast pre-check; confirm eligibility with Google's Rich Results Test.
Exit codes: 0 no errors, 1 errors found, 2 nothing could be read.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import urllib.request
from html.parser import HTMLParser
# Required properties (all must be present). "a|b" means at least one of a or b.
REQUIRED = {
"Article": [], "NewsArticle": [], "BlogPosting": [],
"Product": ["name", "offers|review|aggregateRating"],
"Offer": ["price|priceSpecification"],
"BreadcrumbList": ["itemListElement"],
"ListItem": ["position"],
"Event": ["name", "startDate", "location"],
"Recipe": ["name", "image"],
"JobPosting": ["title", "description", "datePosted", "hiringOrganization"],
"VideoObject": ["name", "thumbnailUrl", "uploadDate"],
"LocalBusiness": ["name", "address"],
"Organization": [],
"SoftwareApplication": ["name", "offers"],
"AggregateRating": ["ratingValue", "ratingCount|reviewCount"],
"Review": ["author", "reviewRating"],
"ProfilePage": ["mainEntity"],
"DiscussionForumPosting": ["author", "datePublished", "text|image|video"],
"Dataset": ["name", "description"],
}
RECOMMENDED = {
"Article": ["headline", "image", "datePublished", "author"],
"NewsArticle": ["headline", "image", "datePublished", "author"],
"BlogPosting": ["headline", "image", "datePublished", "author"],
"Product": ["image", "description"],
"Offer": ["priceCurrency", "availability"],
"Organization": ["name", "url", "logo"],
"LocalBusiness": ["telephone", "openingHoursSpecification", "geo", "url"],
"Event": ["image", "description", "endDate", "eventStatus", "offers", "organizer"],
"JobPosting": ["jobLocation|applicantLocationRequirements", "validThrough", "baseSalary"],
}
LOCAL_SUBTYPES = ("Restaurant", "Store", "Dentist", "MedicalBusiness", "AutoRepair",
"LegalService", "HomeAndConstructionBusiness", "FoodEstablishment")
RETIRED = {
"FAQPage": "FAQ rich results were retired in 2026; markup is harmless but earns no rich result",
"HowTo": "HowTo rich results were removed in 2023",
}
URL_KEYS = {"url", "image", "logo", "thumbnailUrl", "contentUrl", "embedUrl", "item", "sameAs", "@id"}
DATE_KEYS = {"datePublished", "dateModified", "uploadDate", "startDate", "endDate", "datePosted",
"validThrough", "priceValidUntil"}
ISO = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$")
class Extract(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.blocks: list[str] = []
self.text: list[str] = []
self._ld = False
self._skip = 0
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "script" and (a.get("type") or "").lower().strip() == "application/ld+json":
self._ld = True
self.blocks.append("")
elif tag in ("script", "style", "noscript", "template"):
self._skip += 1
def handle_endtag(self, tag):
if tag == "script" and self._ld:
self._ld = False
elif tag in ("script", "style", "noscript", "template") and self._skip:
self._skip -= 1
def handle_data(self, data):
if self._ld:
self.blocks[-1] += data
elif not self._skip:
self.text.append(data)
def load(src: str) -> str:
if re.match(r"^https?://", src):
req = urllib.request.Request(src, headers={"User-Agent": "jsonld-check/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode(r.headers.get_content_charset() or "utf-8", "replace")
with open(src, encoding="utf-8", errors="replace") as fh:
return fh.read()
def types_of(node: dict) -> list[str]:
t = node.get("@type", [])
return [x.split("/")[-1] for x in (t if isinstance(t, list) else [t]) if isinstance(x, str)]
def walk(node, path="$"):
"""Yield (path, dict) for every typed object, descending into nested values."""
if isinstance(node, list):
for i, item in enumerate(node):
yield from walk(item, f"{path}[{i}]")
elif isinstance(node, dict):
if "@type" in node:
yield path, node
for k, v in node.items():
if isinstance(v, (dict, list)):
yield from walk(v, f"{path}.{k}")
def has(node: dict, spec: str) -> bool:
return any(node.get(k) not in (None, "", []) for k in spec.split("|"))
def check_values(node: dict, path: str, errors: list, warns: list) -> None:
for k, v in node.items():
vals = v if isinstance(v, list) else [v]
for val in vals:
if k in URL_KEYS and isinstance(val, str) and k != "@id":
if not re.match(r"^https?://", val):
warns.append(f"{path}.{k} is not an absolute URL: {val!r}")
if k in DATE_KEYS and isinstance(val, str) and not ISO.match(val.strip()):
errors.append(f"{path}.{k} is not ISO 8601: {val!r}")
if "price" in node and isinstance(node["price"], str) and re.search(r"[^\d.]", node["price"]):
errors.append(f"{path}.price must be a number without currency symbols or commas: {node['price']!r}")
def audit(src: str, raw: str) -> tuple[list[str], list[str], list[str]]:
p = Extract()
p.feed(raw)
visible = html.unescape(" ".join(" ".join(p.text).split())).lower()
errors, warns, found = [], [], []
if not p.blocks:
warns.append("no application/ld+json blocks found (microdata/RDFa not checked)")
for i, block in enumerate(p.blocks):
try:
data = json.loads(block)
except json.JSONDecodeError as exc:
errors.append(f"block {i}: invalid JSON ({exc.msg} at line {exc.lineno} col {exc.colno})")
continue
roots = data if isinstance(data, list) else [data]
for r in roots:
if isinstance(r, dict) and "schema.org" not in str(r.get("@context", "")):
warns.append(f"block {i}: missing or unexpected @context {r.get('@context')!r}")
for path, node in walk(data, f"block{i}"):
for t in types_of(node):
found.append(t)
key = "LocalBusiness" if t in LOCAL_SUBTYPES else t
if t in RETIRED:
warns.append(f"{path} {t}: {RETIRED[t]}")
for spec in REQUIRED.get(key, []):
if not has(node, spec):
errors.append(f"{path} {t}: missing required {spec.replace('|', ' or ')}")
for spec in RECOMMENDED.get(key, []):
if not has(node, spec):
warns.append(f"{path} {t}: missing recommended {spec.replace('|', ' or ')}")
for prop in ("name", "headline"):
val = node.get(prop)
if isinstance(val, str) and len(val) > 3 and t not in ("Organization", "ListItem", "Person", "Brand", "WebSite"):
if val.lower().strip() not in visible:
warns.append(f"{path} {t}.{prop} not found in visible text: {val[:60]!r}")
if t == "Offer" and "price" in node:
price = str(node["price"])
if price not in visible and price.rstrip("0").rstrip(".") not in visible:
warns.append(f"{path} Offer.price {price} not found in visible text")
check_values(node, path, errors, warns)
return errors, warns, found
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("sources", nargs="+")
ap.add_argument("--summary", action="store_true", help="one line per file instead of full detail")
args = ap.parse_args()
total_err, readable = 0, 0
for src in args.sources:
try:
raw = load(src)
except Exception as exc:
print(f"{src}: cannot read ({exc})")
continue
readable += 1
errors, warns, found = audit(src, raw)
total_err += len(errors)
types = ", ".join(sorted(set(found))) or "none"
if args.summary:
print(f"{'FAIL' if errors else 'ok '} {src} types=[{types}] errors={len(errors)} warnings={len(warns)}")
continue
print(f"== {src}\n types: {types}")
for e in errors:
print(f" ERROR {e}")
for w in warns:
print(f" WARN {w}")
if not readable:
return 2
return 1 if total_err else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.