← Markup Mirror
API

Run Markup Mirror from your own code

The one metered task, markup, is available over HTTP. It takes a web page as the page has read it - the type you want (or auto), the page itself (its URL, title, H1, meta description, share image, image URLs and visible text), every JSON-LD block already on it (or none) and the facts the page found: the blocks and their typed nodes, the type it guesses, and one finding per line tagged [fix], [check] or [info] - and returns one JSON-LD object built from the page's own values, one entry per change (the node it touches, what changed and why), the properties the markup needs that the page does not state yet, a verdict (keep, fix or add) and a note.

The division of labour first: the page pulls every <script type="application/ld+json"> block out of the pasted HTML (plus JSON-LD pasted separately), parses each one with the line and column of any syntax error, walks every typed node - nested ones and @graph members included - and checks it against the source skill's required and recommended properties per type, against the value rules and against the page's visible text; the run writes the markup. The page reads up to 400,000 characters. After the reply it checks the verdict against its own rule (add when there is no JSON-LD, fix when any finding is at fix level, keep otherwise), runs the reply's JSON-LD through the same checker, and holds every value in it to the page: a name, price, rating, count, phone or address must be on the page (or kept unflagged from the existing markup), a date's year must be on the page, an image must be one of the page's images and a URL must be on the page's own host; a required property the reply leaves out passes only when not_on_page names it, and adding FAQPage or HowTo is refused. It warns when a sentence quotes a number the input does not have. The JSON-LD to paste is exported as a <script type="application/ld+json"> tag with < written as \u003c. Nothing is sent to the site - copy the tag and paste it into the page yourself. Your code can do the same with the engine files the page loads (see "Build the input yourself"). Markup Mirror is not affiliated with or endorsed by any search engine, and its result preview is an unbranded illustration.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"markup-mirror"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, want, page, existing, facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the markup run is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field, the input and the rules

Every run sends task: markup (the one task is markup); a missing or different one is still answered as markup. Every value is a string. What each key holds, in the words of the system prompt, with the first sample's value (its first line where it has several):

KeyRequiredWhat it holds
taskyesAlways markup. If it is missing or different, still answer this task and set "task": "markup".
wantyesThe type the person wants (Product, Article, BlogPosting, Organization, LocalBusiness, Event, SoftwareApplication, BreadcrumbList, WebSite, FAQPage), or auto. First sample: auto.
pageyesThe page as read: URL, title, H1, meta description, share image, image URLs, then the visible text (long text keeps its start and end, with a marker where the middle was left out). First sample: URL: https://shop.brindlewick.example.com/kettles/arc ...
existingyesEvery JSON-LD block already on the page, or none. A block that does not parse is given as written. First sample: Block 1 (in the page): ...
factsyesWhat the page found: the blocks and nodes, the type it guesses, then one finding per line tagged [fix], [check] or [info]. First sample: 1 JSON-LD block found (1 parse), with 3 typed nodes: product, Offer, AggregateRating. ...
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads a paste (MarkKit.form): up to 400,000 characters. HTML is read for its title, first H1, meta description (or og:description), og:image, canonical link, every image's src and the visible text (scripts, styles, templates, SVG and comments left out); every <script type="application/ld+json"> block is pulled out. JSON-LD pasted separately - a bare object or script tags - is added after the page's own blocks. A paste that starts with { or [ is read as bare JSON-LD, with no page text to hold it to; anything else is plain text, taken as the visible text. The URL is the one you give, or failing that the canonical link. Blocks (MarkKit.parse): each is parsed as JSON; one that does not parse is a bad_json finding with the line and column of the error (and a hint for a trailing comma, single quotes or a comment), and a raw </script inside a block is a script_break. Nodes (MarkKit.walk): every object with an @type, depth first - nested values and @graph members included - with a path such as Product.offers. A type takes its own rules or those of the type it belongs to (below); a wrongly cased type such as product is still held to Product's list, and flagged.

The source skill's properties per type

From the source skill's table of required properties and its quick reference (MarkKit.RULES). A missing required property is a missing_required finding at fix; missing recommended ones are one missing_recommended finding at check.

TypeRequired (fix)Recommended (check)Also held to these rules
Organizationname, urllogo, sameAs, contactPointCorporation, NGO, EducationalOrganization
WebSitename, url--
Articleheadline, image, datePublished, authordateModified, publisher, descriptionBlogPosting, NewsArticle, TechArticle, ScholarlyArticle, Report
Productname, image, offerssku, brand, aggregateRating, review-
Offerprice, availabilitypriceCurrency-
SoftwareApplicationname, offersapplicationCategory, operatingSystem, aggregateRatingWebApplication, MobileApplication, VideoGame
FAQPagemainEntity--
Questionname, acceptedAnswer--
Answertext--
HowToname, step--
HowToSteptext--
BreadcrumbListitemListElement--
ListItemposition, nameitem-
LocalBusinessname, addresstelephone, openingHoursSpecification, url, imageRestaurant, Store, Dentist, Plumber, Electrician, HairSalon, CafeOrCoffeeShop, Bakery, BarOrPub, Hotel, AutoRepair, ProfessionalService, LegalService, MedicalBusiness, HomeAndConstructionBusiness, HealthAndBeautyBusiness, FoodEstablishment
Eventname, startDate, locationendDate, description, image, offers, eventStatus, eventAttendanceMode, organizerMusicEvent, BusinessEvent, EducationEvent, SportsEvent, TheaterEvent, ComedyEvent, Festival, SocialEvent, FoodEvent
PostalAddress-streetAddress, addressLocality, postalCode, addressCountry-
AggregateRatingratingValuereviewCount, ratingCount-

Value rules (MarkKit.checkNode), each a fix unless said: a top-level node with no @context (no_context) or one that is not schema.org (bad_context); a node with no @type (no_type); a type schema.org spells differently (unknown_type: a fix when only the case is wrong, a check otherwise); a date that is not ISO 8601 in datePublished, dateModified, dateCreated, startDate, endDate, uploadDate, datePosted, validFrom, validThrough, priceValidUntil or foundingDate (bad_date); a value that is not a full https:// URL in url, image, logo, item, sameAs, thumbnailUrl, contentUrl, embedUrl or mainEntityOfPage (relative_url); a price that is not a plain number (bad_price); a priceCurrency that is not a 3-letter upper-case ISO 4217 code (bad_currency); a schema.org enumeration outside its list (bad_enum) - availability (InStock, OutOfStock, PreOrder, PreSale, BackOrder, Discontinued, InStoreOnly, OnlineOnly, LimitedAvailability, SoldOut, MadeToOrder, Reserved); eventStatus (EventScheduled, EventCancelled, EventPostponed, EventRescheduled, EventMovedOnline); eventAttendanceMode (OfflineEventAttendanceMode, OnlineEventAttendanceMode, MixedEventAttendanceMode); itemCondition (NewCondition, UsedCondition, RefurbishedCondition, DamagedCondition), written bare or as https://schema.org/...; a reviewCount or ratingCount that is not a whole number (bad_count); a ratingValue outside worstRating to bestRating (1 to 5 when not given; bad_rating); breadcrumb positions that do not count 1, 2, 3 (bad_positions) and a crumb other than the last with no item URL (missing_item, a check); an empty string (empty, a check); two top-level nodes of one type (duplicate, a check).

Against the page: name, headline, price, ratingValue, reviewCount, ratingCount, telephone, sku, streetAddress, addressLocality, postalCode, text and alternativeHeadline are held to the page's visible text plus its title and meta description (MarkKit.onPage) - phone numbers by their digits, prices, ratings and counts by value (so 89.00 matches $89.00 and 1204 matches 1,204), other text by its first 60 characters, ignoring case and punctuation. A value the page never states is a not_on_page finding at fix. Retired features (MarkKit.RETIRED, at info, as the source skill dates them): FAQPage: Google stopped showing FAQ rich results on May 7, 2026 (per the source skill, citing Google's documentation updates); the markup still describes the page, but earns no rich result. HowTo: Google retired HowTo rich results in September 2023 (per the source skill); the markup still describes the page, but earns no rich result. SearchAction: Google retired the sitelinks search box in November 2024 (per the source skill); WebSite still supports the site name.

The verdict the page expects (MarkKit.expectVerdict(form)): add when there is no JSON-LD, fix when any finding is at fix, keep otherwise. MarkKit.factLines writes the blocks and nodes, the type the page guesses and one line per finding into facts; page keeps the first 6,000 characters of a long visible text by its start and end with a marker where the middle was left out, and existing likewise at 9,000. The page asks for at least 40 characters of visible text (or, for bare JSON-LD, a block that parses) before it builds an input.

The model's rules

As the system prompt states them, in order:

  1. verdict - add when existing is none; otherwise fix when facts has any [fix] line; otherwise keep.
  2. jsonld - one JSON object with "@context": "https://schema.org": a single node, or an @graph of nodes.
    • For keep, repeat the existing markup; you may add a recommended property only when the page states its value.
    • For fix, the existing markup with every [fix] finding corrected. A value that does not appear on the page is replaced by the page's value, or removed when the page has none.
    • For add, markup for want - or, when want is auto, for the type the page guesses - plus a BreadcrumbList only when the page shows a breadcrumb trail.
    • Every value comes from page: copy names, headlines, prices, ratings, counts, phone numbers and addresses exactly as the page states them. Write dates in ISO 8601, prices as plain numbers, priceCurrency only when the page shows a currency symbol or code, and enumerations as full URLs such as https://schema.org/InStock.
    • Build every URL from the page's URL; use an image only from the page's image URLs or share image.
    • Never invent a value. When a required property's value is not on the page, leave the property out and name it in not_on_page.
    • Do not add FAQPage or HowTo to win a rich result; if the existing markup has them and the page shows the content, keep them.
  3. changes - one entry per change, at most 12: node (the type it touches), change (at most 20 words) and why (one sentence, at most 20 words). [] when nothing changed.
  4. not_on_page - at most 6 short strings, each a property the markup needs and the page does not state, with what to add to the page (for example, a product photo). [] when nothing is missing.
  5. note - one sentence, at most 30 words: the verdict in plain words and what to do next.

And the house rules:

The reply

One JSON object, in the shape the system prompt asks for:

{
  "task": "markup",
  "verdict": "keep | fix | add",
  "jsonld": { "@context": "https://schema.org", "@type": "..." },
  "changes": [ { "node": "Product", "change": "what changed", "why": "one sentence" } ],
  "not_on_page": [ "a missing property and what to add to the page" ],
  "note": "one sentence"
}

The page reads the reply with ReplyKit.normalize: the verdict matched in upper or lower letters (anything other than keep, fix or add reads as unknown), jsonld parsed when it arrives as a string, every changes entry trimmed to node, change and why (one with no change is dropped) and not_on_page kept as short strings. The findings always come from the page's own reading, never from the reply.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that is not the page's own rule (MarkKit.expectVerdict); no JSON-LD object, or one with no typed node; every fix-level finding the page's checker makes on the reply's own JSON-LD (ReplyKit.audit(jsonld, form) runs it through MarkKit.checkBlock), except that a missing required property is only noted when not_on_page names it; every value the reply makes up - held leaf by leaf, skipping @context, @type, @id, priceCurrency, inLanguage, position, bestRating, worstRating, addressCountry and addressRegion, valid enumeration URLs and values kept unflagged from the existing markup: an image that is not one of the page's images or its share image, a URL on a host the page never uses, a date whose year the page never states, and any other value not in the page's visible text; a FAQPage, HowTo or sitelinks search box the existing markup did not have; on a keep, a top-level type the existing markup had and the reply drops; more than 12 changes (a note); and in every sentence (the note, each change and why) a number that is not in the input, backticks or retry_note. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Keep it" (keep), "Fix it" (fix), "Add it" (add).

What the page builds from it: MarkView.tree(blocks) draws every typed node as a card with each property's value and status and ghost rows for the required and recommended properties it lacks, and MarkView.preview(form, jsonld) an unbranded illustration of a search result built from the markup - search engines decide what they show. MarkKit.scriptTag(jsonld) writes the tag to paste, with < written as \u003c so no value can close the tag early. ReplyKit.md and ReplyKit.json write the page's findings, the JSON-LD as that tag, what changed and what the page does not state yet; MarkKit.findingsCSV(form.findings) writes one row per finding - level, code, node, property and the finding - with CRLF line ends.

Worked examples - every sample

These are the free samples on the page: the page as pasted, its URL and the type wanted, how the page reads it - each JSON-LD block, every typed node and every finding with its level - the exact input it sends, the reply that replays for free, and what the page does with it - the reply's JSON-LD as the tag to paste, the verdict against the page's own rule, and its check. Together they cover the verdicts fix, add and keep.

A product page with broken markup

The URL: https://shop.brindlewick.example.com/kettles/arc; the type wanted: auto.

The page as pasted:

<!doctype html>
<html lang="en"><head>
<title>Arc Electric Kettle 1.7 L - Brindlewick Home</title>
<meta name="description" content="A 1.7 litre stainless steel kettle with five temperature presets and a keep-warm mode.">
<meta property="og:image" content="https://shop.brindlewick.example.com/img/arc-kettle-1200.jpg">
<link rel="canonical" href="https://shop.brindlewick.example.com/kettles/arc">
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "product",
  "name": "Arc Electric Kettle",
  "image": "/img/arc-kettle-1200.jpg",
  "offers": { "@type": "Offer", "price": "$89.00", "priceCurrency": "usd", "availability": "In stock" },
  "aggregateRating": { "@type": "AggregateRating", "ratingValue": "4.8", "reviewCount": "1,204" }
}
</script>
</head><body>
<nav>Home / Kitchen / Kettles</nav>
<h1>Arc Electric Kettle 1.7 L</h1>
<p class="price">$89.00</p>
<p>In stock - ships in 2 days</p>
<p>Rated 4.6 out of 5 from 1,204 reviews</p>
<p>Brand: Brindlewick. SKU: HK-ARC-17.</p>
<p>A 1.7 litre stainless steel kettle with five temperature presets and a keep-warm mode.</p>
<img src="https://shop.brindlewick.example.com/img/arc-kettle-1200.jpg" alt="Arc kettle">
<button>Add to cart</button>
</body></html>

The page reads it as HTML with 1 JSON-LD block (in the page, parses), 3 typed nodes. It guesses Product. 8 findings, 7 of them at fix.

The nodes (MarkKit.walk):

TypePathHeld to the rules ofTop level
producttop levelProductyes
Offerproduct.offersOfferno
AggregateRatingproduct.aggregateRatingAggregateRatingno

The findings (MarkKit.checkNode), as sent in facts:

Input:

{
 "task": "markup",
 "want": "auto",
 "page": "URL: https://shop.brindlewick.example.com/kettles/arc\nTitle: Arc Electric Kettle 1.7 L - Brindlewick Home\nH1: Arc Electric Kettle 1.7 L\nMeta description: A 1.7 litre stainless steel kettle with five temperature presets and a keep-warm mode.\nShare image: https://shop.brindlewick.example.com/img/arc-kettle-1200.jpg\nImages on the page: https://shop.brindlewick.example.com/img/arc-kettle-1200.jpg\nVisible text:\nArc Electric Kettle 1.7 L - Brindlewick Home \n Home / Kitchen / Kettles \n Arc Electric Kettle 1.7 L\n $89.00\n In stock - ships in 2 days\n Rated 4.6 out of 5 from 1,204 reviews\n Brand: Brindlewick. SKU: HK-ARC-17.\n A 1.7 litre stainless steel kettle with five temperature presets and a keep-warm mode.\n Add to cart",
 "existing": "Block 1 (in the page):\n{\n \"@context\": \"https://schema.org\",\n \"@type\": \"product\",\n \"name\": \"Arc Electric Kettle\",\n \"image\": \"/img/arc-kettle-1200.jpg\",\n \"offers\": {\n  \"@type\": \"Offer\",\n  \"price\": \"$89.00\",\n  \"priceCurrency\": \"usd\",\n  \"availability\": \"In stock\"\n },\n \"aggregateRating\": {\n  \"@type\": \"AggregateRating\",\n  \"ratingValue\": \"4.8\",\n  \"reviewCount\": \"1,204\"\n }\n}",
 "facts": "1 JSON-LD block found (1 parse), with 3 typed nodes: product, Offer, AggregateRating.\nPage type the page guesses from the text and markup: Product.\n[fix] \"product\" is spelled \"Product\" in schema.org - types are case-sensitive.\n[check] product could add sku, brand, review (recommended by the source skill).\n[fix] product image \"/img/arc-kettle-1200.jpg\" is not a full URL (https://...).\n[fix] Offer (product.offers) price \"$89.00\" should be a plain number (digits and a decimal point) - put the currency in priceCurrency.\n[fix] Offer (product.offers) priceCurrency \"usd\" should be a 3-letter upper-case ISO 4217 code.\n[fix] Offer (product.offers) availability \"In stock\" is not a schema.org value; use one of https://schema.org/InStock, https://schema.org/OutOfStock, https://schema.org/PreOrder, https://schema.org/PreSale...\n[fix] AggregateRating (product.aggregateRating) ratingValue \"4.8\" does not appear in the page's visible text - markup must match what the page shows.\n[fix] AggregateRating (product.aggregateRating) reviewCount \"1,204\" should be a whole number."
}

Reply:

{
 "task": "markup",
 "verdict": "fix",
 "jsonld": {
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Arc Electric Kettle",
  "image": "https://shop.brindlewick.example.com/img/arc-kettle-1200.jpg",
  "offers": {
   "@type": "Offer",
   "price": 89,
   "priceCurrency": "USD",
   "availability": "https://schema.org/InStock"
  },
  "aggregateRating": {
   "@type": "AggregateRating",
   "ratingValue": 4.6,
   "reviewCount": 1204
  }
 },
 "changes": [
  {
   "node": "Product",
   "change": "Changed the type product to Product since schema.org types are case sensitive.",
   "why": "schema.org type names are case sensitive so search engines can recognize the markup."
  },
  {
   "node": "Product",
   "change": "Changed the image path to the full web address shown in the image list.",
   "why": "Structured data images must be complete URLs so search engines can load them."
  },
  {
   "node": "Offer",
   "change": "Changed the price from a dollar text string to a plain number.",
   "why": "Prices must be written as plain numbers so search engines can parse them correctly."
  },
  {
   "node": "Offer",
   "change": "Changed the currency code usd to the upper-case ISO code USD.",
   "why": "Currency codes must be three upper-case letters such as USD, not lower-case text."
  },
  {
   "node": "Offer",
   "change": "Changed availability from the text In stock to the schema.org address for InStock.",
   "why": "Availability must use a schema.org address such as InStock, not plain descriptive text."
  },
  {
   "node": "AggregateRating",
   "change": "Changed the rating value from 4.8 to 4.6 to match what is shown.",
   "why": "The rating of 4.6 out of 5 appears in the visible text, not 4.8."
  },
  {
   "node": "AggregateRating",
   "change": "Changed the review count 1,204 from formatted text into a whole number.",
   "why": "Review counts must be written as whole numbers without comma punctuation, matching 1,204 reviews."
  }
 ],
 "not_on_page": [],
 "note": "The listing needed fixes to its structured data - type capitalization, image address, price format, currency code, availability, rating, and review count - apply this corrected markup."
}

The reply's JSON-LD as the tag to paste (MarkKit.scriptTag):

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Arc Electric Kettle",
  "image": "https://shop.brindlewick.example.com/img/arc-kettle-1200.jpg",
  "offers": {
    "@type": "Offer",
    "price": 89,
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": 4.6,
    "reviewCount": 1204
  }
}
</script>

What changed:

  1. Product - Changed the type product to Product since schema.org types are case sensitive. schema.org type names are case sensitive so search engines can recognize the markup.
  2. Product - Changed the image path to the full web address shown in the image list. Structured data images must be complete URLs so search engines can load them.
  3. Offer - Changed the price from a dollar text string to a plain number. Prices must be written as plain numbers so search engines can parse them correctly.
  4. Offer - Changed the currency code usd to the upper-case ISO code USD. Currency codes must be three upper-case letters such as USD, not lower-case text.
  5. Offer - Changed availability from the text In stock to the schema.org address for InStock. Availability must use a schema.org address such as InStock, not plain descriptive text.
  6. AggregateRating - Changed the rating value from 4.8 to 4.6 to match what is shown. The rating of 4.6 out of 5 appears in the visible text, not 4.8.
  7. AggregateRating - Changed the review count 1,204 from formatted text into a whole number. Review counts must be written as whole numbers without comma punctuation, matching 1,204 reviews.

The page's checker on the reply's JSON-LD (ReplyKit.audit):

The verdict: fix - "Fix it"; it agrees with the page's own rule (MarkKit.expectVerdict gives fix).

The note: "The listing needed fixes to its structured data - type capitalization, image address, price format, currency code, availability, rating, and review count - apply this corrected markup."

The page's check (ReplyKit.check): kept.

No notes.

A blog post with none

The URL: https://quorvel.example.com/blog/onboarding-emails; the type wanted: BlogPosting.

The page as pasted:

<!doctype html>
<html lang="en"><head>
<title>How we cut our onboarding email from 9 steps to 3 - Quorvel Blog</title>
<meta name="description" content="What happened when we deleted six of our nine onboarding emails, and what replaced them.">
<meta property="og:image" content="https://quorvel.example.com/blog/img/onboarding-cover.png">
</head><body>
<header><a href="/">Quorvel</a></header>
<article>
<h1>How we cut our onboarding email from 9 steps to 3</h1>
<p>By Ilse Marrowby - Published 14 October 2026 - Updated 21 October 2026 - 6 min read</p>
<img src="https://quorvel.example.com/blog/img/onboarding-cover.png" alt="Three envelopes">
<p>For two years every new Quorvel account got nine onboarding emails in its first fortnight. Most were never opened.</p>
<p>In September we deleted six of them and rewrote the other three around the one thing a new team does first: invite a teammate.</p>
<p>Activation, measured as a second person joining in week one, went from 31% to 38%. Unsubscribes halved.</p>
</article>
<footer>Quorvel, 2026</footer>
</body></html>

The page reads it as HTML with no JSON-LD. It guesses Article. 1 finding, 0 of them at fix.

The findings (MarkKit.checkNode), as sent in facts:

Input:

{
 "task": "markup",
 "want": "BlogPosting",
 "page": "URL: https://quorvel.example.com/blog/onboarding-emails\nTitle: How we cut our onboarding email from 9 steps to 3 - Quorvel Blog\nH1: How we cut our onboarding email from 9 steps to 3\nMeta description: What happened when we deleted six of our nine onboarding emails, and what replaced them.\nShare image: https://quorvel.example.com/blog/img/onboarding-cover.png\nImages on the page: https://quorvel.example.com/blog/img/onboarding-cover.png\nVisible text:\nHow we cut our onboarding email from 9 steps to 3 - Quorvel Blog \n Quorvel \n How we cut our onboarding email from 9 steps to 3\n By Ilse Marrowby - Published 14 October 2026 - Updated 21 October 2026 - 6 min read\n For two years every new Quorvel account got nine onboarding emails in its first fortnight. Most were never opened.\n In September we deleted six of them and rewrote the other three around the one thing a new team does first: invite a teammate.\n Activation, measured as a second person joining in week one, went from 31% to 38%. Unsubscribes halved.\n Quorvel, 2026",
 "existing": "none",
 "facts": "The page has no JSON-LD.\nPage type the page guesses from the text and markup: Article.\n[info] The page has no JSON-LD."
}

Reply:

{
 "task": "markup",
 "verdict": "add",
 "jsonld": {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "How we cut our onboarding email from 9 steps to 3",
  "image": "https://quorvel.example.com/blog/img/onboarding-cover.png",
  "datePublished": "2026-10-14",
  "dateModified": "2026-10-21",
  "author": {
   "@type": "Person",
   "name": "Ilse Marrowby"
  },
  "description": "What happened when we deleted six of our nine onboarding emails, and what replaced them.",
  "url": "https://quorvel.example.com/blog/onboarding-emails"
 },
 "changes": [
  {
   "node": "BlogPosting",
   "change": "Added BlogPosting markup with headline, image, author, and publish and update dates copied from the article.",
   "why": "No structured data existed, so search engines could not identify the article's author or publish date."
  }
 ],
 "not_on_page": [],
 "note": "No markup existed, so BlogPosting schema was added using the headline, image, author, and dates already on the article."
}

The reply's JSON-LD as the tag to paste (MarkKit.scriptTag):

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "How we cut our onboarding email from 9 steps to 3",
  "image": "https://quorvel.example.com/blog/img/onboarding-cover.png",
  "datePublished": "2026-10-14",
  "dateModified": "2026-10-21",
  "author": {
    "@type": "Person",
    "name": "Ilse Marrowby"
  },
  "description": "What happened when we deleted six of our nine onboarding emails, and what replaced them.",
  "url": "https://quorvel.example.com/blog/onboarding-emails"
}
</script>

What changed:

  1. BlogPosting - Added BlogPosting markup with headline, image, author, and publish and update dates copied from the article. No structured data existed, so search engines could not identify the article's author or publish date.

The page's checker on the reply's JSON-LD (ReplyKit.audit):

The verdict: add - "Add it"; it agrees with the page's own rule (MarkKit.expectVerdict gives add).

The note: "No markup existed, so BlogPosting schema was added using the headline, image, author, and dates already on the article."

The page's check (ReplyKit.check): kept.

No notes.

An event page done right

The URL: https://wrenfold-market.example.org/events/night-market-november; the type wanted: auto.

The page as pasted:

<!doctype html>
<html lang="en"><head>
<title>Wrenfold Night Market - 7 November 2026 - Wrenfold Quay</title>
<link rel="canonical" href="https://wrenfold-market.example.org/events/night-market-november">
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Event",
  "name": "Wrenfold Night Market",
  "startDate": "2026-11-07T17:00:00+00:00",
  "endDate": "2026-11-07T22:00:00+00:00",
  "eventStatus": "https://schema.org/EventScheduled",
  "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
  "location": { "@type": "Place", "name": "Wrenfold Quay", "address": { "@type": "PostalAddress", "streetAddress": "1 Wrenfold Quay", "addressLocality": "Bristol", "postalCode": "BS1 5DB", "addressCountry": "GB" } },
  "image": "https://wrenfold-market.example.org/img/night-market.jpg",
  "description": "Forty street-food stalls, live music and craft stands on the harbourside.",
  "organizer": { "@type": "Organization", "name": "Wrenfold Markets CIC", "url": "https://wrenfold-market.example.org" }
}
</script>
</head><body>
<h1>Wrenfold Night Market</h1>
<p>Saturday 7 November 2026, 5pm to 10pm</p>
<p>Wrenfold Quay, 1 Wrenfold Quay, Bristol BS1 5DB</p>
<p>Forty street-food stalls, live music and craft stands on the harbourside. Free entry.</p>
<img src="https://wrenfold-market.example.org/img/night-market.jpg" alt="Stalls at night">
<p>Run by Wrenfold Markets CIC.</p>
</body></html>

The page reads it as HTML with 1 JSON-LD block (in the page, parses), 4 typed nodes. It guesses Event. 2 findings, 0 of them at fix.

The nodes (MarkKit.walk):

TypePathHeld to the rules ofTop level
Eventtop levelEventyes
PlaceEvent.location-no
PostalAddressEvent.location > Place.addressPostalAddressno
OrganizationEvent.organizerOrganizationno

The findings (MarkKit.checkNode), as sent in facts:

Input:

{
 "task": "markup",
 "want": "auto",
 "page": "URL: https://wrenfold-market.example.org/events/night-market-november\nTitle: Wrenfold Night Market - 7 November 2026 - Wrenfold Quay\nH1: Wrenfold Night Market\nImages on the page: https://wrenfold-market.example.org/img/night-market.jpg\nVisible text:\nWrenfold Night Market - 7 November 2026 - Wrenfold Quay \n Wrenfold Night Market\n Saturday 7 November 2026, 5pm to 10pm\n Wrenfold Quay, 1 Wrenfold Quay, Bristol BS1 5DB\n Forty street-food stalls, live music and craft stands on the harbourside. Free entry.\n Run by Wrenfold Markets CIC.",
 "existing": "Block 1 (in the page):\n{\n \"@context\": \"https://schema.org\",\n \"@type\": \"Event\",\n \"name\": \"Wrenfold Night Market\",\n \"startDate\": \"2026-11-07T17:00:00+00:00\",\n \"endDate\": \"2026-11-07T22:00:00+00:00\",\n \"eventStatus\": \"https://schema.org/EventScheduled\",\n \"eventAttendanceMode\": \"https://schema.org/OfflineEventAttendanceMode\",\n \"location\": {\n  \"@type\": \"Place\",\n  \"name\": \"Wrenfold Quay\",\n  \"address\": {\n   \"@type\": \"PostalAddress\",\n   \"streetAddress\": \"1 Wrenfold Quay\",\n   \"addressLocality\": \"Bristol\",\n   \"postalCode\": \"BS1 5DB\",\n   \"addressCountry\": \"GB\"\n  }\n },\n \"image\": \"https://wrenfold-market.example.org/img/night-market.jpg\",\n \"description\": \"Forty street-food stalls, live music and craft stands on the harbourside.\",\n \"organizer\": {\n  \"@type\": \"Organization\",\n  \"name\": \"Wrenfold Markets CIC\",\n  \"url\": \"https://wrenfold-market.example.org\"\n }\n}",
 "facts": "1 JSON-LD block found (1 parse), with 4 typed nodes: Event, Place, PostalAddress, Organization.\nPage type the page guesses from the text and markup: Event.\n[check] Event could add offers (recommended by the source skill).\n[check] Organization (Event.organizer) could add logo, sameAs, contactPoint (recommended by the source skill)."
}

Reply:

{
 "task": "markup",
 "verdict": "keep",
 "jsonld": {
  "@context": "https://schema.org",
  "@type": "Event",
  "name": "Wrenfold Night Market",
  "startDate": "2026-11-07T17:00:00+00:00",
  "endDate": "2026-11-07T22:00:00+00:00",
  "eventStatus": "https://schema.org/EventScheduled",
  "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
  "location": {
   "@type": "Place",
   "name": "Wrenfold Quay",
   "address": {
    "@type": "PostalAddress",
    "streetAddress": "1 Wrenfold Quay",
    "addressLocality": "Bristol",
    "postalCode": "BS1 5DB",
    "addressCountry": "GB"
   }
  },
  "image": "https://wrenfold-market.example.org/img/night-market.jpg",
  "description": "Forty street-food stalls, live music and craft stands on the harbourside.",
  "organizer": {
   "@type": "Organization",
   "name": "Wrenfold Markets CIC",
   "url": "https://wrenfold-market.example.org"
  }
 },
 "changes": [],
 "not_on_page": [],
 "note": "This event's markup already matches what is shown on the website, so it can stay as it is; no edits are needed right now."
}

The reply's JSON-LD as the tag to paste (MarkKit.scriptTag):

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Event",
  "name": "Wrenfold Night Market",
  "startDate": "2026-11-07T17:00:00+00:00",
  "endDate": "2026-11-07T22:00:00+00:00",
  "eventStatus": "https://schema.org/EventScheduled",
  "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
  "location": {
    "@type": "Place",
    "name": "Wrenfold Quay",
    "address": {
      "@type": "PostalAddress",
      "streetAddress": "1 Wrenfold Quay",
      "addressLocality": "Bristol",
      "postalCode": "BS1 5DB",
      "addressCountry": "GB"
    }
  },
  "image": "https://wrenfold-market.example.org/img/night-market.jpg",
  "description": "Forty street-food stalls, live music and craft stands on the harbourside.",
  "organizer": {
    "@type": "Organization",
    "name": "Wrenfold Markets CIC",
    "url": "https://wrenfold-market.example.org"
  }
}
</script>

What changed: nothing.

The page's checker on the reply's JSON-LD (ReplyKit.audit):

The verdict: keep - "Keep it"; it agrees with the page's own rule (MarkKit.expectVerdict gives keep).

The note: "This event's markup already matches what is shown on the website, so it can stay as it is; no edits are needed right now."

The page's check (ReplyKit.check): kept.

No notes.

A cafe claiming a rating

The URL: https://pellmoss-coffee.example.com/; the type wanted: auto.

The page as pasted:

<!doctype html>
<html lang="en"><head>
<title>Pellmoss Coffee - Specialty coffee in Portland, Maine</title>
<meta name="description" content="Single-origin espresso and pour-over on Quillon Street, Portland. Open daily from 7am.">
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "CafeOrCoffeeShop",
  "name": "Pellmoss Coffee",
  "url": "https://pellmoss-coffee.example.com",
  "telephone": "+1 207 555 0182",
  "address": { "@type": "PostalAddress", "streetAddress": "212 Quillon Street", "addressLocality": "Portland", "addressRegion": "ME", "postalCode": "04101", "addressCountry": "US" },
  "openingHours": "Mo-Su 07:00-17:00",
  "aggregateRating": { "@type": "AggregateRating", "ratingValue": "4.9", "reviewCount": "386" }
}
</script>
</head><body>
<h1>Pellmoss Coffee</h1>
<p>Single-origin espresso and pour-over on Quillon Street, Portland. Open daily from 7am to 5pm.</p>
<p>212 Quillon Street, Portland, ME 04101</p>
<p>Call us: (207) 555-0148</p>
<img src="https://pellmoss-coffee.example.com/img/bar.jpg" alt="The espresso bar">
</body></html>

The page reads it as HTML with 1 JSON-LD block (in the page, parses), 3 typed nodes. It guesses LocalBusiness. 4 findings, 3 of them at fix.

The nodes (MarkKit.walk):

TypePathHeld to the rules ofTop level
CafeOrCoffeeShoptop levelLocalBusinessyes
PostalAddressCafeOrCoffeeShop.addressPostalAddressno
AggregateRatingCafeOrCoffeeShop.aggregateRatingAggregateRatingno

The findings (MarkKit.checkNode), as sent in facts:

Input:

{
 "task": "markup",
 "want": "auto",
 "page": "URL: https://pellmoss-coffee.example.com/\nTitle: Pellmoss Coffee - Specialty coffee in Portland, Maine\nH1: Pellmoss Coffee\nMeta description: Single-origin espresso and pour-over on Quillon Street, Portland. Open daily from 7am.\nImages on the page: https://pellmoss-coffee.example.com/img/bar.jpg\nVisible text:\nPellmoss Coffee - Specialty coffee in Portland, Maine \n Pellmoss Coffee\n Single-origin espresso and pour-over on Quillon Street, Portland. Open daily from 7am to 5pm.\n 212 Quillon Street, Portland, ME 04101\n Call us: (207) 555-0148",
 "existing": "Block 1 (in the page):\n{\n \"@context\": \"https://schema.org\",\n \"@type\": \"CafeOrCoffeeShop\",\n \"name\": \"Pellmoss Coffee\",\n \"url\": \"https://pellmoss-coffee.example.com\",\n \"telephone\": \"+1 207 555 0182\",\n \"address\": {\n  \"@type\": \"PostalAddress\",\n  \"streetAddress\": \"212 Quillon Street\",\n  \"addressLocality\": \"Portland\",\n  \"addressRegion\": \"ME\",\n  \"postalCode\": \"04101\",\n  \"addressCountry\": \"US\"\n },\n \"openingHours\": \"Mo-Su 07:00-17:00\",\n \"aggregateRating\": {\n  \"@type\": \"AggregateRating\",\n  \"ratingValue\": \"4.9\",\n  \"reviewCount\": \"386\"\n }\n}",
 "facts": "1 JSON-LD block found (1 parse), with 3 typed nodes: CafeOrCoffeeShop, PostalAddress, AggregateRating.\nPage type the page guesses from the text and markup: LocalBusiness.\n[check] CafeOrCoffeeShop could add openingHoursSpecification, image (recommended by the source skill).\n[fix] CafeOrCoffeeShop telephone \"+1 207 555 0182\" does not appear in the page's visible text - markup must match what the page shows.\n[fix] AggregateRating (CafeOrCoffeeShop.aggregateRating) ratingValue \"4.9\" does not appear in the page's visible text - markup must match what the page shows.\n[fix] AggregateRating (CafeOrCoffeeShop.aggregateRating) reviewCount \"386\" does not appear in the page's visible text - markup must match what the page shows."
}

Reply:

{
 "task": "markup",
 "verdict": "fix",
 "jsonld": {
  "@context": "https://schema.org",
  "@type": "CafeOrCoffeeShop",
  "name": "Pellmoss Coffee",
  "url": "https://pellmoss-coffee.example.com",
  "telephone": "(207) 555-0148",
  "address": {
   "@type": "PostalAddress",
   "streetAddress": "212 Quillon Street",
   "addressLocality": "Portland",
   "addressRegion": "ME",
   "postalCode": "04101",
   "addressCountry": "US"
  },
  "openingHours": "Mo-Su 07:00-17:00"
 },
 "changes": [
  {
   "node": "CafeOrCoffeeShop",
   "change": "Replaced telephone +1 207 555 0182 with the page's number (207) 555-0148.",
   "why": "The old phone number never appears in the page's visible text, only a different one does."
  },
  {
   "node": "AggregateRating",
   "change": "Removed the aggregateRating block (rating 4.9, 386 reviews) since neither value is on the page.",
   "why": "Neither the rating value nor the review count appears anywhere in the page's visible text."
  }
 ],
 "not_on_page": [],
 "note": "This page already had structured data, but two values conflicted with the text, so the phone number was corrected and the unverifiable rating was removed."
}

The reply's JSON-LD as the tag to paste (MarkKit.scriptTag):

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "CafeOrCoffeeShop",
  "name": "Pellmoss Coffee",
  "url": "https://pellmoss-coffee.example.com",
  "telephone": "(207) 555-0148",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "212 Quillon Street",
    "addressLocality": "Portland",
    "addressRegion": "ME",
    "postalCode": "04101",
    "addressCountry": "US"
  },
  "openingHours": "Mo-Su 07:00-17:00"
}
</script>

What changed:

  1. CafeOrCoffeeShop - Replaced telephone +1 207 555 0182 with the page's number (207) 555-0148. The old phone number never appears in the page's visible text, only a different one does.
  2. AggregateRating - Removed the aggregateRating block (rating 4.9, 386 reviews) since neither value is on the page. Neither the rating value nor the review count appears anywhere in the page's visible text.

The page's checker on the reply's JSON-LD (ReplyKit.audit):

The verdict: fix - "Fix it"; it agrees with the page's own rule (MarkKit.expectVerdict gives fix).

The note: "This page already had structured data, but two values conflicted with the text, so the phone number was corrected and the unverifiable rating was removed."

The page's check (ReplyKit.check): kept.

No notes.

The findings CSV for the first sample

What MarkKit.findingsCSV(form.findings) writes for "A product page with broken markup" above: one row per finding, in the order the page found them, with its level, code, node, property and text (lines end in CRLF).

level,code,node,property,finding
fix,unknown_type,product,@type,"""product"" is spelled ""Product"" in schema.org - types are case-sensitive."
check,missing_recommended,product,"sku, brand, review","product could add sku, brand, review (recommended by the source skill)."
fix,relative_url,product,image,"product image ""/img/arc-kettle-1200.jpg"" is not a full URL (https://...)."
fix,bad_price,Offer (product.offers),price,"Offer (product.offers) price ""$89.00"" should be a plain number (digits and a decimal point) - put the currency in priceCurrency."
fix,bad_currency,Offer (product.offers),priceCurrency,"Offer (product.offers) priceCurrency ""usd"" should be a 3-letter upper-case ISO 4217 code."
fix,bad_enum,Offer (product.offers),availability,"Offer (product.offers) availability ""In stock"" is not a schema.org value; use one of https://schema.org/InStock, https://schema.org/OutOfStock, https://schema.org/PreOrder, https://schema.org/PreSale..."
fix,not_on_page,AggregateRating (product.aggregateRating),ratingValue,"AggregateRating (product.aggregateRating) ratingValue ""4.8"" does not appear in the page's visible text - markup must match what the page shows."
fix,bad_count,AggregateRating (product.aggregateRating),reviewCount,"AggregateRating (product.aggregateRating) reviewCount ""1,204"" should be a whole number."

Build the input yourself

Load the engine in node (global.window = global; require("./markkit.js"); require("./replykit.js")), then:

const K = MarkKit, R = ReplyKit;
const form = K.form({ page: "<html><head><title>Fern Desk Lamp - Lumen and Oak</title>\n<meta name=\"description\" content=\"A brass desk lamp with a three-step dimmer.\">\n<script type=\"application/ld+json\">\n{\"@context\": \"https://schema.org\", \"@type\": \"Product\", \"name\": \"Fern Desk Lamp\", \"image\": \"/img/fern.jpg\",\n \"offers\": {\"@type\": \"Offer\", \"price\": \"64.00\", \"priceCurrency\": \"EUR\", \"availability\": \"https://schema.org/InStock\"}}\n</script></head><body>\n<h1>Fern Desk Lamp</h1>\n<p>EUR 64.00 - in stock</p>\n<p>A brass desk lamp with a three-step dimmer.</p>\n<img src=\"https://lumenandoak.example/img/fern.jpg\" alt=\"Fern lamp\">\n</body></html>",
  ld: "", url: "https://lumenandoak.example/lamps/fern", want: "auto" });
// form.blocks: [{where, ok, error, value, nodes, findings}] - one per JSON-LD block, the page's own first, then any pasted in ld
// form.nodes: [{types, path, top, rule, obj}] - every typed node, nested ones and @graph members included
// form.findings: [{code, level, node, type, prop, text, value}] - level is fix, check or info
K.expectVerdict(form)                 // add with no JSON-LD, fix when any finding is at fix, keep otherwise
K.findingsCSV(form.findings)          // level, code, node, property, finding
const built = K.buildInput(form);     // built.ok is false, with built.note, until the paste has enough to read
// built.warn: what the page warns about
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.check(reply, built)                   // {status: kept or flagged, notes}
// R.audit(reply.jsonld, form)             // {markup: the checker's result on the reply's JSON-LD, invented: [{key, value, why}]}
// K.scriptTag(reply.jsonld)               // the tag to paste, with < written as \u003c

Paste keys: page (the page's HTML, its plain text, or bare JSON-LD), ld (JSON-LD pasted separately - a bare object or script tags - or empty), url (the page's address; empty takes the canonical link) and want (auto, Product, Article, BlogPosting, Organization, LocalBusiness, Event, SoftwareApplication, BreadcrumbList, WebSite or FAQPage; anything else reads as auto). Here the image is a relative path, so the facts carry a relative_url finding at fix and the verdict the page expects is fix. The engine sends nothing anywhere: it only reads the text you give it.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://markup-mirror.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"markup-mirror"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "markup", "want": "auto", "page": "URL: https://lumenandoak.example/lamps/fern\nTitle: Fern Desk Lamp - Lumen and Oak\nH1: Fern Desk Lamp\nMeta description: A brass desk lamp with a three-step dimmer.\nImages on the page: https://lumenandoak.example/img/fern.jpg\nVisible text:\nFern Desk Lamp - Lumen and Oak \n Fern Desk Lamp\n EUR 64.00 - in stock\n A brass desk lamp with a three-step dimmer.", "existing": "Block 1 (in the page):\n{\n \"@context\": \"https://schema.org\",\n \"@type\": \"Product\",\n \"name\": \"Fern Desk Lamp\",\n \"image\": \"/img/fern.jpg\",\n \"offers\": {\n  \"@type\": \"Offer\",\n  \"price\": \"64.00\",\n  \"priceCurrency\": \"EUR\",\n  \"availability\": \"https://schema.org/InStock\"\n }\n}", "facts": "1 JSON-LD block found (1 parse), with 2 typed nodes: Product, Offer.\nPage type the page guesses from the text and markup: Product.\n[check] Product could add sku, brand, aggregateRating, review (recommended by the source skill).\n[fix] Product image \"/img/fern.jpg\" is not a full URL (https://...)."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses markup-mirror:<task>:<inputHash>:a<attempt> (the one task is markup), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="markup-mirror:markup:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '.verdict, .note, (.changes[] | "\(.node) - \(.change)")' reply.json
jq '.jsonld' reply.json | sed 's/</\\u003c/g' > markup.json   # the JSON-LD alone, with < as \u003c, ready for a script tag

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: markup-mirror:markup:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json