← dbt Desk / API
Get a token

Driving dbt Desk over HTTP

Everything the page does, you can do from a script. One endpoint does the work; the rest is authentication, polling and history. The app takes one dbt model — its .sql file and, where you have it, its schema.yml entry — plus a task field naming which of four jobs to run over it, and returns a single JSON envelope.

Base URL

https://api.skillsafe.ai/v1/app-api

Every request carries Authorization: Bearer <token>. The token is scoped to this app when it is minted, so no slug header is ever neededPOST /guest is the one call that names the slug, in its JSON body. Get a token from the token page without opening a developer console.

The task field comes first

dbt Desk is a four-lane app. Every request must name its lane in task, because the four lanes share one system prompt and one model and are routed by that field alone. If it is missing or unrecognised the model picks the closest lane and admits it by setting lane_inferred to true — a fallback, not a feature to build on.

taskWhat it doesSkill behind itartifact.kind
reviewReviews the model the way a staff analytics engineer reviews a colleague's model before it merges, across nine named checks.using-dbt-for-analytics-engineeringsql or none
contractHardens governance: an enforced contract with justified data types, an access modifier, a group and owner, versioning, cross-project refs.working-with-dbt-meshyaml
testsWrites the dbt unit_tests: YAML that pins the model's logic, mocking every upstream input.adding-dbt-unit-testyaml
metricsDefines the MetricFlow semantic model and the metrics that sit on top of it.building-dbt-semantic-layeryaml

The request body is the input object

There is no input wrapper.

/estimate, /run and /run-stream take the input object directly as the JSON body. Wrapping it as {"input": {"task": "review", ...}} is the one mistake that costs an afternoon: the call returns 200, the run is billed, and task never reaches the model, so you get an inferred lane over an empty model. Post the fields at the top level.

FieldTypeRequiredNotes
taskstringyesOne of review, contract, tests, metrics. Nothing else matters as much as this field.
model_sqlstringyesThe dbt model's .sql file — Jinja plus SQL, exactly as it sits in the repo. Send the whole file, config block included.
schema_ymlstringnoThe model's schema.yml entry, if the caller has one. It is where documented columns, tests and existing config come from; without it the documentation and test_coverage checks can only report absence.
model_namestringnoFor example fct_orders. Derived from the YAML name:, a -- file: marker line, or {{ config(alias=...) }} when omitted.
layerstringnostaging, intermediate, marts or unknown. What you declare; the model reports what the reads imply and raises a finding when they disagree.
adapterstringnosnowflake, bigquery, postgres, redshift, databricks, duckdb or other. It decides which dialect and which enforceable constraints come back — omitting it gets you a guess recorded in assumptions.
dbt_versionstringnoFor example 1.10+. Governs which YAML keys the emitted artifact may use.
prescanobjectnoThe deterministic in-browser scan: {resources: [{id, label}], flags: [{id, severity, label, line, occurrences, lane}], other_lane_flags: [{id, lane, label}], stats: {}}. See below — this is the field that makes the answer checkable.
clip_notestringnoSet when the caller truncated the input, so the model writes around the gap instead of inventing what was in it.

The prescan contract

The browser app runs a deterministic scanner over the same material before every run and passes its findings in prescan.flags, each with a stable id. The model must return exactly one coverage_check entry per flag id in prescan.flags — no more, no fewer, no invented ids. That is what lets the free scan hold the paid run accountable, and it is the one part of the answer you can verify programmatically:

sent    = {f["id"] for f in payload["prescan"]["flags"]}
covered = {c["flag_id"] for c in result["coverage_check"]}
assert sent == covered, "unreconciled: %s" % (sent - covered)

A caller who omits prescan gets no reconciliation at all. The lane still runs and the answer is still valid — there is simply nothing to check it against, and coverage_check comes back empty. If you have your own linter, feed its output in as flags and the model will account for every one of them.

Each coverage_check entry carries a status: confirmed (agreed, and finding_id names the finding that carries it), set-aside (real, but it belongs to a different lane — note names which), or superseded (the scanner was wrong here, and note says why with evidence). superseded is the only status that contradicts the scanner.

prescan.resources lists the ref()s, source()s, macros and vars the scanner saw the model reference, as {id, label} entries. They are context, not findings: no coverage_check entry, and the model is instructed not to manufacture a finding just to mention one. prescan.other_lane_flags is the same — flags that belong to one of the other three lanes, passed so the model knows they were seen and can point at the next lane. They get no coverage_check entries either; only prescan.flags does.

The response envelope

Every endpoint returns the same wrapper. Success carries data; failure carries error. Nothing returns a bare value, so a client can branch on the presence of error alone.

{"ok": true,  "data":  { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "...", "details": { ... }}}

Error codes

CodeHTTPWhat it meansWhat to do
UNAUTHORIZED401Missing, malformed or expired token.Mint a new one. Guest tokens expire; a personal token from the token page outlives them.
PAYMENT_REQUIRED402Balance below the run's minimum.Call /estimate and compare min_credits against /me before running. /estimate is free and should never return this.
NOT_FOUND404No such job, record or collection on this release.Check the job_id, and check the collection name is dbtruns. A collection not declared on the release you are calling 404s.
VALIDATION_ERROR400The body did not match the app's shape.Read error.details; it names the offending field. The usual cause is an input wrapper or a missing model_sql.
RATE_LIMITED429Too many requests.Back off and retry with the same Idempotency-Key. Never tight-loop.
INTERNAL500Something broke on our side.Retry once with the same idempotency key, then stop and report it. A held run is released, not charged.

The output contract

The model replies with one JSON object and nothing else. The app strips a code fence if one appears and takes the outermost {...}, so a client that wants to be robust should do the same rather than calling JSON.parse on the raw string and giving up. The parsed object arrives at data.output.output as a string on /jobs/{job_id}, and as the concatenation of the stream deltas on /run-stream.

The envelope below is identical for every lane. Every key is present on every task; arrays are present even when empty. Only body and artifact change between lanes.

FieldTypeNotes
lanestringThe task you sent, echoed exactly.
lane_inferredbooltrue means your task did not arrive or was not recognised and the model guessed. Treat it as a client bug, not a result.
model_namestringThe model's real name, or "unknown". Never invented.
titlestringOne line naming the model and the headline problem.
layerstringstaging | intermediate | marts | unknown — judged from the reads, not just the prefix.
materializationstringview | table | incremental | ephemeral | materialized_view | unknown, read from config, never guessed from the SQL.
posturestringready | hardening-recommended | blocked. blocked means something here would fail or mislead in production today.
verdictstringOne sentence a reviewer could paste into the pull request.
summarystringThree to six sentences: what the model does, what state it is in, what to change first.
assumptionsstring[]Choices the model had to make because a fact was absent.
open_questionsstring[]Questions whose answers would change the advice. Worth surfacing in your UI.
findingsobject[]{id, title, severity, area, column, line, evidence, why, fix, fix_code}. Ids run DD-001, DD-002, ... in presentation order, severity descending. severity is critical | high | medium | low. evidence is verbatim from what you sent. column and line are ""/0 when they do not apply.
coverage_checkobject[]{flag_id, status, finding_id, note}, status one of confirmed | set-aside | superseded. Exactly one entry per prescan.flags id, and none for anything else.
artifactobject{kind, filename, content}, kind one of none | sql | yaml. content is a whole file, never a diff and never a fragment — write it straight into the project.
next_laneobject{lane, reason}. One of the other three task ids, or "". The natural chain is reviewcontracttestsmetrics.
bodyobjectThe lane-specific object. Only your own lane's shape is ever returned; the four shapes are never blended.
{
  "lane": "review",
  "lane_inferred": false,
  "model_name": "fct_orders",
  "title": "fct_orders - grain untested and the incremental filter is missing",
  "layer": "marts",
  "materialization": "incremental",
  "posture": "hardening-recommended",
  "verdict": "One sentence a reviewer could paste into the pull request.",
  "summary": "Three to six sentences.",
  "assumptions": ["stated only when the model had to choose"],
  "open_questions": ["a question whose answer would change the advice"],
  "findings": [
    {"id": "DD-001", "title": "short imperative title", "severity": "critical",
     "area": "incremental", "column": "order_id", "line": 42,
     "evidence": "verbatim from the pasted material",
     "why": "what goes wrong in production, concretely",
     "fix": "what to do, in prose",
     "fix_code": "the corrected SQL or YAML fragment, or \"\""}
  ],
  "coverage_check": [
    {"flag_id": "DM-NO-GRAIN-TEST", "status": "confirmed", "finding_id": "DD-001", "note": ""}
  ],
  "artifact": {"kind": "yaml", "filename": "fct_orders.yml", "content": "version: 2\n..."},
  "next_lane": {"lane": "contract", "reason": "one sentence on why that is the useful next step"},
  "body": { ... }
}

area on a finding is one of lineage, sql, config, incremental, documentation, testing, contract, governance, semantics, portability, performance.

1. A tiny client

A few lines of setup that every later step reuses: the base URL, the bearer token, and a JSON call that raises on the error branch of the envelope. Keep the token out of source control — the samples below use a "YOUR_TOKEN" placeholder, and reading it from the shell at start-up is the usual improvement.

# Every call in this document reuses these three values.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="dbt-desk"
TOKEN="YOUR_TOKEN"        # from https://dbt-desk.skillsafe.ai/tokens.html

post() {  # post <path> <json>
  curl -sS -X POST "$BASE$1" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$2"
}

get() {   # get <path>
  curl -sS "$BASE$1" -H "Authorization: Bearer $TOKEN"
}

2. Get a token

A guest token is minted on demand and is enough for /me and /estimate. Running a lane is metered, so it needs a personal token — sign in at the token page and copy it from there. Every POST /guest mints a new guest identity, so hold one token for the session rather than minting per request. This is the only call that names the slug, and it names it in the JSON body: there is no slug header anywhere in this API.

curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"dbt-desk"}' | tee guest.json
# {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}

TOKEN=$(python3 -c "import json;print(json.load(open('guest.json'))['data']['token'])")

3. Check who you are and what you can spend

GET /me is free and returns the subject type (guest or user) and the credit balance. Compare that balance against the estimate in the next step before running anything — a 402 after submitting is a failure of the client, not of the user.

get /me
# {"ok":true,"data":{"subject_type":"user","credits":48210, ...}}

4. Price the run before you make it

POST /estimate takes the same body as /run, is free, creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled.

hold_credits is a reservation, not a price.

It is priced against the full output cap for the lane — the largest answer the model is allowed to produce. The actual charge is usually far lower, and the unspent remainder of the hold is released, never charged. Present it to a user as "up to", never as "this costs". The hold differs per lane, because the four lanes have different prompts and different output caps, so re-estimate whenever task changes.

# payload.json holds the input object itself - no "input" wrapper.
cat > payload.json <<'JSON'
{
  "task": "review",
  "model_sql": "{{ config(materialized='incremental') }}\nselect ...",
  "schema_yml": "version: 2\nmodels:\n  - name: fct_orders\n",
  "model_name": "fct_orders",
  "layer": "marts",
  "adapter": "snowflake",
  "dbt_version": "1.10+"
}
JSON

curl -sS -X POST "$BASE/estimate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @payload.json
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#                    "hold_credits":4180,"min_credits":460,"sponsor_enabled":false}}

5. Run a lane and poll for the result

POST /run returns a job_id immediately; poll GET /jobs/{job_id} until status is terminal (succeeded, failed or cancelled). The model's JSON arrives as a string at data.output.output.

Always send an idempotency key — either the Idempotency-Key header or an idempotency_key field in the body. dbt Desk derives it from (task, input, attempt): the lane, a hash of the input object, and the attempt number. All three matter. Leave task out and a contract run over a model you already reviewed returns the cached review. Leave attempt out and the automatic re-ask after a malformed reply collides with the reply that was malformed. Get it right and a retried request never bills twice.

# (task, input, attempt) - the lane must be in the key or lane two returns lane one.
KEY="dbt-desk:review:$(shasum -a 256 payload.json | cut -c1-16):1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @payload.json | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['job_id'])")

until curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
  | tee job.json | grep -q '"status":"succeeded"'; do sleep 2; done

python3 -c "import json;print(json.load(open('job.json'))['data']['output']['output'])"

6. Stream it instead, for anything interactive

POST /run-stream is the same call over Server-Sent Events. Deltas arrive as they are generated, which is what the page uses to advance its progress stages. The same idempotency rule applies. Accumulate the text deltas and parse once the stream closes — a partial JSON object is not a JSON object, so do not try to parse mid-stream. The terminal done event carries job_id, charged_credits and truncated; a truncated of true means the model hit the output cap and the JSON will not parse, which is when a client re-asks with a smaller input.

curl -sS -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d @payload.json

# event: delta
# data: {"text":"{\"lane\":\"review\",\"lane_inferred\":false,"}
# event: delta
# data: {"text":"\"model_name\":\"fct_orders\","}
# event: done
# data: {"job_id":"job_...","charged_credits":2914,"truncated":false}

7. Query the run history collection

Signed-in runs are mirrored into the app's declared collection, dbtruns, and POST /collections/dbtruns/query reads them back. The declared fields are uid, title, lane, model_name, layer, materialization, posture, verdict, input_hash, finding_count, critical_count and ran_at, plus an entry object holding the full result.

Three ways to get this wrong, all of which fail quietly.

1. Records nest under doc. Every record comes back as {"record_id": "...", "created_at": "...", "doc": {...}}. Read fields off rec.doc, never flat off recrec.model_name is undefined, not an error, so a client that reads it flat renders a list of blanks and no exception.
2. where takes operator objects. {"lane": {"eq": "review"}}, not {"lane": "review"}. A bare value is not a filter.
3. The sort key is sort. {"field": "ran_at", "dir": "desc"}. order_by is silently ignored: the query still returns 200 and quietly falls back to created_at desc, which is close enough to a correct answer that nobody notices.

post /collections/dbtruns/query '{
  "where": {"lane": {"eq": "review"}},
  "sort": {"field": "ran_at", "dir": "desc"},
  "limit": 20
}'
# {"ok":true,"data":{"records":[
#   {"record_id":"rec_01j...","created_at":"2026-08-21T09:14:03Z",
#    "doc":{"uid":"...","title":"fct_orders - grain untested","lane":"review",
#           "model_name":"fct_orders","layer":"marts","materialization":"incremental",
#           "posture":"hardening-recommended","verdict":"...","input_hash":"...",
#           "finding_count":7,"critical_count":1,"ran_at":"2026-08-21T09:14:03.221Z",
#           "entry":{"result":{...},"meta":{},"input":{...}}}}
# ]}}

8. One worked example per lane

All four lanes take the same model. Only task changes, and only body and artifact change in the answer — the envelope documented above is identical every time. Fields shown as ... follow the shapes above. The model used throughout is this one:

-- models/marts/fct_orders.sql
{{ config(materialized='incremental', unique_key='order_id') }}

with orders as (select * from {{ ref('stg_orders') }})

select
    o.order_id,
    o.customer_id,
    o.status,
    o.amount::number(38,2) as amount,
    o.ordered_at
from orders o
where o.status != 'cancelled'

task: "review" — Review the model

Nine named checks in a fixed order, the grain stated explicitly, the upstreams and output columns the SQL actually has, and the commands you run to verify. This is the only lane that may return no file: artifact.kind is sql when the model is worth rewriting and none when only the YAML needs to change.

Request

{
  "task": "review",
  "model_sql": "{{ config(materialized='incremental', unique_key='order_id') }}\n\nwith orders as (select * from {{ ref('stg_orders') }})\n\nselect o.order_id, ...",
  "schema_yml": "version: 2\nmodels:\n  - name: fct_orders\n    description: One row per order.\n",
  "model_name": "fct_orders",
  "layer": "marts",
  "adapter": "snowflake",
  "dbt_version": "1.10+",
  "prescan": {
    "resources": [
      {"id": "REF-1", "label": "ref('stg_orders') (line 4)"}
    ],
    "flags": [
      {"id": "DM-INCREMENTAL-NO-FILTER", "severity": "high",
       "label": "incremental model with no is_incremental() filter",
       "line": 1, "occurrences": 1, "lane": "review"},
      {"id": "DM-SELECT-STAR", "severity": "medium",
       "label": "select * from a ref() hides the output columns",
       "line": 4, "occurrences": 1, "lane": "review"}
    ],
    "other_lane_flags": [
      {"id": "DM-NO-CONTRACT", "lane": "contract", "label": "no enforced contract on a marts model"}
    ],
    "stats": {"lines": 12, "refs": 1, "sources": 0}
  }
}

Response data.output.output, parsed

{
  "lane": "review",
  "lane_inferred": false,
  "model_name": "fct_orders",
  "title": "fct_orders - an incremental model that rebuilds the world every run",
  "layer": "marts",
  "materialization": "incremental",
  "posture": "blocked",
  "verdict": "The model is configured incremental but has no is_incremental() branch, so every run scans all of stg_orders and reprocesses it.",
  "summary": "One row per order, built on stg_orders. ...",
  "assumptions": ["amount::number(38,2) reads as Snowflake, matching adapter."],
  "open_questions": ["Is ordered_at ever updated after the order is placed?"],
  "findings": [
    {"id": "DD-001", "title": "Add an is_incremental() filter on ordered_at",
     "severity": "high", "area": "incremental", "column": "ordered_at", "line": 1,
     "evidence": "{{ config(materialized='incremental', unique_key='order_id') }}",
     "why": "Without the branch the model is a full rebuild wearing an incremental config: same cost, plus a merge.",
     "fix": "Wrap the where clause in {% if is_incremental() %} against the max ordered_at already in this.",
     "fix_code": "{% if is_incremental() %}\n  and o.ordered_at > (select max(ordered_at) from {{ this }})\n{% endif %}"},
    {"id": "DD-002", "title": "Name the columns instead of select *",
     "severity": "medium", "area": "sql", "column": "", "line": 4,
     "evidence": "with orders as (select * from {{ ref('stg_orders') }})",
     "why": "A column added upstream lands in this mart unannounced and unowned.",
     "fix": "Select the columns this model needs.", "fix_code": ""}
  ],
  "coverage_check": [
    {"flag_id": "DM-INCREMENTAL-NO-FILTER", "status": "confirmed", "finding_id": "DD-001", "note": ""},
    {"flag_id": "DM-SELECT-STAR", "status": "confirmed", "finding_id": "DD-002", "note": ""}
  ],
  "artifact": {"kind": "sql", "filename": "fct_orders.sql", "content": "{{ config(\n    materialized='incremental',\n..."},
  "next_lane": {"lane": "contract", "reason": "It is a marts model other teams will read; give it a contract before they do."},
  "body": {
    "grain": {"stated": "one row per order", "evidence": "unique_key='order_id' in config", "confidence": "high"},
    "checks": [
      {"id": "lineage", "name": "Every upstream goes through ref() or source()", "status": "pass", "note": "One ref, no hardcoded relation."},
      {"id": "layering", "name": "The layer matches what the model reads", "status": "pass", "note": "A marts model reading a staging model."},
      {"id": "grain", "name": "The grain is stated and testable", "status": "warn", "note": "Declared in config, not tested in YAML."},
      {"id": "sql_correctness", "name": "The SQL produces the stated grain", "status": "warn", "note": "select * hides whether stg_orders is unique on order_id."},
      {"id": "materialization", "name": "The materialization suits the model", "status": "fail", "note": "See DD-001."},
      {"id": "incremental_safety", "name": "The incremental branch is safe", "status": "fail", "note": "There is no branch at all."},
      {"id": "portability", "name": "No adapter-specific syntax without cause", "status": "warn", "note": "::number is Snowflake-only; cast() is portable."},
      {"id": "documentation", "name": "The model and its columns are documented", "status": "warn", "note": "The model has a description; the columns have none."},
      {"id": "test_coverage", "name": "The grain and the critical columns are tested", "status": "fail", "note": "No unique or not_null on order_id."}
    ],
    "layering": {"declared_layer": "marts", "implied_layer": "marts", "agrees": true, "note": ""},
    "upstream": [
      {"kind": "ref", "name": "stg_orders", "project": "", "role": "the order spine",
       "used_correctly": true, "note": ""}
    ],
    "output_columns": [
      {"name": "order_id", "expr": "o.order_id", "documented": false, "note": "the grain"},
      {"name": "amount", "expr": "o.amount::number(38,2)", "documented": false, "note": "cast is adapter-specific"}
    ],
    "rewrite": {"offered": true, "what_changed": ["Added the is_incremental() branch", "Named the columns"], "risk": "The first run after this change is still a full build."},
    "verify_commands": [
      "dbt build --select fct_orders --empty",
      "dbt test --select fct_orders",
      "dbt show --select fct_orders --limit 5"
    ]
  }
}

task: "contract" — Emit the governance YAML

Turns the model into one another team can safely depend on: an enforced contract with a justified data_type per column, an access modifier, a group and owner, a versioning decision, and the dependencies.yml wiring for cross-project refs. Always emits artifact.kind: "yaml" — the complete governance block, ready to paste. Note that deprecation_date is deliberately left as a placeholder: it is the user's decision, never the model's.

Request

{
  "task": "contract",
  "model_sql": "{{ config(materialized='incremental', unique_key='order_id') }}\n...",
  "schema_yml": "version: 2\nmodels:\n  - name: fct_orders\n",
  "model_name": "fct_orders",
  "layer": "marts",
  "adapter": "snowflake",
  "dbt_version": "1.10+"
}

Response data.output.output, parsed

{
  "lane": "contract",
  "lane_inferred": false,
  "model_name": "fct_orders",
  "title": "fct_orders - contractable today, but two of five types are placeholders",
  "layer": "marts",
  "materialization": "incremental",
  "posture": "hardening-recommended",
  "verdict": "Enforce the contract on the three columns the SQL types explicitly and document the other two before promoting it to public.",
  "summary": "...",
  "assumptions": ["..."],
  "open_questions": ["What type is status in stg_orders? Nothing in the pasted model says."],
  "findings": [ ... ],
  "coverage_check": [ ... ],
  "artifact": {
    "kind": "yaml",
    "filename": "fct_orders.yml",
    "content": "version: 2\n\ngroups:\n  - name: finance\n    owner:\n      name: Finance Analytics\n\nmodels:\n  - name: fct_orders\n    access: public\n    group: finance\n    config:\n      contract:\n        enforced: true\n..."
  },
  "next_lane": {"lane": "tests", "reason": "An enforced contract is worth pinning with unit tests."},
  "body": {
    "contract": {"enforced": true, "reason": "Other teams will select from this mart.",
                 "risks": ["A type change upstream now fails the build instead of drifting silently."]},
    "columns": [
      {"name": "order_id", "data_type": "varchar", "constraints": ["not_null"],
       "description": "The order's natural key. One row per value.",
       "inferred_from": "unique_key='order_id'; no cast, so the adapter's string type"},
      {"name": "amount", "data_type": "number(38,2)", "constraints": [],
       "description": "Order total in the order's currency.",
       "inferred_from": "the explicit ::number(38,2) cast on line 9"},
      {"name": "status", "data_type": "varchar", "constraints": [],
       "description": "Order status, excluding cancelled.",
       "inferred_from": "no evidence in the pasted model - placeholder"}
    ],
    "access": {"value": "public", "reason": "It is a mart other groups consume.", "consumers": []},
    "group": {"name": "finance", "owner_name": "Finance Analytics", "owner_email": "",
              "reason": "The model is a finance fact table."},
    "versioning": {
      "needed": true, "reason": "Dropping select * will change the column set.",
      "latest_version": 2, "defined_in": "the versions block in the artifact",
      "deprecation": {"version": 1, "deprecation_date": "YYYY-MM-DD (you choose)",
                      "note": "Announce, then give consumers one full quarter."}
    },
    "cross_project": {"refs": [], "dependencies_yml_needed": false, "dependencies_yml": ""},
    "breaking_changes": [
      {"change": "Enforcing a contract on status as varchar",
       "breaks": "Any consumer relying on it being an enum",
       "mitigation": "Confirm the upstream type before enforcing; it is a placeholder here."}
    ]
  }
}

task: "tests" — Write the dbt unit tests

dbt's unit_tests: YAML — fixed input rows and the exact rows the model must return. This is not data testing: not_null and unique come back separately in data_tests. Every upstream ref() and source() must be mocked in every test, because dbt fails to build a unit test with an unmocked input — input_coverage is where that is proven. Always emits artifact.kind: "yaml".

Request

{
  "task": "tests",
  "model_sql": "{{ config(materialized='incremental', unique_key='order_id') }}\n...",
  "model_name": "fct_orders",
  "layer": "marts",
  "adapter": "snowflake",
  "prescan": {
    "resources": [{"id": "REF-1", "label": "ref('stg_orders') (line 4)"}],
    "flags": [
      {"id": "DM-NO-UNIT-TESTS", "severity": "high",
       "label": "no unit_tests block for a model with filter logic",
       "line": 0, "occurrences": 1, "lane": "tests"}
    ],
    "other_lane_flags": [],
    "stats": {"lines": 12, "refs": 1, "sources": 0}
  }
}

Response data.output.output, parsed

{
  "lane": "tests",
  "lane_inferred": false,
  "model_name": "fct_orders",
  "title": "fct_orders - three unit tests pin the filter, the cast and the incremental branch",
  "layer": "marts",
  "materialization": "incremental",
  "posture": "hardening-recommended",
  "verdict": "The status filter is the one piece of business logic here and nothing pins it; three fixed-row tests do.",
  "summary": "...",
  "assumptions": ["..."],
  "open_questions": ["..."],
  "findings": [ ... ],
  "coverage_check": [
    {"flag_id": "DM-NO-UNIT-TESTS", "status": "confirmed", "finding_id": "DD-001", "note": ""}
  ],
  "artifact": {
    "kind": "yaml",
    "filename": "fct_orders_unit_tests.yml",
    "content": "version: 2\n\nunit_tests:\n  - name: test_fct_orders_excludes_cancelled\n    model: fct_orders\n    given:\n      - input: ref('stg_orders')\n        rows:\n..."
  },
  "next_lane": {"lane": "metrics", "reason": "amount and ordered_at are a measure and a time dimension waiting to be declared."},
  "body": {
    "strategy": "Pin the status filter with a cancelled row that must not survive, and pin the cast with a fractional amount. Keep two rows per input so each assertion is unambiguous.",
    "unit_tests": [
      {
        "name": "test_fct_orders_excludes_cancelled",
        "covers": "the where o.status != 'cancelled' filter",
        "why_this_case": "Catches the day someone flips != to = or drops the clause.",
        "given": [
          {"input": "ref('stg_orders')", "format": "dict",
           "rows": ["{order_id: 1, customer_id: 7, status: 'placed', amount: 10.00, ordered_at: '2026-01-01'}",
                    "{order_id: 2, customer_id: 7, status: 'cancelled', amount: 99.00, ordered_at: '2026-01-01'}"]}
        ],
        "expect_rows": ["{order_id: 1, customer_id: 7, status: 'placed', amount: 10.00, ordered_at: '2026-01-01'}"],
        "overrides": {"macros": [], "vars": [], "env_vars": []},
        "notes": ""
      }
    ],
    "input_coverage": [
      {"input": "ref('stg_orders')", "mocked_in": ["test_fct_orders_excludes_cancelled"], "covered": true}
    ],
    "data_tests": [
      {"column": "order_id", "tests": ["unique", "not_null"], "why": "this is the grain"}
    ],
    "coverage_gaps": ["The is_incremental() branch cannot be unit tested until it exists."],
    "fixture_notes": ["Move rows to tests/fixtures/stg_orders.csv and use format: csv once past four rows."]
  }
}

task: "metrics" — Define the semantic model and metrics

MetricFlow YAML: entities, dimensions and measures taken from the columns the model actually returns, and metrics taken from what a stakeholder would ask of them. Every measure needs a time dimension to aggregate over; a model with no time column says so in unsupported rather than inventing a date. Always emits artifact.kind: "yaml".

Request

{
  "task": "metrics",
  "model_sql": "{{ config(materialized='incremental', unique_key='order_id') }}\n...",
  "schema_yml": "version: 2\nmodels:\n  - name: fct_orders\n",
  "model_name": "fct_orders",
  "layer": "marts",
  "adapter": "snowflake",
  "dbt_version": "1.10+"
}

Response data.output.output, parsed

{
  "lane": "metrics",
  "lane_inferred": false,
  "model_name": "fct_orders",
  "title": "fct_orders - one semantic model, three honest metrics, no invented columns",
  "layer": "marts",
  "materialization": "incremental",
  "posture": "ready",
  "verdict": "amount over ordered_at supports revenue, order count and average order value; anything about customers needs a dimension this model does not carry.",
  "summary": "...",
  "assumptions": ["ordered_at is the event time, not a load timestamp."],
  "open_questions": ["Is amount gross or net of discounts? The column name does not say."],
  "findings": [ ... ],
  "coverage_check": [ ... ],
  "artifact": {
    "kind": "yaml",
    "filename": "fct_orders_semantic.yml",
    "content": "version: 2\n\nsemantic_models:\n  - name: orders\n    model: ref('fct_orders')\n..."
  },
  "next_lane": {"lane": "review", "reason": "The measures assume a grain the model does not test."},
  "body": {
    "semantic_model": {
      "name": "orders",
      "model": "ref('fct_orders')",
      "description": "One row per order, cancelled orders excluded.",
      "defaults": {"agg_time_dimension": "ordered_at"},
      "entities": [
        {"name": "order", "type": "primary", "expr": "order_id", "why": "the model's grain"},
        {"name": "customer", "type": "foreign", "expr": "customer_id", "why": "joins to a customer semantic model if one exists"}
      ],
      "dimensions": [
        {"name": "ordered_at", "type": "time", "type_params": {"time_granularity": "day"}, "expr": "", "why": "the only time column"},
        {"name": "status", "type": "categorical", "type_params": {}, "expr": "", "why": "the surviving statuses after the filter"}
      ],
      "measures": [
        {"name": "order_total", "agg": "sum", "expr": "amount", "agg_time_dimension": "ordered_at",
         "description": "Sum of order amounts.", "create_metric": false},
        {"name": "order_count", "agg": "count_distinct", "expr": "order_id", "agg_time_dimension": "ordered_at",
         "description": "Distinct orders.", "create_metric": false}
      ]
    },
    "metrics": [
      {"name": "revenue", "label": "Revenue", "type": "simple",
       "type_params_summary": "measure: order_total", "filter": "",
       "why": "the question every stakeholder asks first"},
      {"name": "average_order_value", "label": "Average order value", "type": "ratio",
       "type_params_summary": "numerator: revenue, denominator: orders", "filter": "",
       "why": "both sides exist as real measures on this model"}
    ],
    "time_spine": {"needed": false, "reason": "No cumulative metric is defined.", "model_hint": ""},
    "granularity": {"finest": "day", "reason": "ordered_at is declared at day granularity."},
    "unsupported": [
      "Revenue by customer region - this model carries customer_id but no region column."
    ],
    "validation_notes": [
      "dbt parse would confirm every expr resolves to a real column; select * upstream means this could not be verified from the paste."
    ]
  }
}

Notes that will save you a support round trip