Adding AI Usage Metadata to JSON-LD Structured Data
Every post on this site carries structured AI transparency metadata — visible both to readers (as a disclosure badge) and to machines (as Schema.org JSON-LD). Here’s how I built it, from the post editor to the structured data output.
The AI Transparency Framework
The foundation is an AI Transparency page that defines structured disclosure levels for every post:
Text content levels (0–3):
- 0 — None: written entirely without AI tools
- 1 — Editorial assistance: grammar, rephrasing, title suggestions
- 2 — Co-drafting: AI helped expand ideas or restructure content
- 3 — AI-generated (human reviewed): majority AI-generated, human-edited
Code content levels (0–2):
- 0 — Human-written: all code written manually
- 1 — AI-assisted: AI helped with boilerplate, debugging, refactoring
- 2 — Primarily AI-generated: most code AI-generated, all reviewed
Plus two free-text fields: tools used (e.g., “Claude, Copilot”) and an optional description.
The question was: how do we get these values into posts and surface them as machine-readable structured data?
Step 1: Adding Fields to the Post Editor
The site runs on Indiekit, an IndieWeb server with a Micropub-based publishing flow. Posts are created through an admin form, stored in MongoDB, and rendered by Eleventy.
Four new fields were added to the post creation form’s “Advanced options” section — two dropdowns for the levels, two text inputs for tools and description:
{{ select({
name: "ai-text-level",
label: "AI text level",
optional: true,
items: [
{ text: "0 — None", value: "0", selected: properties["ai-text-level"] === "0" },
{ text: "1 — Editorial assistance", value: "1", selected: properties["ai-text-level"] === "1" },
{ text: "2 — Co-drafting", value: "2", selected: properties["ai-text-level"] === "2" },
{ text: "3 — AI-generated (human reviewed)", value: "3", selected: properties["ai-text-level"] === "3" }
]
}) }}
{{ select({
name: "ai-code-level",
label: "AI code level",
optional: true,
items: [
{ text: "", value: "" },
{ text: "0 — Human-written", value: "0", selected: properties["ai-code-level"] === "0" },
{ text: "1 — AI-assisted", value: "1", selected: properties["ai-code-level"] === "1" },
{ text: "2 — Primarily AI-generated", value: "2", selected: properties["ai-code-level"] === "2" }
]
}) }}
{{ input({
name: "ai-tools",
value: properties["ai-tools"],
label: "AI tools",
hint: "e.g. Claude, ChatGPT, Copilot",
optional: true
}) }}
{{ input({
name: "ai-description",
value: properties["ai-description"],
label: "AI usage note",
optional: true
}) }}
These values flow through Indiekit’s standard pipeline untouched: form submission → sanitization → JF2 → Micropub → MongoDB → Eleventy frontmatter. No special handling needed — Indiekit passes through any properties it doesn’t recognize.
Empty values are cleaned up before submission so posts without AI metadata don’t store empty strings:
// Remove empty AI usage fields before converting to MF2
for (const key of ["ai-text-level", "ai-code-level", "ai-tools", "ai-description"]) {
if (!values[key]) {
delete values[key];
}
}
Step 2: The Visual Disclosure
When AI metadata is present on a post, a styled aside renders between the content and the comments section:
{% set aiTextLevel = aiTextLevel or ai_text_level %}
{% set aiCodeLevel = aiCodeLevel or ai_code_level %}
{% set aiTools = aiTools or ai_tools %}
{% set aiDescription = aiDescription or ai_description %}
{% if aiTextLevel or aiCodeLevel or aiTools %}
<aside class="ai-disclosure">
<strong>AI Usage</strong>
{% if aiTextLevel %}
<span>Text: Editorial / Co-drafted / AI-generated</span>
{% endif %}
{% if aiCodeLevel %}
<span>Code: Human / AI-assisted / AI-generated</span>
{% endif %}
{% if aiTools %}
<span>Tools: {{ aiTools }}</span>
{% endif %}
{% if aiDescription %}
<p>{{ aiDescription }}</p>
{% endif %}
</aside>
{% endif %}
The double property name resolution (aiTextLevel or ai_text_level) handles both camelCase and underscore naming — Eleventy’s data cascade can use either format depending on how the frontmatter was generated.
You can see this in action on Deploy Your Own IndieWeb Site on Cloudron with Indiekit — look for the “AI Usage” badge below the article content.
Step 3: Schema.org JSON-LD
The human-visible badge is useful, but search engines and validation tools need machine-readable data. Each post already had a JSON-LD <script> block with standard Schema.org Article properties (headline, author, datePublished, etc.). The AI metadata extends it with two additional properties.
Choosing the right Schema.org properties
After researching Schema.org’s vocabulary, two properties on CreativeWork (the parent type of Article) fit naturally:
usageInfo — a URL pointing to a page that describes usage guidelines or policies. This links to the AI Transparency page where the disclosure framework is defined. Any validator or crawler following this link gets the full context of what each level means.
creativeWorkStatus — a free-text string describing the status of a creative work. We use it for a human-readable summary of the AI disclosure: "Text: Editorial assistance, Code: AI-assisted, Tools: Claude".
Both are valid Schema.org properties — you can verify this with the Schema.org validator.
The implementation
The JSON-LD block conditionally appends the AI properties when at least one is set:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "...",
"url": "...",
"datePublished": "...",
"author": { "@type": "Person", "name": "..." },
"publisher": { "@type": "Organization", "name": "..." },
"description": "..."
{% if aiTextLevel or aiCodeLevel or aiTools %},
"usageInfo": "https://rmendes.net/ai",
"creativeWorkStatus": "Text: Editorial assistance, Code: AI-assisted, Tools: Claude"
{% endif %}
}
</script>
The creativeWorkStatus string is built dynamically from the metadata values. The Nunjucks template maps numeric levels to labels — "1" becomes "Editorial assistance", "0" becomes "Human-written" — and joins them into a comma-separated string:
{% set _aiParts = [] %}
{% if aiTextLevel %}
{% set _textLabel %}
{% if aiTextLevel === "0" %}None
{% elif aiTextLevel === "1" %}Editorial assistance
{% elif aiTextLevel === "2" %}Co-drafting
{% elif aiTextLevel === "3" %}AI-generated
{% endif %}
{% endset %}
{% set _aiParts = (_aiParts.push("Text: " + _textLabel), _aiParts) %}
{% endif %}
{% if aiCodeLevel %}
{% set _codeLabel %}
{% if aiCodeLevel === "0" %}Human-written
{% elif aiCodeLevel === "1" %}AI-assisted
{% elif aiCodeLevel === "2" %}AI-generated
{% endif %}
{% endset %}
{% set _aiParts = (_aiParts.push("Code: " + _codeLabel), _aiParts) %}
{% endif %}
{% if aiTools %}
{% set _aiParts = (_aiParts.push("Tools: " + aiTools), _aiParts) %}
{% endif %}
"creativeWorkStatus": "{{ _aiParts | join(', ') }}"
Live output
Here’s the actual JSON-LD output from Deploy Your Own IndieWeb Site on Cloudron with Indiekit, which has text level 1 (editorial assistance), code level 1 (AI-assisted), and tools “Claude”:
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Deploy Your Own IndieWeb Site on Cloudron with Indiekit",
"url": "https://rmendes.net/articles/2026/01/24/deploy-your-own-indieweb-site/",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://rmendes.net/articles/2026/01/24/deploy-your-own-indieweb-site/"
},
"datePublished": "2026-01-24T19:10:41.262Z",
"dateModified": "2026-01-24T19:10:41.262Z",
"author": {
"@type": "Person",
"name": "Ricardo Mendes",
"url": "https://rmendes.net/"
},
"publisher": {
"@type": "Organization",
"name": "Ricardo Mendes",
"url": "https://rmendes.net",
"logo": {
"@type": "ImageObject",
"url": "https://rmendes.net/images/og-default.png"
}
},
"description": "What You'll Build By the end of this tutorial, you'll have...",
"usageInfo": "https://rmendes.net/ai",
"creativeWorkStatus": "Text: Editorial assistance, Code: AI-assisted, Tools: Claude"
}
You can verify this yourself:
- View source on the article and search for
application/ld+json - Paste the URL into the Schema.org Validator to see the parsed structured data
- Test with Google’s Rich Results Test at search.google.com/test/rich-results
No conflict with ActivityPub
One concern was whether adding Schema.org JSON-LD would interfere with ActivityPub federation. It doesn’t — they operate on completely different layers:
- ActivityPub uses HTTP content negotiation. When a fediverse server requests a post with
Accept: application/activity+json, the ActivityPub plugin intercepts the request and returns an ActivityPub JSON document. The HTML page is never rendered. - JSON-LD in HTML lives inside a
<script type="application/ld+json">tag in the page markup. It’s only present when the page is served as HTML to browsers and search crawlers.
The two never interact.
Posts without AI metadata
When no AI properties are set on a post, nothing changes — no disclosure badge renders, and the JSON-LD contains only the standard Article properties. The entire feature is purely additive and conditional.
On the blog listing page, posts with AI usage (level > 0) show a compact badge like AI: T1/C1 next to the date, so readers can see at a glance which posts involved AI assistance.
Why bother?
There’s no established standard for AI disclosure in structured data yet. The C2PA coalition is working on content provenance at the file level, and IPTC has metadata fields for AI-generated media, but for blog posts there’s nothing definitive.
Using Schema.org’s existing usageInfo and creativeWorkStatus properties is a pragmatic approach: it uses a vocabulary that validators and crawlers already understand, links to a human-readable policy page, and includes a machine-parseable status string. If a formal standard emerges, migrating will be straightforward — the data model is simple and the implementation is contained in a single template file.
In the meantime, the disclosure is there for anyone — human or machine — who wants to know.
AI: Text Editorial · Code AI-assisted · Claude
Editorial assistance — article drafted by human, AI helped with structure and clarity

Comments
Sign in with your website to comment:
Loading comments...
No comments yet. Be the first to share your thoughts!