Zero to First Transform: Running a JSON-LD Vocabulary Through CoreModels
By the end of this page you will have made exactly one HTTP call and produced two artifacts: a JSON Schema generated from an RDF vocabulary, and a lossiness ledger that states — in writing — what the conversion could not carry across. Learning to read both is the entire skill. Everything else in the CoreModels transform surface is a variation on this loop.
Zero to First Transform: Running a JSON-LD Vocabulary Through CoreModels
By the end of this page you will have made exactly one HTTP call and produced two artifacts: a JSON Schema generated from an RDF vocabulary, and a lossiness ledger that states — in writing — what the conversion could not carry across. Learning to read both is the entire skill. Everything else in the CoreModels transform surface is a variation on this loop.
We are the CoreModels team at ARAMAI. The inputs and outputs below are real: we ran them through the shipped coders and pasted the results.
What you need
Three things, no installation:
- A CoreModels host. We write
https://coremodels.example.comthroughout — substitute yours. - A bearer token in
$TOKEN, and a project id in$PROJECT_ID. The call we make is stateless and needs only Viewer access; the project scopes authorization and nothing else. curlandjq. Thejqpart matters more than it looks: schema documents travel as JSON strings inside the request body, and hand-escaping a JSON-LD file is where first attempts usually die.
The vocabulary
Save this as person.jsonld. It is small but not a toy: two classes with an inheritance edge,
three properties, two literal datatypes, one object-valued property, and one term borrowed from
schema.org rather than minted locally.
{
"@context": {
"schema": "https://schema.org/",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"ex": "https://example.org/"
},
"@graph": [
{ "@id": "ex:Person", "@type": "rdfs:Class", "rdfs:label": "Person" },
{ "@id": "ex:Employee", "@type": "rdfs:Class", "rdfs:label": "Employee",
"rdfs:subClassOf": { "@id": "ex:Person" } },
{ "@id": "schema:name", "@type": "rdf:Property", "rdfs:label": "name",
"schema:domainIncludes": { "@id": "ex:Person" },
"schema:rangeIncludes": { "@id": "xsd:string" } },
{ "@id": "ex:age", "@type": "rdf:Property", "rdfs:label": "age",
"schema:domainIncludes": { "@id": "ex:Person" },
"schema:rangeIncludes": { "@id": "xsd:integer" } },
{ "@id": "ex:manager", "@type": "rdf:Property", "rdfs:label": "manager",
"schema:domainIncludes": { "@id": "ex:Employee" },
"schema:rangeIncludes": { "@id": "ex:Person" } }
]
}
That is the dialect the jsonld format key reads: a vocabulary, published the way schema.org
publishes its terms. Nodes typed rdfs:Class are the types. Nodes typed rdf:Property are the
fields, attached to their class by schema:domainIncludes and typed by schema:rangeIncludes.
rdfs:subClassOf is inheritance. Four prefixes are always understood without being declared —
rdf, rdfs, xsd, and schema — which is why rdfs:Class works above even though the
@context never mentions rdfs.
The one call
We use schema/map, the stateless mapping endpoint. It runs the full engine — decode the source,
produce a plan, validate the plan through the engine's universal gate, execute it, encode the
target — and returns everything it did.
One thing will bite you if nobody warns you first: the default inferred strategy matches the
source against a target hint, so it needs something to aim at. Omit the hint and the call
comes back with The inference resolver requires a target IR to match against. For a
straight format conversion the idiom is to hand the document to itself as the hint. Every
construct then matches its own twin and the plan comes out as clean identity mappings.
jq -n --rawfile doc person.jsonld '{
sourceFormat: "jsonld",
sourceSchema: $doc,
targetFormat: "jsonschema",
targetHintFormat: "jsonld",
targetHintSchema: $doc,
mapping: { kind: "inferred" }
}' > body.json
curl -s -X POST "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @body.json
Two headers, one body, no setup, no state. schema/map never writes to the project — every call
is inherently a dry run.
The schema that comes back
The response uses the envelope shared by every transform endpoint: success, lossiness,
errors, plus this verb's payload — schema and plan. For JSON-shaped targets, schema is a
JSON object, not a string. Here it is, exactly as the engine produced it:
{
"type": "object",
"properties": {
"name": {
"type": "string",
"x-maps-to": { "schema": "https://schema.org/name" }
},
"age": {
"type": "integer",
"x-maps-to": { "ex": "https://example.org/age" }
}
},
"x-maps-to": { "ex": "https://example.org/Person" },
"$defs": {
"ex:Employee": {
"allOf": [
{ "$ref": "#/$defs/ex:Person" },
{
"type": "object",
"properties": {
"manager": {
"$ref": "#/$defs/ex:Person",
"x-maps-to": { "ex": "https://example.org/manager" }
}
}
}
],
"x-maps-to": { "ex": "https://example.org/Employee" }
}
}
}
Four things happened there, and together they are how CoreModels treats JSON-LD.
The IRIs came through. In a vocabulary, a node's @id is a global identifier, so the coder
expands each qname against the @context and lifts it into a cross-standard mapping annotation —
the x-maps-to entries you see on the root object and on every property. schema:name became
https://schema.org/name; ex:age became https://example.org/age. Push this schema onward to
Avro, LinkML, SQL, or OWL later and those identities travel with it. That is why we treat JSON-LD
as a carrier of meaning rather than one more syntax.
Inheritance became allOf. ex:Employee rdfs:subClassOf ex:Person is expressed the way JSON
Schema expresses extension: an allOf over a $ref to the parent plus the subclass's own
properties.
Datatypes landed cleanly. xsd:string became "type": "string", xsd:integer became
"type": "integer", and the object-valued range on manager became a real $ref — a reference,
not a stringified type name.
Nothing is required. There is no required array, and that is faithful reading, not an
omission. RDF is open-world: an rdf:Property never asserts that instances must supply a value,
so every decoded property arrives optional. If your downstream contract needs mandatory fields,
they have to come from somewhere that can state them.
The ledger
lossiness is the honest change report, and the habit we ask everyone to build is: read it every
time. Each entry carries a kind — StructuralDrop (no home in the target), TypeApproximation
(close but not exact), ConstraintRelaxation (a rule could not be enforced), or
SemanticNarrowing (meaning was narrowed or guessed) — plus a path naming the construct and a
plain-English explanation.
For this conversion the ledger is empty:
{ "lossiness": [] }
Empty means the target held everything the source said: classes to objects, properties to typed
properties, subclassing to allOf, IRIs to x-maps-to. Nothing to review.
The rule that empty ledgers can hide is the one worth memorizing: success: true means "it
ran", not "nothing changed." Aim the same vocabulary at a relational target and the ledger stops
being empty — an enumeration has no inline representation in Postgres, and the engine says so
rather than letting you find out in production. Entries are review items, not errors; a
non-empty ledger on a successful call is by design.
The plan, in one paragraph
The response's third artifact is plan — the operations the engine actually executed, each
tagged with its origin. For this call it is five operations: two type mappings and three element
mappings, all "origin": "Inferred". Store it and you can replay the identical transformation
later through the plan/execute endpoint; the same plan against the same source produces the
same output. That is what turns a one-off conversion into something a build can repeat, and it is
a subject that deserves its own article.
The other direction, in two lines
If the model you care about already lives in a CoreModels project, the mirror-image call publishes it as a vocabulary. Same envelope, same ledger discipline, Viewer role, and the smallest body on the whole API:
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": "jsonld" }'
Types come back as rdfs:Class nodes, inheritance as rdfs:subClassOf, elements as
rdf:Property nodes wired by schema:domainIncludes and schema:rangeIncludes, and controlled
lists as classes subclassing schema:Enumeration with one node per term. Because jsonld is a
JSON-shaped format, the schema field is a JSON object here too.
What to try next
You have run the loop end to end: JSON-LD in, JSON Schema out, ledger read. The same call works in
every direction the engine supports — swap targetFormat for linkml, avro, owl, shex,
odcs, protobuf, or sql (with "vendor": "postgres" | "mysql" | "sqlserver") and the shape of
the response never changes. And because the format decodes as well as encodes, you can write a
vocabulary straight into a project with schema/import when you want it governed rather than just
converted.
For the full endpoint reference and ready-to-paste request bodies for every format key, see the Schema Transformation API guide in the CoreModels docs.