# LinkML — API: The LinkML HTTP Surface: Import, Export, Map, Replay

There are ten routes on the CoreModels transform API. Five of them matter if LinkML is your source or your target, and the first thing to settle is which one you actually want - because the difference between them is not the format, it is whether anything gets written and who decides what passes through.

# The LinkML HTTP Surface: Import, Export, Map, Replay

There are ten routes on the CoreModels transform API. Five of them matter if LinkML is your source or your target, and the first thing to settle is which one you actually want - because the difference between them is not the format, it is whether anything gets written and who decides what passes through.

This article is the reference for that decision: the routes, their roles, real request and response bodies, and the errors you will hit if you get an argument wrong.

## Direction: both, without asterisks

Two format lists govern the whole surface. Decode (the formats you can bring *in*) is `jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm`. Encode (the formats you can produce) is `jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse`.

`linkml` appears in both, which is worth stating plainly because two neighbours on those lists do not: `odm` decodes only (ODM entities are authored documentation, not a generated artifact), and `synapse` encodes only - attempt to decode it and you get the honest reply *"'synapse' is encode-only: a Synapse schema is plain draft-07 JSON Schema - decode it with the 'jsonschema' format."* LinkML carries no such caveat. Anywhere a route says source format or target format, `linkml` is legal.

## The five routes

All are `POST`, all are authorized and project-scoped, and none of them uses an `api/` prefix.

| Route | What it does with LinkML | Role |
|---|---|---|
| `graph/transform/schema/import/{projectId}` | writes a LinkML schema into the project | Admin |
| `graph/transform/schema/export/{projectId}` | emits the project's schema as LinkML | Viewer |
| `graph/transform/schema/map/{projectId}` | LinkML in or out, through the mapping engine; nothing written | Viewer (`ai`: Editor) |
| `graph/transform/schema/mapImport/{projectId}` | maps LinkML onto the project's existing schema | Admin (dry run: Viewer) |
| `graph/transform/plan/execute/{projectId}` | replays a stored plan against the same source | Viewer |

Every response shares one envelope: `success` (false only when it could not proceed - read `errors`), `lossiness` (an array of `{kind, path, explanation}` records whose kinds are `StructuralDrop`, `TypeApproximation`, `ConstraintRelaxation`, `SemanticNarrowing`), `errors`, and the payload key for that route - `projectId`, `schema`, or `summary`, plus `plan` on the mapping routes and `dryRun` on map-import.

## Import: LinkML → project

Body: `format`, `schema` (the document text), optional `spaces` (target space ids; empty means the project's main space).

```bash
curl -s "https://coremodels.example.com/graph/transform/schema/import/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "format": "linkml",
    "schema": "id: https://example.org/person\nname: person\nclasses:\n  Person:\n    attributes:\n      name:\n        range: string\n        required: true\n      age:\n        range: integer\n"
  }'
```

```json
{ "success": true, "lossiness": [], "errors": [], "projectId": "..." }
```

Classes become Types, attributes become Elements, enums become Taxonomies, and any `class_uri` / `slot_uri` / `meaning` CURIE is expanded through the document's `prefixes:` map into a full IRI and attached as a `mapsTo` meaning link. Omit `format` or `schema` and the call fails before it touches the graph: `Body must include 'format' and 'schema'.`

## Export: project → LinkML

```bash
curl -s "https://coremodels.example.com/graph/transform/schema/export/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "format": "linkml" }' | jq -r '.schema'
```

The `schema` key holds the LinkML as a string. Export is a **Viewer** read that never writes, so it is safe to hand to a build agent or an analyst account. `vendor` is ignored here - it only applies to `sql` output - as are the `synapseOrg` / `synapseName` / `synapseVersion` keys. LinkML export takes no format-specific options at all; optional `spaces` narrows it to particular spaces.

## Map: the engine surface

`schema/map` is where LinkML gets interesting, and where the mental model has to change. This route does not "convert a document"; it **executes a plan**. The plan is produced by one of three strategies, validated by a single gate, executed deterministically, and returned to you next to the result.

That has a consequence people meet on their first call: whatever the plan does not cover does not appear in the output. Here it is, concretely. The source - a four-slot LinkML class:

```yaml
id: https://example.org/sensor-registry
name: sensor_registry
prefixes:
  linkml: https://w3id.org/linkml/
  schema: https://schema.org/
imports:
  - linkml:types
default_range: string

classes:
  Sensor:
    class_uri: schema:Thing
    attributes:
      sensor_id:
        identifier: true
        required: true
      name:
        slot_uri: schema:name
        required: true
      installed_on:
        range: datetime
      sampling_interval_s:
        range: integer
```

The target hint - the shape we are mapping toward, which deliberately knows about only two of those slots:

```bash
jq -n --rawfile s sensor.yaml --arg hint '{
  "$id": "Sensor", "type": "object", "title": "Sensor",
  "properties": {
    "sensor_id": { "type": "string" },
    "name": { "type": "string" }
  },
  "required": ["sensor_id", "name"]
}' '{sourceFormat:"linkml", sourceSchema:$s,
     targetFormat:"linkml",
     targetHintFormat:"jsonschema", targetHintSchema:$hint,
     mapping:{kind:"inferred", caseInsensitive:true}}' \
| curl -s "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @-
```

The `plan` in the response has three operations:

```json
{
  "operations": [
    { "kind": "TypeMapping", "origin": "Inferred",
      "sourceTypeId": "Sensor", "targetTypeId": "Sensor", "targetLabel": "Sensor" },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["SensorSensorId"], "targetElementIds": ["Sensor::sensor_id"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["SensorName"], "targetElementIds": ["Sensor::name"] }
  ]
}
```

The `lossiness` array accounts for the rest, by name:

```json
[
  { "kind": "StructuralDrop", "path": "Type[Sensor].SensorInstalledOn",
    "explanation": "Element is a member of the mapped type but no operation maps or drops it." },
  { "kind": "StructuralDrop", "path": "Type[Sensor].SensorSamplingIntervalS",
    "explanation": "Element is a member of the mapped type but no operation maps or drops it." }
]
```

And `schema` holds LinkML containing exactly what the plan carried - meaning links included, because they ride along on the mapped nodes:

```yaml
classes:
  Sensor:
    class_uri: schema:Thing
    attributes:
      sensor_id:
        identifier: true
        required: true
      name:
        slot_uri: schema:name
        required: true
```

Two practical rules follow. First: if you want a straight format conversion rather than a projection, hand the route the **source document as its own target hint** (`targetHintFormat: "linkml"` with the same text) - every label matches, the plan covers everything, and nothing is dropped. Second: `mapping` is optional and defaults to `{"kind":"inferred"}`, but inference is not optional about its hint. With neither `targetHintSchema` nor `useProjectAsTargetHint: true`, the call fails with `The inference resolver requires a target IR to match against.`

Setting `useProjectAsTargetHint: true` swaps the inline hint for the project's own schema - the way to align an incoming LinkML file against a model you already govern without writing anything.

## Replay: the plan as an artifact

The `plan` you got back is not a log line. Send it to `plan/execute` with the same source and you get the same output, every time; the parsed plan goes through the identical validation gate first, because a stored plan earns no shortcut. Note that `plan` here is a **string**, not an object:

```bash
jq -n --rawfile s sensor.yaml --rawfile p plan.json \
  '{sourceFormat:"linkml", sourceSchema:$s, plan:$p, targetFormat:"sql", vendor:"postgres"}' \
| curl -s "https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @- | jq -r '.schema'
```

`plan/execute` answers with the standard envelope plus `schema`, and it is a **Viewer** call - which is the point. Review and approve the plan once; let a low-privilege pipeline replay it forever after.

## Map-import: onto a model you already have

`schema/mapImport` is the writing cousin of `schema/map`: the project's current schema *is* the mapping target, and the transformed result is written back. Dry-run first - with `"dryRun": true` the call needs only Viewer and returns `summary` (counts of types, elements, taxonomies, components, relations), the `plan`, and the ledger, while writing nothing:

```bash
jq -n --rawfile s sensor.yaml \
  '{sourceFormat:"linkml", sourceSchema:$s, dryRun:true, mapping:{kind:"inferred"}}' \
| curl -s "https://coremodels.example.com/graph/transform/schema/mapImport/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @-
```

Read the lossiness, review the plan, then repeat with `"dryRun": false` (which requires Admin) to commit.

## The errors you will actually see

Every failure carries a path and a message, and the LinkML decoder is deliberately hard to break - unknown keys ride through as preserved data rather than errors. What genuinely fails:

- A document that is not YAML: `The document is not valid YAML: ...`, with the parser's complaint appended.
- Valid YAML that is not a schema: `The document is valid YAML but is not a LinkML schema (none of id, name, prefixes, classes, slots, or enums is present).` - or, for a document that is not a mapping at all, `... (expected a mapping at the top level).`
- An empty payload: `The LinkML YAML document is empty.`
- A bad format key: `Unknown schema format '...'. Use: ...`, listing the legal values.
- A mapping-kind typo: `Unknown mapping kind '...'. Use: explicit | inferred | ai.`
- `kind: "explicit"` without a guide: `An explicit mapping requires a 'guide' object (see the SIA mapping-guide wire format).`
- A typo *inside* an explicit guide - rejected, never silently ignored: `Unknown mapping-guide key 'fieldMapping'. Known keys: autoMatchByMapsTo, fieldMappings, taxonomyDirectives, drops.`

One role note to file away: `mapping.kind: "ai"` raises the requirement on any of these routes to **Editor or Admin** membership, because it sends your schema content to the Anthropic API server-side. `inferred` and `explicit` stay at the route's baseline role and never leave the server.

For the complete ten-route catalog, the record-level routes, and ready-to-paste bodies for every format, see the Schema Transformation API reference in the CoreModels docs.
