Your First MACH ODM Transform: Markdown In, JSON Schema Out
The MACH Alliance publishes its Open Data Model (ODM) as entity documentation: Markdown files with prose, a field table, embedded YAML schema definitions, and sample objects. That is a great format for humans and a frustrating one for machines — you cannot validate a payload against a Markdown page.
Your First MACH ODM Transform: Markdown In, JSON Schema Out
The MACH Alliance publishes its Open Data Model (ODM) as entity documentation: Markdown files with prose, a field table, embedded YAML schema definitions, and sample objects. That is a great format for humans and a frustrating one for machines — you cannot validate a payload against a Markdown page.
CoreModels closes that gap with one HTTP call. The odm schema format decodes a MACH ODM entity document exactly as published, and the dedicated conversion endpoint turns it into a formal JSON Schema 2020-12 document — with every constraint carried over, the prose promoted into the schema, and an explicit ledger of anything that could not be represented. This quickstart takes you from a raw entity document to a usable JSON Schema in about five minutes.
What you need
- A CoreModels API host (we use
https://coremodels.example.comas the placeholder throughout). - A bearer token in
$TOKENfor a user with at least Viewer access to a project. - A project id in
$PROJECT_ID. The conversion endpoint is stateless — the project only scopes authorization; nothing is read from or written to the graph.
That is the whole setup. Viewer is enough because we are converting, not importing.
The input: a real ODM entity document
Save the following as customer.md. It has the structure every ODM entity document shares: an H1 naming the entity, an ## Entity purpose section, an ## Object table with MUST/SHOULD/COULD practice levels, a ## YAML Schema Definition section with fenced YAML blocks, and a ## Sample Object.
# MACH Alliance Open Data Model: `Customer`
## Entity purpose
A unified customer entity representing an individual or organization interacting with the business.
## Object: Customer
| Field | Description | Practice |
|-------|-------------|----------|
| `id` | Unique customer identifier | MUST |
| `status` | Lifecycle status | SHOULD |
| `person` | Person data for individual customers | COULD |
## YAML Schema Definition
### Customer Schema
```yaml
Customer:
type: object
required:
- id
- status
properties:
id:
type: string
description: Unique customer identifier
status:
type: string
enum: [active, inactive, archived]
person:
$ref: "#/components/schemas/PersonData"
addresses:
type: array
items:
$ref: "#/components/schemas/Address"
```
### Supporting Type Definitions
```yaml
PersonData:
type: object
required: [first_name]
properties:
first_name:
type: string
last_name:
type: string
Address:
type: object
additionalProperties: false
properties:
line1:
type: string
country:
type: string
pattern: "^[A-Z]{2}$"
maxLength: 2
```
## Sample Object: Minimal customer
```json
{
"id": "cust-001",
"status": "active"
}
```
The call
The endpoint is POST /graph/transform/odm/convert/{projectId}. The body is entities — one or more { name, markdown } documents. Because the Markdown has to travel as a JSON string, the cleanest way to build the body is jq --rawfile:
jq -n --rawfile md customer.md \
'{ entities: [ { name: "customer", markdown: $md } ] }' \
| curl -s -X POST \
"https://coremodels.example.com/graph/transform/odm/convert/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @-
One entity in means one schema out, under the schema key. (Send several entities and you get schemas instead — a bundle keyed by kebab-cased entity name. That is the batch story, and it deserves its own article.)
The output
{
"success": true,
"lossiness": [],
"errors": [],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://machalliance.org/odm/customer.schema.json",
"title": "Customer",
"description": "A unified customer entity representing an individual or organization interacting with the business.",
"type": "object",
"required": ["id", "status"],
"properties": {
"id": {
"type": "string",
"description": "Unique customer identifier",
"x-odm-practice": "MUST"
},
"status": {
"type": "string",
"enum": ["active", "inactive", "archived"],
"x-odm-practice": "SHOULD"
},
"person": {
"$ref": "#/$defs/PersonData",
"x-odm-practice": "COULD"
},
"addresses": {
"type": "array",
"items": { "$ref": "#/$defs/Address" }
}
},
"x-odm-source": "machalliance/standards",
"$defs": {
"PersonData": {
"type": "object",
"required": ["first_name"],
"properties": {
"first_name": { "type": "string" },
"last_name": { "type": "string" }
}
},
"Address": {
"type": "object",
"additionalProperties": false,
"properties": {
"line1": { "type": "string" },
"country": {
"type": "string",
"pattern": "^[A-Z]{2}$",
"maxLength": 2
}
}
}
},
"examples": [
{ "id": "cust-001", "status": "active" }
]
}
}
Walk through what happened:
- The entity schema became the document root. The converter picks the YAML schema whose name matches the entity named in the H1 (
Customer), and the supporting schemas (PersonData,Address) moved under$defs. $refpointers were rewired.#/components/schemas/PersonDatabecame#/$defs/PersonData, so the document resolves against itself. A reference back to the entity itself would become#.- Every constraint survived verbatim.
required, theenum,pattern: "^[A-Z]{2}$",maxLength: 2,additionalProperties: false— the translator is a structural rewriter, not a whitelist, so keywords it has never heard of are carried over too. - The prose was promoted into the schema. The first paragraph of
## Entity purposebecame the rootdescription. The practice column of the## Objecttable landed per-property asx-odm-practice(MUST,SHOULD,COULD—addresseshas none because it is not in the table). The first## Sample ObjectJSON block becameexamples. - Provenance is stamped.
x-odm-source: "machalliance/standards"and the$idderived from the kebab-cased entity name mark where this schema came from.
Read the lossiness ledger — every time
The lossiness array is the part of the response we most want you to internalize. success: true means the conversion ran; it does not mean nothing changed. Anything the output could not carry exactly is recorded as { kind, path, explanation }.
This document converts clean, so the ledger is empty. Here is what would populate it:
- A
$refpointing outside the document (an external URL, or a target no YAML block defines) is preserved verbatim but reported asSemanticNarrowing— the pointer dangles, and the ledger says so at the exact path. - A
## Sample Objectblock that is not valid JSON is skipped, with aSemanticNarrowingrecord explaining that no examples were attached.
Hard failures are different: a document with no ## YAML Schema Definition section at all, or with malformed YAML inside it, comes back as success: false with a path-carrying entry in errors. Broken input never silently produces an empty schema.
Where this goes next
odm is a first-class source format across the whole transform surface, with one honest limit: it is decode-only. ODM entities are authored documentation, so there is nothing meaningful to encode back to — you will never see odm as an export target. But as a source, the same document you just converted can go anywhere the engine goes: POST /graph/transform/schema/import/{projectId} with "format": "odm" writes it into a CoreModels project as Types and Elements, POST /graph/transform/odm/import/{projectId} does the same with a dry-run-first contract, and the stateless mapping endpoint accepts "sourceFormat": "odm" on the way to SQL DDL, Avro, LinkML, or any other encode format — always with the same lossiness ledger attached.
For the full endpoint catalog, request bodies for every format, and the rest of the quickstarts, see the CoreModels transform API guide in the product docs.