Agent Experiences

Schema Markup for Agents

Structured data does not buy you AI citations. It does make your pages unambiguous — who wrote this, when it changed, what entity you are, what the product costs — so that any system reading you, human or machine, does not have to guess. Implement it for the things it demonstrably does, and treat citation lift as unproven upside.

First, the contested part

You will find confident claims in both directions about whether schema markup gets you into AI answers. It is worth being precise about what is actually known.

Google's position

Google has stated publicly and repeatedly that structured data is not a ranking factor for AI Overviews and is not a requirement for inclusion in them. Their guidance is that schema exists to make specific rich result types eligible in classic search, and that nothing about generative answers changed that.

The dissent

Practitioners — Mike King and the iPullRank team most vocally — argue that this guidance is self-serving, that a platform benefiting from cheap, clean extraction has little incentive to tell publishers their markup matters, and that the retrieval and grounding layers behind AI answers plausibly consume the same structured signals.

Where that leaves you

This is genuinely unresolved. Nobody outside the retrieval teams has visibility into whether a JSON-LD block influences a grounding decision, and the public statements and the practitioner counter-argument are both unfalsifiable from where you sit. Treating either as settled is a mistake.

The practical answer is that it does not matter much, because the uncontested benefits below already justify the work. Implement schema for those. If it also helps citation, that is upside you did not pay extra for. What you should not do is skip content quality, freshness, or source clarity because you shipped markup — nobody, on either side of this argument, claims schema substitutes for those.

What schema uncontestedly does

Rich results in classic search

Breadcrumbs, product prices, review stars, recipe cards, event listings and video keys are all gated on valid markup. Classic search still sends the traffic that answer engines do not — and it sends less of it than it used to, so the presentation you do get is worth defending.

Entity disambiguation

This is the highest-value thing markup does. A sameAs array pointing at Wikidata, Wikipedia, LinkedIn, Crunchbase or GitHub tells any consumer that your "Atlas" is that Atlas, and that your author is the same person who wrote those papers. Entity resolution is a hard problem you can solve for free by declaring the answer.

Machine-readable dates and authorship

Parsing "updated last spring" out of prose is guesswork. An ISO 8601 dateModified and a named author with a resolvable identity are not. Freshness is a measured factor in which URLs get cited: Ahrefs' 2026 analysis found AI-cited URLs skew roughly 25.7% fresher than the general set.

Source: Ahrefs, 2026

Product and offer feeds

Agentic commerce protocols read product data, not paragraphs. Price, currency, availability, SKU and validity window are the fields a buying agent needs to compare you against an alternative. This one is not speculative — it is the input format. See the e-commerce guide for how that shook out in practice.

Note what is missing from that list: "makes ChatGPT recommend you." Schema is plumbing. It removes ambiguity that would otherwise have to be inferred. Everything below is about doing it correctly enough that the removal actually happens.

Article

The workhorse. Put it on every editorial page. Everything here should also be visible on the rendered page — schema is a restatement of what the reader sees, never a place to assert things the page does not say.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "How Answer Engines Choose Which Sources to Cite",
  "description": "A field guide to citation selection in generative search, based on observed crawler and retrieval behavior.",
  "datePublished": "2026-03-04T09:00:00-08:00",
  "dateModified": "2026-08-11T14:20:00-07:00",
  "author": {
    "@type": "Person",
    "@id": "https://example.com/#/schema/person/rmoss",
    "name": "Rebecca Moss",
    "jobTitle": "Principal Research Engineer",
    "url": "https://example.com/authors/rebecca-moss",
    "sameAs": [
      "https://www.linkedin.com/in/rebeccamoss",
      "https://github.com/rmoss",
      "https://orcid.org/0000-0002-1825-0097"
    ]
  },
  "publisher": {
    "@type": "Organization",
    "@id": "https://example.com/#organization",
    "name": "Example Research",
    "logo": {
      "@type": "ImageObject",
      "url": "https://example.com/logo-600x60.png",
      "width": 600,
      "height": 60
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://example.com/research/citation-selection"
  },
  "image": ["https://example.com/img/citation-selection-1200x675.png"],
  "inLanguage": "en-US"
}
</script>

Which fields are load-bearing

  • dateModified — the one people leave stale. Freshness is measurable and it correlates with citation; the Ahrefs 2026 figure above is the cleanest public evidence. But this field is only useful if it is true. A dateModified that bumps on every deploy while the prose has not changed since 2023 is a lie that a retrieval system can catch by comparing your text to its own cached copy, and a visible "updated today" on unchanged content is the kind of thing that erodes human trust too.
  • author as a Person, not a string"author": "Rebecca Moss" is technically valid and nearly worthless. A Person object with a URL and a sameAs array is a claim that can be checked against the outside world, which is the entire point.
  • sameAs on the author — ORCID for researchers, LinkedIn and GitHub for practitioners, a personal site with an about page. These connect an author byline to an identity with a track record. More on this in trust and authority signals.
  • mainEntityOfPage — states which URL this article canonically lives at. Cheap insurance against your content being attributed to a syndication copy.
  • headline — should match the visible h1. Google has historically truncated long headlines in rich results; more importantly, a headline that disagrees with the page heading is a contradiction signal.

Organization

Define this once, on your homepage or a dedicated about page, give it a stable @id, and reference that @id from everywhere else instead of redefining the organization on every page.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Organization",
  "@id": "https://example.com/#organization",
  "name": "Example Research",
  "legalName": "Example Research, Inc.",
  "url": "https://example.com/",
  "logo": {
    "@type": "ImageObject",
    "url": "https://example.com/logo-600x60.png"
  },
  "foundingDate": "2019-06-01",
  "sameAs": [
    "https://en.wikipedia.org/wiki/Example_Research",
    "https://www.wikidata.org/wiki/Q00000000",
    "https://www.linkedin.com/company/example-research",
    "https://github.com/example-research",
    "https://www.crunchbase.com/organization/example-research"
  ],
  "contactPoint": [
    {
      "@type": "ContactPoint",
      "contactType": "customer support",
      "email": "support@example.com",
      "telephone": "+1-555-0100",
      "availableLanguage": ["en", "es"],
      "areaServed": "US"
    },
    {
      "@type": "ContactPoint",
      "contactType": "press",
      "email": "press@example.com"
    }
  ],
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "410 Mission Street, Suite 300",
    "addressLocality": "San Francisco",
    "addressRegion": "CA",
    "postalCode": "94105",
    "addressCountry": "US"
  }
}
</script>

The sameAs array is the highest-value part of this block, and it is worth understanding why. Any system reasoning about your organization has to first decide which organization you are. There are other companies with your name, or names close to yours; there is a defunct company that used to have your domain; there is a person with your brand as a surname. That disambiguation happens whether or not you participate in it.

sameAs lets you participate. Pointing at a Wikidata Q-number or a Wikipedia article is the strongest form, because those are the identifiers knowledge graphs are actually keyed on — you are handing over a primary key rather than asking for a fuzzy match. LinkedIn, GitHub, and Crunchbase are weaker but still useful corroboration. If you have no Wikidata entry, the ordinary path is to earn independent coverage first; creating a self-referential entry for a company nobody has written about tends to get reverted, and is not the shortcut it looks like.

One rule: every URL in sameAs must be a profile of this entity, controlled or clearly about it. It is not a link-building field. Stuffing it with directory listings you do not own dilutes the signal and, at scale, looks like manipulation.

FAQPage

Set your expectations correctly before you implement this. Google retired FAQ rich results for the general web — they now surface only for a narrow set of well-known government and health sites. If your reason for adding FAQPage markup is to get the accordion in the SERP, that reason no longer exists.

It is still worth adding, for a different reason: it converts a question-and-answer section from a visual convention into a declared structure. A parser that would otherwise have to infer "this h3 is a question and the paragraph after it is the answer" is told so explicitly, with the answer boundaries marked. That is useful to anything extracting a self-contained answer from your page.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "@id": "https://example.com/support/billing-faq#faq",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "When does my plan renew?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Plans renew on the monthly anniversary of your signup date. If you signed up on the 31st, renewal falls on the last day of shorter months."
      }
    },
    {
      "@type": "Question",
      "name": "Do you refund partial months?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. Cancelling stops the next renewal and your account stays active until the end of the paid period. We do not prorate refunds for the current period."
      }
    }
  ]
}
</script>
  • Every question and answer in the markup must appear on the page, in full, visible to the user. Marking up answers that live only in the JSON-LD is the single most common way sites earn a structured data manual action.
  • Answers should be complete sentences that survive being lifted out of context. "No, see above" is useless to an extractor.
  • FAQPage is for questions you answer on your own page. Pages where users post questions and answers are QAPage with an acceptedAnswer and suggestedAnswer — a different type with different rules.

Product with Offer

This is the block that agentic commerce actually consumes. When a shopping assistant compares your product to two others, it is comparing these fields — not your copy.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://example.com/products/trail-runner-gtx#product",
  "name": "Trail Runner GTX Waterproof Hiking Shoe",
  "description": "Gore-Tex lined trail shoe with a 6mm drop, Vibram Megagrip outsole, and a 320g per-shoe weight in US M9.",
  "sku": "TR-GTX-9-BLK",
  "mpn": "TRGTX2026BLK09",
  "gtin13": "0123456789012",
  "brand": {
    "@type": "Brand",
    "name": "Example Outdoor"
  },
  "image": [
    "https://example.com/img/trail-runner-gtx-1x1.jpg",
    "https://example.com/img/trail-runner-gtx-4x3.jpg"
  ],
  "color": "Black",
  "material": "Recycled polyester mesh, Gore-Tex membrane",
  "weight": {
    "@type": "QuantitativeValue",
    "value": 320,
    "unitCode": "GRM"
  },
  "offers": {
    "@type": "Offer",
    "url": "https://example.com/products/trail-runner-gtx",
    "price": "168.00",
    "priceCurrency": "USD",
    "priceValidUntil": "2026-12-31",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "seller": {
      "@id": "https://example.com/#organization"
    },
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "shippingRate": {
        "@type": "MonetaryAmount",
        "value": "0.00",
        "currency": "USD"
      },
      "shippingDestination": {
        "@type": "DefinedRegion",
        "addressCountry": "US"
      },
      "deliveryTime": {
        "@type": "ShippingDeliveryTime",
        "handlingTime": {
          "@type": "QuantitativeValue",
          "minValue": 0,
          "maxValue": 1,
          "unitCode": "DAY"
        },
        "transitTime": {
          "@type": "QuantitativeValue",
          "minValue": 2,
          "maxValue": 5,
          "unitCode": "DAY"
        }
      }
    },
    "hasMerchantReturnPolicy": {
      "@type": "MerchantReturnPolicy",
      "applicableCountry": "US",
      "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
      "merchantReturnDays": 60,
      "returnMethod": "https://schema.org/ReturnByMail",
      "returnFees": "https://schema.org/FreeReturn"
    }
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.4",
    "reviewCount": 218,
    "bestRating": "5"
  }
}
</script>
  • availability must be a Schema.org enumeration URL, not the word "in stock", and it has to reflect reality at request time. An agent that recommends you and then hits an out-of-stock page has learned something about your feed.
  • price and priceCurrency — price as a string without currency symbols or thousands separators. Currency as an ISO 4217 code. If price varies by variant, emit a distinct Offer per variant or an AggregateOffer with lowPrice and highPrice; a single price that only applies to the cheapest size is a mismatch waiting to be caught.
  • priceValidUntil — an expiry, not decoration. Once it passes, some consumers treat the offer as stale. Set it from your actual pricing cycle and let your CMS roll it forward.
  • sku, mpn, gtin — identifiers are how a comparison engine knows your listing and someone else's are the same physical product. Without them you are an unmatched string.
  • shipping and returns — increasingly the deciding fields in an agent-mediated comparison, because they are the ones buyers are surprised by. Declaring them removes a reason to pick the other listing.

BreadcrumbList

Cheap to generate from your route structure, still rendered in search results, and it tells a reader arriving mid-site where this page sits in your hierarchy. There is no argument against shipping it.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://example.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Research",
      "item": "https://example.com/research"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "How Answer Engines Choose Which Sources to Cite"
    }
  ]
}
</script>

Two details: positions are 1-indexed and must be contiguous, and the final item — the current page — conventionally omits item, since it is the page you are already on. The trail should match the breadcrumb you render visually, not an idealized site map.

Connecting entities with @graph

This is the part most implementations get wrong, and it is the part that separates markup that describes a page from markup that describes a knowledge graph.

The common failure looks like this: a page emits four separate <script type="application/ld+json"> blocks — an Article, an Organization, a BreadcrumbList, a WebSite — each internally valid, none referring to the others. A consumer sees four disconnected objects and has to guess that the Organization in block two is the same one that publishes the Article in block one. Across your site you have now declared dozens of distinct, unrelated organizations that happen to share a name.

The fix is @id. Give every reusable entity a stable, globally unique identifier — a URI you control, conventionally the canonical URL plus a fragment — define it once, and reference it by @id everywhere else. Then put the whole page's markup in a single @graph array so the relationships are in one document.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://example.com/#organization",
      "name": "Example Research",
      "url": "https://example.com/",
      "sameAs": ["https://www.wikidata.org/wiki/Q00000000"]
    },
    {
      "@type": "WebSite",
      "@id": "https://example.com/#website",
      "url": "https://example.com/",
      "name": "Example Research",
      "publisher": { "@id": "https://example.com/#organization" },
      "inLanguage": "en-US"
    },
    {
      "@type": "Person",
      "@id": "https://example.com/#/schema/person/rmoss",
      "name": "Rebecca Moss",
      "url": "https://example.com/authors/rebecca-moss",
      "jobTitle": "Principal Research Engineer",
      "worksFor": { "@id": "https://example.com/#organization" },
      "sameAs": [
        "https://orcid.org/0000-0002-1825-0097",
        "https://www.linkedin.com/in/rebeccamoss"
      ],
      "hasCredential": {
        "@type": "EducationalOccupationalCredential",
        "name": "PhD, Computer Science",
        "credentialCategory": "degree",
        "educationalLevel": "Doctorate",
        "recognizedBy": {
          "@type": "CollegeOrUniversity",
          "name": "Massachusetts Institute of Technology"
        }
      }
    },
    {
      "@type": "WebPage",
      "@id": "https://example.com/research/citation-selection",
      "url": "https://example.com/research/citation-selection",
      "name": "How Answer Engines Choose Which Sources to Cite",
      "isPartOf": { "@id": "https://example.com/#website" },
      "about": { "@id": "https://example.com/#organization" },
      "breadcrumb": { "@id": "https://example.com/research/citation-selection#breadcrumb" },
      "primaryImageOfPage": {
        "@type": "ImageObject",
        "@id": "https://example.com/research/citation-selection#primaryimage",
        "url": "https://example.com/img/citation-selection-1200x675.png"
      },
      "datePublished": "2026-03-04T09:00:00-08:00",
      "dateModified": "2026-08-11T14:20:00-07:00"
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://example.com/research/citation-selection#breadcrumb",
      "itemListElement": [
        { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" },
        { "@type": "ListItem", "position": 2, "name": "Research", "item": "https://example.com/research" },
        { "@type": "ListItem", "position": 3, "name": "How Answer Engines Choose Which Sources to Cite" }
      ]
    },
    {
      "@type": "Article",
      "@id": "https://example.com/research/citation-selection#article",
      "isPartOf": { "@id": "https://example.com/research/citation-selection" },
      "mainEntityOfPage": { "@id": "https://example.com/research/citation-selection" },
      "headline": "How Answer Engines Choose Which Sources to Cite",
      "author": { "@id": "https://example.com/#/schema/person/rmoss" },
      "publisher": { "@id": "https://example.com/#organization" },
      "image": { "@id": "https://example.com/research/citation-selection#primaryimage" },
      "datePublished": "2026-03-04T09:00:00-08:00",
      "dateModified": "2026-08-11T14:20:00-07:00",
      "inLanguage": "en-US"
    }
  ]
}
</script>

Read what that document now asserts that six disconnected blocks would not: Rebecca Moss works for the same organization that publishes the site, that organization is the one at a specific Wikidata node, this article is part of this page, this page is part of this website, and the breadcrumb belongs to this page rather than floating free. Every one of those is a relationship a consumer would otherwise have to infer.

Practical rules for @id: make it a URI, make it stable across deploys (never derive it from a build hash or a database row that gets reseeded), keep the same value for the same entity on every page, and use a fragment to distinguish entities that share a URL — #organization, #article, #breadcrumb. If your CMS generates markup per-component, centralize the @id values in one module so components reference rather than reinvent them.

Common mistakes

Markup that contradicts the visible page

The one with real consequences. Google's structured data policies require markup to represent content visible to the user, and violations draw manual actions that remove rich result eligibility site-wide — not just on the offending page. Review stars for reviews nobody left, a price in JSON-LD that differs from the price on the page, FAQ answers that exist only in the markup. Beyond the penalty, a mismatch between your markup and your text is exactly the kind of inconsistency that makes a source look unreliable to anything cross-checking it.

Dates in schema that disagree with dates in the copy

A specific and extremely common case of the above. The byline says "Updated March 2026" because someone typed it; dateModified says August because the template emits build time. Now you have two contradictory freshness claims on one page, and a consumer has no principled way to pick. Derive both from the same source — one CMS field, rendered into the visible dateline and the JSON-LD by the same code path — so they cannot drift.

hasCredential as a bare string

hasCredential expects an EducationalOccupationalCredential, not text. "hasCredential": "PhD Computer Science, MIT" parses, and communicates almost nothing — no issuing body, no credential type, no level. The object form separates those into fields something can act on:

"hasCredential": {
  "@type": "EducationalOccupationalCredential",
  "name": "Board Certification in Internal Medicine",
  "credentialCategory": "certification",
  "recognizedBy": {
    "@type": "Organization",
    "name": "American Board of Internal Medicine",
    "url": "https://www.abim.org/"
  }
}

Orphaned entities with no @id linking

Covered above, but worth restating as a failure mode: valid-but-disconnected markup passes every validator and still fails to say anything about relationships. If your page emits an Organization and an Article and nothing ties them together, you have described two things, not a publication.

Marking up content that is not on the page

Templates are the usual culprit: a product template emits aggregateRating for every product including the ones with zero reviews, or a global include stamps an Organization with an award nobody won. Emit fields conditionally on the underlying data actually existing. Absent is always better than fabricated.

Validation and workflow

Three checks, in order, each catching something the others miss.

1. Rich Results Test

search.google.com/test/rich-results tells you which Google rich result types your page is eligible for and which required fields are missing. Narrow by design — it only reports on types Google has features for, so it will stay silent about a perfectly good block it has no use for. Use the live URL mode, not pasted code, so you are testing what is actually served.

2. Schema.org validator

validator.schema.org checks against the vocabulary itself rather than one consumer's feature set. This is where you catch misspelled properties, values of the wrong type, and types used outside their expected domain — the things that make your markup wrong for every non-Google consumer while Google's tool shrugs.

3. Server-rendered check

The one people skip, and the one that matters most for agents. Most AI fetchers do not execute JavaScript. Markup injected client-side — by a tag manager, an analytics script, or a useEffect — is invisible to them, even though it looks perfect in your browser's inspector.

That third check is a one-liner, and it belongs in CI rather than in a quarterly audit:

# Does the raw HTML contain your JSON-LD, before any JS runs?
curl -sL https://example.com/research/citation-selection \
  | grep -o 'application/ld+json'

# Print and pretty-check the blocks that are actually in the response
curl -sL https://example.com/research/citation-selection \
  | python3 -c "
import sys, re, json
html = sys.stdin.read()
blocks = re.findall(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>', html, re.S)
print(f'{len(blocks)} JSON-LD block(s) in server HTML')
for b in blocks:
    json.loads(b)   # raises on malformed JSON
print('all blocks parse')
"

If that returns zero blocks while your browser shows them, your markup is client-injected and every non-rendering consumer is missing it. In Next.js, emit JSON-LD from a Server Component or a route's metadata — a plain <script type="application/ld+json"> with dangerouslySetInnerHTML inside a server-rendered component is in the HTML payload; the same thing inside a 'use client' effect is not. More on rendering constraints in technical implementation.

Keeping it correct after launch

  • Generate markup from the same data that renders the page. Hand-maintained JSON-LD drifts from the content within a release or two; derived markup cannot.
  • Add the parse-and-presence check above to CI for a representative URL of each template. It catches the regression where someone moves a component behind a client boundary.
  • Watch Search Console's structured data reports for errors appearing at template scale — one broken page is a content problem, four hundred is a code problem.
  • Re-validate after any CMS, framework, or tag manager upgrade. These are the changes that silently relocate your markup.
  • Prune markup for rich result types that no longer exist rather than carrying it forever. If you keep FAQPage, keep it deliberately, for the readability reason above.

Implementation checklist

  • One @graph per page rather than scattered independent blocks
  • Stable @id URIs for Organization, WebSite, and each author Person
  • sameAs on the organization pointing at Wikidata, Wikipedia, and owned profiles
  • Authors as Person objects with sameAs, not strings
  • datePublished and dateModified in ISO 8601, derived from the same field as the visible dateline
  • Every marked-up claim also present in the visible page content
  • Credentials as EducationalOccupationalCredential objects with a recognizedBy
  • Product offers carrying currency, availability, identifiers, and a real priceValidUntil
  • All JSON-LD present in the server-rendered HTML, verified in CI
  • Clean passes on both the Rich Results Test and the Schema.org validator

Unfamiliar with a term used here? The glossary covers the vocabulary around agent experience optimization, and trust and authority signals covers the non-markup half of the same problem — the credibility work that schema describes but cannot manufacture.

Stay Updated

Analysis of AI search, crawler policy and agent standards — sent when there is something worth reading, roughly twice a month. Unsubscribe anytime.

We store your email address only to send you this newsletter. See our privacy policy.