One Neutral IR, Fourteen Formats: The Schema Transformation API
JSON Schema, Avro, SQL DDL, LinkML, proto3, an ODCS data contract: each format you run is a partial view of the same underlying model. CoreModels treats it that way: one neutral intermediate representation in the middle, fourteen formats around it, and a signed confession every time a hop loses something. This article walks the transformation API end to end, over HTTP and over MCP.
One Neutral IR, Fourteen Formats: The Schema Transformation API
JSON Schema, Avro, SQL DDL, LinkML, proto3, an ODCS data contract: each format you run is a partial view of the same underlying model. CoreModels treats it that way: one neutral intermediate representation in the middle, fourteen formats around it, and a signed confession every time a hop loses something. This article walks the transformation API end to end, over HTTP and over MCP.
N coders, not N squared
Point-to-point schema converters do not scale. Fourteen formats connected pairwise would need up to 14 × 13 = 182 directed translators, each with its own bugs and its own opinions about what a date-time becomes. CoreModels maintains one coder per format instead. Each coder decodes its format into a neutral intermediate representation (types, elements, taxonomies, relations, constraints) and/or encodes that representation back out. Add a fifteenth format and you write one coder — it immediately interoperates with every other format in the table.
The second design decision matters just as much: the engine never pretends a transformation was free. Formats genuinely differ in what they can express. Postgres has no inline enum. Proto3 has no date-time scalar. Apache Ossie (formerly OSI) has no controlled-list construct. When a construct cannot survive a hop, CoreModels does not silently drop it or smuggle it into a vendor extension — it emits the closest honest representation and records exactly what happened in a lossiness ledger that rides on every response. Our API documentation puts it bluntly:
success: truedoes not mean "nothing changed." It means "it ran." Always readlossiness.
Fourteen formats, one hub
These are the format keys the engine dispatches on, with directionality:
| Key | Format | In (decode) | Out (encode) |
|---|---|---|---|
jsonschema | JSON Schema | ✓ | ✓ |
sia | alias for jsonschema | ✓ | ✓ |
shex | ShEx shapes | ✓ | ✓ |
avro | Avro schema | ✓ | ✓ |
jsonld | JSON-LD | ✓ | ✓ |
sql | SQL DDL — vendors: postgres (default), mysql, sqlserver (alias mssql) | ✓ | ✓ |
osi | Apache Ossie (formerly OSI) semantic model, YAML | ✓ | ✓ |
osi-json | the same OSI model as JSON — "the serialization dbt ingests" | ✓ | ✓ |
owl | OWL/Turtle ontology | ✓ | ✓ |
linkml | LinkML schema (YAML) | ✓ | ✓ |
protobuf | Protocol Buffers proto3 (.proto file); alias proto | ✓ | ✓ |
odcs | Bitol Open Data Contract Standard v3 YAML contract | ✓ | ✓ |
odm | ODM entity documents (Markdown) | ✓ | — |
synapse | Synapse curation schema | — | ✓ |
Two formats are deliberately one-way. odm is decode-only because ODM entities are authored documentation, not a generated artifact. synapse is encode-only, and if you try to decode it the engine tells you why in plain words:
'synapse' is encode-only: a Synapse schema is plain draft-07 JSON Schema — decode it with the 'jsonschema' format.
That message is the whole philosophy in miniature: the API would rather explain a boundary than blur it.
Transforming a schema over HTTP
The transform surface lives under graph/transform/... on your CoreModels host. All routes require authentication and are project-scoped; read-shaped operations need only Viewer access. The two you will use most:
POST graph/transform/schema/export/{projectId}— encode the project's governed model into any output format (Viewer).POST graph/transform/schema/map/{projectId}— stateless source-to-target transformation through the mapping engine (Viewer). Nothing is written to the project — every call is inherently a dry run.
Every response uses the same envelope: success, lossiness, errors, plus the payload fields for that endpoint (schema here; the mapping endpoints add plan).
Exporting the governed model is one line of body. Replace coremodels.example.com with your CoreModels host throughout:
curl -s -X POST \
"https://coremodels.example.com/graph/transform/schema/export/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "format": "sql", "vendor": "postgres" }'
You get back { success, lossiness, errors, schema }, where schema is the DDL string and lossiness is the ledger for that encoding.
The more interesting endpoint is schema/map: hand it a schema in one format, name a target format, and optionally describe the shape you are mapping into with a target hint. Here is a complete worked example. Save this as customer.schema.json:
{
"$id": "Customer",
"type": "object",
"title": "Customer",
"properties": {
"customer_key": { "type": "integer" },
"full_name": { "type": "string" },
"signup_date": { "type": "string", "format": "date-time" },
"status": { "type": "string", "enum": ["draft", "active", "closed"] }
},
"required": ["customer_key", "full_name"]
}
Now map it to Postgres DDL, using an existing table definition as the target hint so the engine aligns incoming fields to it by label and type. Save this request body as map-request.json:
{
"sourceFormat": "jsonschema",
"sourceSchema": "{\"$id\":\"Customer\",\"type\":\"object\",\"title\":\"Customer\",\"properties\":{\"customer_key\":{\"type\":\"integer\"},\"full_name\":{\"type\":\"string\"},\"signup_date\":{\"type\":\"string\",\"format\":\"date-time\"},\"status\":{\"type\":\"string\",\"enum\":[\"draft\",\"active\",\"closed\"]}},\"required\":[\"customer_key\",\"full_name\"]}",
"targetFormat": "sql",
"vendor": "postgres",
"targetHintFormat": "sql",
"targetHintSchema": "CREATE TABLE \"Customer\" (\"customer_key\" INTEGER NOT NULL, \"full_name\" VARCHAR(255) NOT NULL, \"signup_date\" TIMESTAMP, \"status\" VARCHAR(255));",
"mapping": { "kind": "inferred", "caseInsensitive": true }
}
and POST it:
curl -s -X POST \
"https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @map-request.json
The response:
{
"success": true,
"lossiness": [
{
"kind": "StructuralDrop",
"path": "Type[Customer].Customer::status",
"explanation": "Element is a member of the mapped type but no operation maps or drops it."
}
],
"errors": [],
"schema": "CREATE TABLE \"Customer\" (\n \"customer_key\" INTEGER NOT NULL,\n \"full_name\" VARCHAR(255) NOT NULL,\n \"signup_date\" TIMESTAMP\n);",
"plan": { "operations": [ /* the replayable mapping plan — store this verbatim */ ] }
}
Unescaped, that schema string is exactly what the Postgres encoder produces:
CREATE TABLE "Customer" (
"customer_key" INTEGER NOT NULL,
"full_name" VARCHAR(255) NOT NULL,
"signup_date" TIMESTAMP
);
Three things happened, and the response is honest about all of them. The required fields became NOT NULL. The date-time string became a TIMESTAMP. And status — enum-constrained in the source, a plain VARCHAR in the hint — is not the same construct, so the inference resolver refused to pretend it was: the element was left out of the mapping and the ledger says so with a StructuralDrop. A mapping that names the correspondence explicitly — an AI-proposed plan that maps the element and carries its taxonomy, for instance — can bring status across; label matching alone is not allowed to erase a constraint silently.
A note on mapping strategies: mapping.kind is inferred (label/type matching against the target hint, case-insensitive by default — a target hint is required, and without one the resolver refuses rather than guesses), explicit (an authored mapping guide with field mappings, taxonomy directives, and declared drops — unknown keys are rejected with a path-carrying error), or ai (a server-side Claude proposer suggests a plan; requires Editor or Admin project membership). The ai kind is deliberately fenced: its proposal passes the exact same validation gate as every other plan, with at most one repair attempt, and its self-reported confidence scores are advisory only — the gate never trusts them. Whatever the strategy, the plan that comes back executes deterministically. You can also set useProjectAsTargetHint to map against the project's own governed schema instead of an inline hint.
Reading the lossiness ledger
Every transform response carries lossiness as an array of records with one shape:
{ "kind": "...", "path": "...", "explanation": "..." }
There are exactly four kinds:
- StructuralDrop — something had no home in the target and was left out entirely. Example: the
statuselement above, which the DDL hint had no compatible column for. - TypeApproximation — a value or type was represented by a close-but-not-exact target type. Example: proto3 has no date-time scalar, so a DateTime degrades to
string. - ConstraintRelaxation — a rule (required, max-length, enum) could not be enforced and was relaxed. Example: exporting the governed Customer model to Postgres emits
statusasVARCHAR(255)and records "Postgres has no inline enum; emitted as VARCHAR (the allowed-value constraint is not enforced)." - SemanticNarrowing — meaning was narrowed or guessed. Example: the enum identifier
Customer::status::enumsanitized toCustomer__status__enumfor proto3.
The same forgiveness applies on the way in. The protobuf coder states the posture plainly: "a decode never hard-fails on a construct it can skip" — services and extend blocks in a .proto file, for instance, are skipped with a lossiness record rather than aborting the parse.
We consider the ledger a feature, not an apology. Most conversion tools optimize for a clean-looking output and let the differences leak silently into production; six months later someone discovers the "enum" column accepts anything. CoreModels takes the opposite bet: constructs a wire format cannot express are reported as lossiness rather than smuggled into the output, and the ledger travels with the artifact even when there is no JSON envelope to carry it — the OWL export, for instance, writes its ledger as # lossiness: comments at the top of the Turtle file. An empty ledger is a real guarantee. A non-empty ledger is a to-do list you can act on before anything ships.
The plan is a replayable artifact
schema/map never writes to the project, which means the interesting output is not just the schema — it is the plan. The plan is the validated, deterministic recipe the engine executed, and you can store it and replay it later against POST graph/transform/plan/execute/{projectId} (Viewer, stateless):
# plan.json is the "plan" object saved from the schema/map response
PLAN=$(jq -c . plan.json | jq -Rs .) # the plan JSON, re-encoded as a string
SRC=$(jq -Rs . customer.schema.json) # the source schema, as a string
curl -s -X POST \
"https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"sourceFormat\": \"jsonschema\",
\"sourceSchema\": $SRC,
\"plan\": $PLAN,
\"targetFormat\": \"sql\"
}"
Note that plan is passed as a string containing the plan JSON. The guarantee is simple and test-backed: same plan plus same source produces the same output. That turns a one-off conversion into infrastructure — check the plan into your repo, run it in CI, diff its output over time. If the source schema drifts in a way the plan does not cover, the run tells you instead of improvising.
The same transform from an agent, via MCP
Everything above is also available to AI agents through the CoreModels MCP server. The read-only endpoint is /mcp (OAuth 2.0-protected, with dynamic client registration — spec-compliant MCP clients handle discovery and the PKCE flow automatically). From Claude Code:
claude mcp add --transport http coremodels https://go.coremodels.io/mcp
then run /mcp inside Claude Code to complete the OAuth flow.
The tool is transform_schema: a stateless schema-to-schema transformation through the same mapping engine, returning the converted schema, the replayable plan, and the lossiness ledger. Required arguments are graphProjectId (32-char hex), sourceFormat, sourceSchema, and targetFormat; optional arguments include vendor (for SQL output), targetHintFormat / targetHintSchema (inferred mapping matches against the hint, so pass one — for a pure format hop, the schema itself can serve as the hint, as below), mappingKind (inferred — the default — explicit, or ai), guide, caseInsensitive, and synapseOrg / synapseName / synapseVersion for Synapse output. As on HTTP, mappingKind: "ai" requires Editor or Admin membership because it calls the Anthropic API server-side.
Let's finish the worked example by carrying the enum through a hop that can hold it. Enums are first-class constructs in both proto3 and LinkML, so take the streaming estate's proto3 rendition of the same Customer contract and map it 1:1, with the schema as its own target hint. The agent invokes:
{
"graphProjectId": "0123456789abcdef0123456789abcdef",
"sourceFormat": "protobuf",
"sourceSchema": "syntax = \"proto3\";\n\nmessage Customer {\n int64 customer_key = 1;\n string full_name = 2;\n optional string signup_date = 3;\n optional Status status = 4;\n}\n\nenum Status {\n DRAFT = 0;\n ACTIVE = 1;\n CLOSED = 2;\n}",
"targetFormat": "linkml",
"targetHintFormat": "protobuf",
"targetHintSchema": "syntax = \"proto3\";\n\nmessage Customer {\n int64 customer_key = 1;\n string full_name = 2;\n optional string signup_date = 3;\n optional Status status = 4;\n}\n\nenum Status {\n DRAFT = 0;\n ACTIVE = 1;\n CLOSED = 2;\n}"
}
and the returned schema is the LinkML encoder's actual output:
id: https://schematica.io/coremodels/protobufschema
name: protobufSchema
prefixes:
linkml: https://w3id.org/linkml/
imports:
- linkml:types
default_range: string
classes:
Customer:
attributes:
customer_key:
range: integer
required: true
full_name:
required: true
signup_date:
status:
range: Status
enums:
Status:
permissible_values:
DRAFT:
ACTIVE:
CLOSED:
Compare the two hops. Going to Postgres, the enum-constrained element had no home in the DDL hint and the ledger recorded the drop. Going to LinkML, the enum survives as a first-class enums block with its permissible values intact — and the ledger comes back empty, which is itself a guarantee. (Notice, too, that proto3's plain scalars customer_key and full_name arrive as required: true, while the explicitly optional fields do not.) Two targets, and in both cases the response tells you precisely what the target format could and could not hold. That is the point of a neutral IR with an honest ledger: you choose the output format based on what it preserves, with the evidence in hand rather than discovered in production.
To go deeper — the full request catalog, ready-to-paste bodies for every format, and per-vendor quickstarts — see the CoreModels docs.