JSON-LD logoAPI

Four Verbs and a Vocabulary: The JSON-LD HTTP Surface, With Real Bodies

Everything you can do with an RDF vocabulary on the CoreModels HTTP API fits in four routes. One writes a vocabulary into a governed project, one publishes a project back out as a vocabulary, one converts statelessly through the mapping engine, and one replays a stored conversion. This article walks all four with request and response bodies we actually ran, then states the direction limits plainly — including the one that has no ledger entry to warn you about it.

Four Verbs and a Vocabulary: The JSON-LD HTTP Surface, With Real Bodies

Everything you can do with an RDF vocabulary on the CoreModels HTTP API fits in four routes. One writes a vocabulary into a governed project, one publishes a project back out as a vocabulary, one converts statelessly through the mapping engine, and one replays a stored conversion. This article walks all four with request and response bodies we actually ran, then states the direction limits plainly — including the one that has no ledger entry to warn you about it.

We are the CoreModels team at ARAMAI.

VerbRoute (POST)RoleJSON-LD direction
Import schemagraph/transform/schema/import/{projectId}Admindecode
Export schemagraph/transform/schema/export/{projectId}Viewerencode
Map schemagraph/transform/schema/map/{projectId}Viewer (ai: Editor)either end
Execute plangraph/transform/plan/execute/{projectId}Viewereither end

Ground rules for all of them: the base path is https://coremodels.example.com/graph/transform/... with no api/ prefix; every request carries Authorization: Bearer $TOKEN and Content-Type: application/json; the project id in the path scopes authorization. Every response shares one envelope:

{
  "success": true,     // false only if the call could NOT proceed — then read "errors"
  "lossiness": [],     // what the target could not hold exactly — read this every time
  "errors": [],        // on failure: [{ "path": "...", "message": "..." }]
  "schema": "..."      // or "projectId" / "data" / "summary", by verb — plus "plan" on mapping calls
}

success: true means the call ran, not that nothing changed. The lossiness entries carry one of four kinds — StructuralDrop, TypeApproximation, ConstraintRelaxation, SemanticNarrowing — with a path and a plain-English explanation.

Import: a vocabulary becomes governed model

schema/import decodes the document and writes the equivalent Types and Elements into the project through the normal profile layer. Body keys: format, schema (the document as a JSON string), and optional spaces (empty means the project's main space). This body is small enough to paste as-is:

{
  "format": "jsonld",
  "schema": "{ \"@context\": { \"schema\": \"https://schema.org/\" }, \"@graph\": [ { \"@id\": \"schema:Person\", \"@type\": \"rdfs:Class\" }, { \"@id\": \"schema:name\", \"@type\": \"rdf:Property\", \"schema:domainIncludes\": { \"@id\": \"schema:Person\" }, \"schema:rangeIncludes\": { \"@id\": \"xsd:string\" } } ] }"
}
curl -s -X POST "https://coremodels.example.com/graph/transform/schema/import/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @import.json

Response: the shared envelope with this verb's payload — { "success": true, "lossiness": [ … ], "errors": [], "projectId": "…" }. The JSON-LD decoder contributes nothing to that ledger (a structural property, and the subject of our deep-dive), so anything you find in it came from the write side.

What landed: a Person type with a name element typed String — and, because every @id in a vocabulary is an IRI, both carry a mapsTo annotation (https://schema.org/Person, https://schema.org/name) that every other export format reads later. And schema is one of the four prefix bindings the exporter always knows, so a later JSON-LD export compacts these IRIs back to the same qnames. Note two decode behaviors that are not bugs: nodes with no rdfs:label take the local name after the last : or / as their label, and every element arrives optional, because an RDF vocabulary contains no statement that a property must be present.

A malformed payload fails honestly rather than half-importing: a document whose root is not a JSON object comes back success: false with errors[0].path of $ and the message The root of a JSON-LD document must be an object.

Export: a project becomes a vocabulary

The smallest useful body on the API:

{ "format": "jsonld" }
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" }'

Because JSON-LD is a JSON-shaped format, the schema field of the response is a JSON object, not a string. Types become rdfs:Class nodes; inheritance becomes rdfs:subClassOf; taxonomies become classes subclassing schema:Enumeration with one member node per term; elements become rdf:Property nodes wired with schema:domainIncludes (always an array — an element shared by several types lists them all) and schema:rangeIncludes. Here is real encoder output for a Customer model with four fields and a three-value status taxonomy, abridged to one property and one enumeration member:

{
  "@context": { "xsd": "http://www.w3.org/2001/XMLSchema#" },
  "@graph": [
    { "@id": "ex:Customer", "@type": "rdfs:Class", "rdfs:label": "Customer" },
    { "@id": "Customer::status::enum", "@type": "rdfs:Class",
      "rdfs:subClassOf": { "@id": "schema:Enumeration" },
      "rdfs:label": "Customer::status::enum" },
    { "@id": "Customer::status::enumdraft", "@type": { "@id": "Customer::status::enum" },
      "rdfs:label": "draft" },
    { "@id": "Customer::signup_date", "@type": "rdf:Property", "rdfs:label": "signup_date",
      "schema:domainIncludes": [ { "@id": "ex:Customer" } ],
      "schema:rangeIncludes": { "@id": "xsd:dateTime" } }
  ]
}

Read the identifiers carefully, because they tell you how much identity your model carries. A node id that already looks like a qname is emitted unchanged. One that does not is minted: the type above became ex:Customer from its PascalCased label, and enumeration members are named by concatenating the taxonomy id with the term id. That is what an export looks like for a model that was never authored in RDF.

Identity is recoverable, though, and the recovery rule is worth knowing: before minting anything, the encoder looks for a mapsTo IRI on the node and compacts it back to a qname against the prefixes it holds. A term governed as https://schema.org/name therefore comes back out as schema:name no matter what the internal node id looks like. Straight through the coder — vocabulary in, vocabulary out — ids and @context bindings survive verbatim, and that decode/encode/decode cycle is what our test suite pins.

One practical note for strict consumers: the emitted @context declares the prefixes the document's identifiers use and can resolve — here just xsd. The vocabulary keys themselves (rdfs:Class, rdf:Property, schema:domainIncludes) rely on the four standard prefixes that CoreModels always understands on the way back in. If you are handing the file to a general-purpose JSON-LD processor, merge the standard rdf, rdfs, xsd, and schema bindings into the context first. No JSON-LD-specific options exist on this endpoint: vendor belongs to sql and the synapse* keys to synapse; both are ignored here.

Map: convert statelessly, with a hint

schema/map runs source → plan → gate → execute → target and writes nothing. The inferred strategy matches against a target hint, and the hint is what decides how much survives. Here we aim a five-node Person vocabulary (ex:Person, ex:Employee, and the properties schema:name, ex:age, ex:manager) at a deliberately narrower JSON Schema hint:

cat > hint.json <<'EOF'
{ "$id": "Person", "type": "object", "title": "Person",
  "properties": { "name": { "type": "string" }, "age": { "type": "integer" } } }
EOF

jq -n --rawfile vocab person.jsonld --rawfile hint hint.json '{
  sourceFormat: "jsonld",
  sourceSchema: $vocab,
  targetFormat: "jsonschema",
  targetHintFormat: "jsonschema",
  targetHintSchema: $hint,
  mapping: { kind: "inferred", caseInsensitive: true }
}' | curl -s -X POST "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
      -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-

The plan field of the response is the authority on what mapped:

{
  "operations": [
    { "kind": "TypeMapping", "origin": "Inferred", "sourceTypeId": "ex:Person",
      "targetTypeId": "Person", "targetLabel": "Person" },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["schema:name"], "targetElementIds": ["Person::name"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ex:age"], "targetElementIds": ["Person::age"] }
  ]
}

Three operations, not five: ex:Employee and ex:manager had no home in the hint, so they are absent from the plan and from the schema the call returns. That schema keeps each property's IRI as x-maps-toschema:name still resolves to https://schema.org/name on the far side of the conversion. For a full-fidelity conversion instead of a narrowing one, pass the source as its own hint, or set useProjectAsTargetHint: true to map toward the project's current schema.

Two other strategies share the same mapping object. explicit takes a mapping guide as a JSON string in guide; unknown keys in a guide are rejected with a path-carrying error rather than ignored, because a silently misspelled directive would execute a plan you did not intend. ai asks a server-side Claude proposer for the plan, which then passes the identical validation gate; it requires Editor or Admin membership plus a server-configured key, and it sends schema content to the Anthropic API server-side.

Execute: replay a stored plan

The plan from any mapping response is a reviewable, replayable artifact. On this endpoint it travels as a string, which is exactly what jq --rawfile gives you:

jq -n --rawfile vocab person.jsonld --rawfile plan plan.json '{
  sourceFormat: "jsonld",
  sourceSchema: $vocab,
  plan: $plan,
  targetFormat: "jsonschema"
}' | curl -s -X POST "https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
      -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-

The parsed plan goes through the same universal gate as every freshly produced plan — a stored plan earns no shortcut — and then executes deterministically. The response is the standard envelope with schema.

The record plane, in passing

jsonld is also one of the engine's data formats. data/import, data/export, and the cross-schema data/map accept json | csv | jsonld | sql | avro as record serializations, so the same key that carries your vocabulary can carry your instances — with property keys written as IRIs rather than opaque local ids.

Direction limits, honestly

jsonld appears in both dispatch lists, so unlike odm (decode-only) or synapse (whose decode attempt returns 'synapse' is encode-only: a Synapse schema is plain draft-07 JSON Schema — decode it with the 'jsonschema' format.), it carries no directional asterisk. The honest limits are semantic:

  • Required-ness does not survive an encode, and nothing tells you. RDFS has no construct for "this property must be present," so an element marked required in the source model is emitted as an ordinary rdf:Property — and the JSON-LD encoder produces no lossiness record when that happens. We checked it: take a JSON Schema carrying "required": ["customer_key", "full_name"], encode it as JSON-LD (encode ledger: empty), decode that document back, and all four elements are optional. If required-ness matters downstream, keep the governed model as the authority and publish the contract in a format that can state it.
  • Value constraints beyond enumerations have nowhere to go. Max-lengths, patterns, and numeric bounds are not vocabulary constructs. Enumerations are the exception — they map both ways through schema:Enumeration.

Roles, in one line: schema/import is Admin, schema/export, schema/map, and plan/execute are Viewer, and mapping.kind=ai raises the bar to Editor or Admin wherever it appears. For ready-to-paste bodies covering every format key the dispatch speaks, see the Schema Transformation API guide in the CoreModels docs.