CoreModelsCoreModels
dbt · API

Four Verbs and a Fan-Out: The dbt Integration over HTTP

The dbt connector declares Import, Audit, and Generate, and four routes carry the loop that matters: push artifacts in, check them against governed meaning, publish contracts back out, and ask what the last import knew. This is the route-by-route reference — role gating, request bodies field by field, exact response shapes. Throughout, `https://coremodels.example.com` stands in for your deployment, `$TOKEN` for your bearer token, and `$PROJECT_ID` for the 32-character hex id of the governing project. The vendor key is `dbt`. One property runs through all four: no credentials — artifacts in, artifacts out. CoreModels never connects to your warehouse, never runs dbt, never reads a dbt platform API, and never writes into your repository; Generate hands back files, and your own PR flow lands them.

The dbt connector declares Import, Audit, and Generate, and four routes carry the loop that matters: push artifacts in, check them against governed meaning, publish contracts back out, and ask what the last import knew. This is the route-by-route reference — role gating, request bodies field by field, exact response shapes. Throughout, https://coremodels.example.com stands in for your deployment, $TOKEN for your bearer token, and $PROJECT_ID for the 32-character hex id of the governing project. The vendor key is dbt. One property runs through all four: no credentials — artifacts in, artifacts out. CoreModels never connects to your warehouse, never runs dbt, never reads a dbt platform API, and never writes into your repository; Generate hands back files, and your own PR flow lands them.

RouteMethodRoleWrites?
graph/integrations/dbt/import/{projectId}POSTAdminadditive graph writes
graph/integrations/dbt/audit/{projectId}POSTViewerno (history opt-in)
graph/integrations/dbt/generate/{projectId}POSTViewerno
graph/integrations/dbt/status/{projectId}GETViewerno

Import — Admin

POST https://coremodels.example.com/graph/integrations/dbt/import/$PROJECT_ID
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "manifest": "<target/manifest.json>", "catalog": "<target/catalog.json>" },
  "spaces": [] }

artifacts maps artifact name to raw file content: manifest is required (schema v10 through v12), catalog and semantic_manifest are optional. spaces is an optional array of space ids — empty or omitted means the project's main space. Models, seeds, snapshots, and sources become Types; columns become Elements; accepted_values tests become Taxonomies with controlled lists; relationships tests become references; parent_map becomes Depends On lineage; semantic models and exposures become Components. The response is a counts object — datasetsAdded, datasetsSkippedExisting, fieldsAdded, lineageEdgesAdded, lineageEdgesSkipped, nodesEnriched, resourcesWritten, resourceEdgesAdded, snapshotStored — plus the lossiness and errors channels. Import is additive: a re-import adds what is new and never mutates governed nodes, so the descriptions, vocabularies, and ontology bindings a human added on top survive every refresh. What changed surfaces through the audit instead.

Audit — Viewer

POST https://coremodels.example.com/graph/integrations/dbt/audit/$PROJECT_ID

{ "artifacts": { "manifest": "<fresh manifest.json>" }, "recordHistory": true }

The same body as import, plus recordHistory (default false — the audit verb stays strictly read-only unless asked). The response carries errorCount, warningCount, infoCount, a codes map of per-code totals, driftedObjects, the artifact fingerprint, structured findings, a markdown report ready to paste into a pull request, historyRecorded, and lossiness. The dbt-specific finding codes are contract-not-enforced, contract-column-missing-type, key-column-untested, and source-no-freshness; the shared drift and coverage layer adds field-type-drift, enum-narrowed, enum-widened, enum-constraint-removed, and dataset-removed. The CI contract is errorCount > 0 ⇒ fail the build.

Generate — Viewer

The publication verb: governed meaning becomes dbt model property files with enforced contracts, one per model, colocated beside that model's own .sql. Four optional body fields:

FieldTypeMeaning
typeNamesarray of stringRestrict generation to the named models — matched against the dbt model name or the governed type's label. Empty or omitted means everything eligible.
targetVersionstringThe dialect to emit for. Omitted means modern: the data_tests: key. "1.7", or any 1.x below 1.8, emits the legacy tests: key and records a lossiness note saying dbt 1.8 and newer read data_tests:. An unparseable version stays modern and records a note. Enforced contracts require dbt 1.5 or newer either way.
spacesarray of stringTarget space ids; empty means the project's main space.
extramap of string to stringOpen vendor options — for dbt, layout and iriInDescription. Both values are strings.

extra.layout decides how the emitted files land in your repo:

layoutArtifact namesFiles returned
model (default)models/staging/stg_orders.yml — beside the model's own .sqlone per eligible model
foldermodels/staging/_coremodels__models.ymlone per model directory
singlemodels/coremodels_contracts.ymlone, repo-wide (back-compat)

Colocation uses the model's original_file_path, captured at import into the dbt metadata mixin under the key dbt.path; a model imported before that capture, or a governed-first type with no dbt origin, falls back to models/<model>.yml. An unrecognized layout does not fail the call — it falls back to model and records a SemanticNarrowing at path layout naming the valid values.

extra.iriInDescription defaults to on. Governed mapsTo bindings — OLS-bound ontology terms, or any other standard — always ride out structurally under meta.coremodels.maps_to, on the model and on each column. With iriInDescription on they are also appended to the column description as [standard: uri], because description is the only slot dbt's persist_docs carries into the warehouse column comment. Send "false" to suppress the description form; the structural form always rides.

curl -sS -X POST "https://coremodels.example.com/graph/integrations/dbt/generate/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "typeNames": ["orders","customers"], "targetVersion": "1.9", "extra": { "layout": "model" } }'

Reading the response

{
  "success": true,
  "artifacts": [
    { "name": "models/marts/orders.yml",    "kind": "yaml", "content": "# Generated by CoreModels …" },
    { "name": "models/marts/customers.yml", "kind": "yaml", "content": "# Generated by CoreModels …" }
  ],
  "lossiness": [
    { "kind": "StructuralDrop", "path": "orders",
      "explanation": "This model already has properties in 'models/marts/_marts__models.yml'. Remove its block there, or dbt will report a duplicate patch for the generated 'models/marts/orders.yml'." },
    { "kind": "SemanticNarrowing", "path": "orders.parent_order_id",
      "explanation": "The reference's target field is not recorded and the target has no unique-tested column; relationships test omitted." }
  ],
  "errors": []
}

artifacts[].name is a repo-relative path, not a label — it is where the file belongs, and a writer script should create directories from it. Expect many artifacts: under the default layout a 400-model project returns 400 of them, each with kind: "yaml". That is the point of per-model files — one repo-wide file is unusable past a few dozen models — but the response grows with the estate, so use typeNames when you want a subset.

lossiness is the list of things the generator refused to guess at. Each record is { kind, path, explanation }, where kind is one of StructuralDrop, SemanticNarrowing, TypeApproximation, or ConstraintRelaxation, and path locates the model or model.column involved. Three are worth routing to a human: an ephemeral model skipped, a model with no columns skipped (dbt rejects an enforced contract with none), and the duplicate-patch record above. That last one is deliberate — dbt refuses two property blocks for one model, and the file already holding that model's patch_path may carry properties for models CoreModels does not govern, so the generator names the file whose block must be removed rather than overwriting a file it does not own. Decoding one content string shows what your PR will contain:

# Generated by CoreModels — governed model contracts.
# Meaning changes belong in CoreModels; regenerate this file rather than editing it.
version: 2

models:
  - name: orders
    description: "One row per placed order."
    config:
      contract:
        enforced: true
      materialized: table
    columns:
      - name: order_id
        data_type: integer
        constraints:
          - type: not_null
        data_tests:
          - unique
          - not_null
      - name: status
        description: "Lifecycle status. [schema.org: https://schema.org/orderStatus]"
        data_type: "varchar(20)"
        data_tests:
          - accepted_values:
              values: ["placed", "shipped"]
        meta:
          coremodels:
            maps_to:
              - standard: "schema.org"
                uri: "https://schema.org/orderStatus"
            vocabulary: "orders status values"

data_type is on every column because an enforced contract requires it — the vendor-native type recorded at import, otherwise a warehouse-neutral fallback. Quoting is conservative: plain tokens stay bare, anything else is double-quoted, which is why "varchar(20)" is. accepted_values comes from a governed taxonomy, so one vocabulary generates the same list in every model using that field, and meta.coremodels.vocabulary names it. Governed references emit a relationships test with to: ref('customers') and field: customer_id; when the target field is unknown the test is omitted and a lossiness record says why, because a guessed field would generate a test that fails against a real warehouse.

When Generate fails

Generate never emits an empty file. If no model survives selection, the call fails and says so:

{ "success": false, "payload": null,
  "lossiness": [ { "kind": "StructuralDrop", "path": "int_order_items",
                   "explanation": "Ephemeral models cannot carry dbt contracts; skipped." } ],
  "errors": [ { "path": "generate", "message": "No eligible models found to generate contracts for." } ] }

The lossiness array survives the failure, so the skip records explain the empty selection instead of leaving you to guess. Two other shapes are worth recognizing: an import or audit body with no artifacts returns errors: [{ "path": "artifacts", "message": "Body must include 'artifacts': { \"<name>\": \"<content>\" } (e.g. manifest for dbt)." }], and a mistyped vendor key returns success: false with Unknown vendor '<v>'. Registered: <comma-joined keys>. on every route.

Status — Viewer

GET graph/integrations/dbt/status/{projectId} returns { success, vendor, imported, state, governedDatasets }. imported says whether an import has ever run; state is the last-import record — projectName, importedAt, toolVersion, artifactVersion, generatedAt, sourceFingerprint, a human-readable counts summary, and facts carrying what the parser noticed, metric and exposure and semantic-model counts among them; governedDatasets is how many imported dbt objects currently resolve to governed Types. Comparing sourceFingerprint against a fresh manifest's is the cheapest way to know whether anything moved.

Beside these four, the generic integration surface carries GET graph/integrations/vendors for discovery, reaudit/history/badge for the audit trail, POST graph/integrations/reconcile/{projectId} (Admin) to link a dbt model and the warehouse table it materializes as one entity, the sync-plan routes, and the API-key surface at POST v1/{projectId}/integrations/dbt/audit that a CI gate calls. Note what none of them are: there is no schema diff between two points in time, no notification when someone edits meaning in CoreModels, and no UI for any of this — HTTP and MCP are the surfaces today. A worked walkthrough of these calls, from first manifest to first generated contract file, lives in the dbt quickstart that ships with the CoreModels integration docs.

More on dbt

Why this matters: the dbt guides on coremodels.io. This page is also available as Markdown.