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.
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 needed —
POST /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.
task | What it does | Skill behind it | artifact.kind |
|---|---|---|---|
| review | Reviews the model the way a staff analytics engineer reviews a colleague's model before it merges, across nine named checks. | using-dbt-for-analytics-engineering | sql or none |
| contract | Hardens governance: an enforced contract with justified data types, an access modifier, a group and owner, versioning, cross-project refs. | working-with-dbt-mesh | yaml |
| tests | Writes the dbt unit_tests: YAML that pins the model's logic, mocking every upstream input. | adding-dbt-unit-test | yaml |
| metrics | Defines the MetricFlow semantic model and the metrics that sit on top of it. | building-dbt-semantic-layer | yaml |
The request body is the input object
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.
| Field | Type | Required | Notes |
|---|---|---|---|
| task | string | yes | One of review, contract, tests, metrics. Nothing else matters as much as this field. |
| model_sql | string | yes | The dbt model's .sql file — Jinja plus SQL, exactly as it sits in the repo. Send the whole file, config block included. |
| schema_yml | string | no | The 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_name | string | no | For example fct_orders. Derived from the YAML name:, a -- file: marker line, or {{ config(alias=...) }} when omitted. |
| layer | string | no | staging, intermediate, marts or unknown. What you declare; the model reports what the reads imply and raises a finding when they disagree. |
| adapter | string | no | snowflake, 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_version | string | no | For example 1.10+. Governs which YAML keys the emitted artifact may use. |
| prescan | object | no | The 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_note | string | no | Set 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
| Code | HTTP | What it means | What to do |
|---|---|---|---|
| UNAUTHORIZED | 401 | Missing, malformed or expired token. | Mint a new one. Guest tokens expire; a personal token from the token page outlives them. |
| PAYMENT_REQUIRED | 402 | Balance below the run's minimum. | Call /estimate and compare min_credits against /me before running. /estimate is free and should never return this. |
| NOT_FOUND | 404 | No 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_ERROR | 400 | The 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_LIMITED | 429 | Too many requests. | Back off and retry with the same Idempotency-Key. Never tight-loop. |
| INTERNAL | 500 | Something 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.
| Field | Type | Notes |
|---|---|---|
| lane | string | The task you sent, echoed exactly. |
| lane_inferred | bool | true means your task did not arrive or was not recognised and the model guessed. Treat it as a client bug, not a result. |
| model_name | string | The model's real name, or "unknown". Never invented. |
| title | string | One line naming the model and the headline problem. |
| layer | string | staging | intermediate | marts | unknown — judged from the reads, not just the prefix. |
| materialization | string | view | table | incremental | ephemeral | materialized_view | unknown, read from config, never guessed from the SQL. |
| posture | string | ready | hardening-recommended | blocked. blocked means something here would fail or mislead in production today. |
| verdict | string | One sentence a reviewer could paste into the pull request. |
| summary | string | Three to six sentences: what the model does, what state it is in, what to change first. |
| assumptions | string[] | Choices the model had to make because a fact was absent. |
| open_questions | string[] | Questions whose answers would change the advice. Worth surfacing in your UI. |
| findings | object[] | {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_check | object[] | {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. |
| artifact | object | {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_lane | object | {lane, reason}. One of the other three task ids, or "". The natural chain is review → contract → tests → metrics. |
| body | object | The 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"
}
import json
import urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "dbt-desk"
TOKEN = "YOUR_TOKEN" # from https://dbt-desk.skillsafe.ai/tokens.html
def call(path, payload=None, method="POST", headers=None):
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=body, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req) as resp:
envelope = json.loads(resp.read())
if not envelope.get("ok"):
err = envelope["error"]
raise RuntimeError(err["code"] + ": " + err["message"])
return envelope["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "dbt-desk";
const TOKEN = "YOUR_TOKEN"; // from https://dbt-desk.skillsafe.ai/tokens.html
async function call(path, payload, method = "POST", extra = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...extra
},
body: payload === undefined ? undefined : JSON.stringify(payload)
});
const envelope = await res.json();
if (!envelope.ok) {
throw new Error(`${envelope.error.code}: ${envelope.error.message}`);
}
return envelope.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "dbt-desk"
)
// Paste the token, or export DBT_DESK_TOKEN before running.
var token = tokenOrDefault()
func tokenOrDefault() string {
if v := os.Getenv("DBT_DESK_TOKEN"); v != "" {
return v
}
return "YOUR_TOKEN"
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, payload any, extra map[string]string) (json.RawMessage, error) {
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class DbtDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "dbt-desk";
static final String TOKEN = "YOUR_TOKEN"; // from https://dbt-desk.skillsafe.ai/tokens.html
static final HttpClient CLIENT = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
HttpRequest req = (jsonBody == null ? b.GET()
: b.POST(HttpRequest.BodyPublishers.ofString(jsonBody))).build();
HttpResponse<String> res = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException("HTTP " + res.statusCode() + ": " + res.body());
}
return res.body(); // {"ok":true,"data":{...}}
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "dbt-desk"
TOKEN = ENV["DBT_DESK_TOKEN"] || "YOUR_TOKEN"
def call(path, payload = nil, extra = {})
uri = URI(BASE + path)
req = payload.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v }
req.body = JSON.dump(payload) unless payload.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
envelope = JSON.parse(res.body)
raise "#{envelope['error']['code']}: #{envelope['error']['message']}" unless envelope["ok"]
envelope["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "dbt-desk";
define("TOKEN", getenv("DBT_DESK_TOKEN") ?: "YOUR_TOKEN");
function call(string $path, ?array $payload = null, array $extra = []): array {
$headers = array_merge([
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
], $extra);
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => $payload !== null,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload === null ? null : json_encode($payload),
]);
$envelope = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($envelope["ok"])) {
throw new RuntimeException($envelope["error"]["code"] . ": " . $envelope["error"]["message"]);
}
return $envelope["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public static class DbtDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "dbt-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("DBT_DESK_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Client = new HttpClient();
public static async Task<JsonElement> CallAsync(
string path, object payload = null, HttpMethod method = null)
{
var req = new HttpRequestMessage(method ?? HttpMethod.Post, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (payload is not null)
{
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
}
var res = await Client.SendAsync(req);
var envelope = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!envelope.GetProperty("ok").GetBoolean())
{
var err = envelope.GetProperty("error");
throw new Exception(
err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
}
return envelope.GetProperty("data");
}
}
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'])")
body = json.dumps({"slug": SLUG}).encode()
req = urllib.request.Request(BASE + "/guest", data=body, method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as resp:
guest = json.loads(resp.read())["data"]
TOKEN = guest["token"] # reuse this for the whole session
print(guest["subject_type"]) # "guest"
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG }) // the only call that names the slug
});
const guest = (await res.json()).data;
const token = guest.token; // reuse for the whole session
console.log(guest.subject_type); // "guest"
guestBody := bytes.NewReader([]byte(`{"slug":"dbt-desk"}`))
req, _ := http.NewRequest("POST", base+"/guest", guestBody)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
SubjectType string `json:"subject_type"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
fmt.Println(env.Data.SubjectType) // "guest"
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"dbt-desk\"}"))
.build();
HttpResponse<String> res = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
// {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
uri = URI(BASE + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => SLUG })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
guest = JSON.parse(res.body)["data"]
token = guest["token"] # reuse for the whole session
puts guest["subject_type"] # "guest"
<?php
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => SLUG]),
]);
$guest = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
$token = $guest["token"]; // reuse for the whole session
echo $guest["subject_type"]; // "guest"
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/guest");
req.Content = new StringContent("{\"slug\":\"dbt-desk\"}", Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req);
var guest = JsonDocument.Parse(await res.Content.ReadAsStringAsync())
.RootElement.GetProperty("data");
var token = guest.GetProperty("token").GetString(); // reuse for the session
Console.WriteLine(guest.GetProperty("subject_type")); // "guest"
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, ...}}
me = call("/me", method="GET")
print(me["subject_type"], me["credits"])
const me = await call("/me", undefined, "GET");
console.log(me.subject_type, me.credits);
data, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Println(me.SubjectType, me.Credits)
String me = call("/me", null); // null body means GET
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","credits":48210}}
me = call("/me") # no payload, so the helper issues a GET
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("/me"); // no payload, so the helper issues a GET
echo $me["subject_type"] . " " . $me["credits"] . "\n";
var me = await CallAsync("/me", null, HttpMethod.Get);
Console.WriteLine(me.GetProperty("subject_type") + " credits=" + me.GetProperty("credits"));
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}}
payload = {
"task": "review",
"model_sql": open("models/marts/fct_orders.sql").read(),
"schema_yml": open("models/marts/fct_orders.yml").read(),
"model_name": "fct_orders",
"layer": "marts",
"adapter": "snowflake",
"dbt_version": "1.10+",
}
est = call("/estimate", payload) # free, creates no job
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
if me["credits"] < est["min_credits"]:
raise SystemExit("short by %d credits" % (est["min_credits"] - me["credits"]))
import { readFileSync } from "node:fs";
const payload = {
task: "review",
model_sql: readFileSync("models/marts/fct_orders.sql", "utf8"),
schema_yml: readFileSync("models/marts/fct_orders.yml", "utf8"),
model_name: "fct_orders",
layer: "marts",
adapter: "snowflake",
dbt_version: "1.10+"
};
const est = await call("/estimate", payload); // free, creates no job
console.log(est.model, est.hold_credits, est.min_credits, est.sponsor_enabled);
if (me.credits < est.min_credits) {
throw new Error(`short by ${est.min_credits - me.credits} credits`);
}
sql, _ := os.ReadFile("models/marts/fct_orders.sql")
yml, _ := os.ReadFile("models/marts/fct_orders.yml")
payload := map[string]any{
"task": "review",
"model_sql": string(sql),
"schema_yml": string(yml),
"model_name": "fct_orders",
"layer": "marts",
"adapter": "snowflake",
"dbt_version": "1.10+",
}
data, err := call("POST", "/estimate", payload, nil)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(data, &est)
fmt.Println(est.Model, est.HoldCredits, est.MinCredits)
String sql = Files.readString(Path.of("models/marts/fct_orders.sql"));
String yml = Files.readString(Path.of("models/marts/fct_orders.yml"));
// The body is the input object itself. Build it with a real JSON library.
String payload = JsonUtil.object(
"task", "review",
"model_sql", sql,
"schema_yml", yml,
"model_name", "fct_orders",
"layer", "marts",
"adapter", "snowflake",
"dbt_version", "1.10+");
String estimate = call("/estimate", payload);
System.out.println(estimate);
// {"ok":true,"data":{"model":"gpt-5.6-terra","hold_credits":4180,"min_credits":460, ...}}
payload = {
"task" => "review",
"model_sql" => File.read("models/marts/fct_orders.sql"),
"schema_yml" => File.read("models/marts/fct_orders.yml"),
"model_name" => "fct_orders",
"layer" => "marts",
"adapter" => "snowflake",
"dbt_version" => "1.10+"
}
est = call("/estimate", payload)
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
abort "short by #{est['min_credits'] - me['credits']}" if me["credits"] < est["min_credits"]
<?php
$payload = [
"task" => "review",
"model_sql" => file_get_contents("models/marts/fct_orders.sql"),
"schema_yml" => file_get_contents("models/marts/fct_orders.yml"),
"model_name" => "fct_orders",
"layer" => "marts",
"adapter" => "snowflake",
"dbt_version" => "1.10+",
];
$est = call("/estimate", $payload);
echo "{$est['model']} hold={$est['hold_credits']} min={$est['min_credits']}\n";
if ($me["credits"] < $est["min_credits"]) {
throw new RuntimeException("short by " . ($est["min_credits"] - $me["credits"]));
}
var payload = new
{
task = "review",
model_sql = await File.ReadAllTextAsync("models/marts/fct_orders.sql"),
schema_yml = await File.ReadAllTextAsync("models/marts/fct_orders.yml"),
model_name = "fct_orders",
layer = "marts",
adapter = "snowflake",
dbt_version = "1.10+"
};
var est = await CallAsync("/estimate", payload);
Console.WriteLine(est.GetProperty("hold_credits") + " held, min " + est.GetProperty("min_credits"));
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'])"
import hashlib, time
def idem(payload, attempt=1):
"""dbt Desk derives the key from (task, input, attempt) - nothing else."""
material = json.dumps(payload, sort_keys=True).encode()
return "dbt-desk:%s:%s:%d" % (
payload["task"], hashlib.sha256(material).hexdigest()[:16], attempt)
key = idem(payload)
job = call("/run", dict(payload, idempotency_key=key)) # or send Idempotency-Key
job_id = job["job_id"]
while True:
status = call("/jobs/" + job_id, method="GET")
if status["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
if status["status"] != "succeeded":
raise RuntimeError("job " + status["status"])
result = json.loads(status["output"]["output"])
print(result["posture"], "-", result["verdict"])
for f in result["findings"]:
print(" [%-8s] %s %s" % (f["severity"], f["id"], f["title"]))
import { createHash } from "node:crypto";
// (task, input, attempt). Two lanes over one model are two runs.
const idem = (p, attempt = 1) =>
`dbt-desk:${p.task}:` +
createHash("sha256").update(JSON.stringify(p)).digest("hex").slice(0, 16) +
`:${attempt}`;
const key = idem(payload);
const job = await call("/run", payload, "POST", { "Idempotency-Key": key });
let status;
do {
await new Promise(r => setTimeout(r, 2000));
status = await call(`/jobs/${job.job_id}`, undefined, "GET");
} while (!["succeeded", "failed", "cancelled"].includes(status.status));
if (status.status !== "succeeded") throw new Error(`job ${status.status}`);
const result = JSON.parse(status.output.output);
console.log(result.posture, "-", result.verdict);
for (const f of result.findings) {
console.log(` [${f.severity}] ${f.id} ${f.title}`);
}
material, _ := json.Marshal(payload)
sum := sha256.Sum256(material)
key := fmt.Sprintf("dbt-desk:%s:%x:1", payload["task"], sum[:8])
data, err := call("POST", "/run", payload, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var job struct {
JobID string `json:"job_id"`
}
json.Unmarshal(data, &job)
for {
time.Sleep(2 * time.Second)
statusData, _ := call("GET", "/jobs/"+job.JobID, nil, nil)
var st struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(statusData, &st)
if st.Status == "succeeded" {
fmt.Println(st.Output.Output) // the model's JSON object, as a string
break
}
if st.Status == "failed" || st.Status == "cancelled" {
panic("job " + st.Status)
}
}
String key = "dbt-desk:review:" + Integer.toHexString(payload.hashCode()) + ":1";
HttpRequest run = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
String jobId = JsonUtil.path(
CLIENT.send(run, HttpResponse.BodyHandlers.ofString()).body(), "data", "job_id");
String status;
do {
Thread.sleep(2000);
status = call("/jobs/" + jobId, null);
} while (!status.contains("\"status\":\"succeeded\"")
&& !status.contains("\"status\":\"failed\""));
System.out.println(status);
require "digest"
# (task, input, attempt)
material = JSON.dump(payload.sort.to_h)
key = "dbt-desk:#{payload['task']}:#{Digest::SHA256.hexdigest(material)[0, 16]}:1"
job = call("/run", payload, { "Idempotency-Key" => key })
status = nil
loop do
sleep 2
status = call("/jobs/#{job['job_id']}")
break if %w[succeeded failed cancelled].include?(status["status"])
end
raise "job #{status['status']}" unless status["status"] == "succeeded"
result = JSON.parse(status["output"]["output"])
puts "#{result['posture']} - #{result['verdict']}"
result["findings"].each { |f| puts " [#{f['severity']}] #{f['id']} #{f['title']}" }
<?php
// (task, input, attempt)
$key = "dbt-desk:{$payload['task']}:"
. substr(hash("sha256", json_encode($payload)), 0, 16) . ":1";
$job = call("/run", $payload, ["Idempotency-Key: " . $key]);
do {
sleep(2);
$status = call("/jobs/" . $job["job_id"]);
} while (!in_array($status["status"], ["succeeded", "failed", "cancelled"], true));
if ($status["status"] !== "succeeded") {
throw new RuntimeException("job " . $status["status"]);
}
$result = json_decode($status["output"]["output"], true);
echo "{$result['posture']} - {$result['verdict']}\n";
foreach ($result["findings"] as $f) {
echo " [{$f['severity']}] {$f['id']} {$f['title']}\n";
}
using System.Security.Cryptography;
var material = JsonSerializer.Serialize(payload);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material)))[..16]
.ToLowerInvariant();
var key = "dbt-desk:review:" + hash + ":1";
var runReq = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
runReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
runReq.Headers.Add("Idempotency-Key", key);
runReq.Content = new StringContent(material, Encoding.UTF8, "application/json");
var runRes = await Client.SendAsync(runReq);
var jobId = JsonDocument.Parse(await runRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("job_id").GetString();
JsonElement status;
string state;
do
{
await Task.Delay(2000);
status = await CallAsync($"/jobs/{jobId}", null, HttpMethod.Get);
state = status.GetProperty("status").GetString();
} while (state is not ("succeeded" or "failed" or "cancelled"));
if (state != "succeeded") throw new Exception("job " + state);
var result = JsonDocument.Parse(
status.GetProperty("output").GetProperty("output").GetString()).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
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}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode())
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
chunks = []
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode().strip()
if not line.startswith("data:"):
continue
event = json.loads(line[5:].strip())
if "text" in event:
chunks.append(event["text"])
print(".", end="", flush=True)
result = json.loads("".join(chunks)) # only valid once the stream has closed
print()
print(result["posture"], result["verdict"])
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
"Accept": "text/event-stream"
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim());
if (event.text) text += event.text;
}
}
const result = JSON.parse(text);
console.log(result.posture, result.verdict);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var sb strings.Builder
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var ev struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &ev) == nil && ev.Text != "" {
sb.WriteString(ev.Text)
}
}
fmt.Println(sb.String()) // the whole JSON object, reassembled
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
StringBuilder text = new StringBuilder();
CLIENT.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(line -> line.startsWith("data:"))
.forEach(line -> text.append(JsonUtil.path(line.substring(5).trim(), "text")));
System.out.println(text);
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.dump(payload)
text = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
event = JSON.parse(line[5..].strip) rescue next
text << event["text"] if event["text"]
end
end
end
end
result = JSON.parse(text)
puts "#{result['posture']} #{result['verdict']}"
<?php
$text = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data:")) {
$event = json_decode(trim(substr($line, 5)), true);
if (isset($event["text"])) $text .= $event["text"];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$result = json_decode($text, true);
echo "{$result['posture']} {$result['verdict']}\n";
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", key);
req.Headers.Add("Accept", "text/event-stream");
req.Content = new StringContent(material, Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var text = new StringBuilder();
while (await reader.ReadLineAsync() is { } line)
{
if (!line.StartsWith("data:")) continue;
var ev = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (ev.TryGetProperty("text", out var t)) text.Append(t.GetString());
}
var result = JsonDocument.Parse(text.ToString()).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
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.
1. Records nest under doc. Every record comes back as
{"record_id": "...", "created_at": "...", "doc": {...}}. Read fields off
rec.doc, never flat off rec —
rec.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":{...}}}}
# ]}}
runs = call("/collections/dbtruns/query", {
"where": {"lane": {"eq": "review"}}, # operator objects, not bare values
"sort": {"field": "ran_at", "dir": "desc"}, # "order_by" is silently ignored
"limit": 20,
})
for rec in runs["records"]:
doc = rec["doc"] # ALWAYS unwrap doc; the fields are never flat on rec
print(doc["ran_at"], doc["model_name"], doc["posture"], doc["finding_count"])
result = doc["entry"]["result"] # the full model output, as returned then
print(" ", result["verdict"])
const runs = await call("/collections/dbtruns/query", {
where: { lane: { eq: "review" } }, // operator objects, not bare values
sort: { field: "ran_at", dir: "desc" }, // "order_by" is silently ignored
limit: 20
});
for (const rec of runs.records) {
const doc = rec.doc; // ALWAYS unwrap doc; rec.model_name is undefined
console.log(doc.ran_at, doc.model_name, doc.posture, doc.finding_count);
console.log(" ", doc.entry.result.verdict);
}
query := map[string]any{
"where": map[string]any{"lane": map[string]any{"eq": "review"}},
"sort": map[string]any{"field": "ran_at", "dir": "desc"},
"limit": 20,
}
data, err := call("POST", "/collections/dbtruns/query", query, nil)
if err != nil {
panic(err)
}
var page struct {
Records []struct {
RecordID string `json:"record_id"`
Doc struct {
ModelName string `json:"model_name"`
Lane string `json:"lane"`
Posture string `json:"posture"`
Verdict string `json:"verdict"`
FindingCount int `json:"finding_count"`
RanAt string `json:"ran_at"`
} `json:"doc"` // the fields live under doc, never on the record
} `json:"records"`
}
json.Unmarshal(data, &page)
for _, rec := range page.Records {
fmt.Println(rec.Doc.RanAt, rec.Doc.ModelName, rec.Doc.Posture, rec.Doc.FindingCount)
}
String query = """
{"where": {"lane": {"eq": "review"}},
"sort": {"field": "ran_at", "dir": "desc"},
"limit": 20}
""";
String page = call("/collections/dbtruns/query", query);
System.out.println(page);
// data.records[i].doc.model_name - the declared fields are nested under "doc",
// so a reader that looks for records[i].model_name finds nothing.
runs = call("/collections/dbtruns/query", {
"where" => { "lane" => { "eq" => "review" } }, # operator objects
"sort" => { "field" => "ran_at", "dir" => "desc" },
"limit" => 20
})
runs["records"].each do |rec|
doc = rec["doc"] # never rec["model_name"]
puts "#{doc['ran_at']} #{doc['model_name']} #{doc['posture']} #{doc['finding_count']}"
puts " #{doc['entry']['result']['verdict']}"
end
<?php
$runs = call("/collections/dbtruns/query", [
"where" => ["lane" => ["eq" => "review"]], // operator objects
"sort" => ["field" => "ran_at", "dir" => "desc"], // not "order_by"
"limit" => 20,
]);
foreach ($runs["records"] as $rec) {
$doc = $rec["doc"]; // never $rec["model_name"]
echo "{$doc['ran_at']} {$doc['model_name']} {$doc['posture']} {$doc['finding_count']}\n";
echo " {$doc['entry']['result']['verdict']}\n";
}
var query = new
{
where = new { lane = new { eq = "review" } }, // operator objects
sort = new { field = "ran_at", dir = "desc" }, // not order_by
limit = 20
};
var page = await CallAsync("/collections/dbtruns/query", query);
foreach (var rec in page.GetProperty("records").EnumerateArray())
{
var doc = rec.GetProperty("doc"); // the fields are under doc, not on rec
Console.WriteLine(doc.GetProperty("ran_at") + " " + doc.GetProperty("model_name")
+ " " + doc.GetProperty("posture"));
}
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
- No
inputwrapper, no slug header. The body is the input object itself, and the token carries the app. Those are the two shapes people bring from other APIs and both fail quietly here. - Two lanes over one model are two runs. Put
taskin the idempotency key or the second lane returns the first lane's cached answer. - Check
lane_inferredon every reply. If it istrue, yourtaskdid not arrive or was not recognised and the model guessed. That is a client bug to fix, not a result to display. - No
prescanmeans no reconciliation.coverage_checkcomes back empty and nothing holds the answer to the deterministic facts. Send flags — yours, if not ours. other_lane_flagsare context. They get nocoverage_checkentries. Onlyprescan.flagsdoes, and the mapping is one to one in both directions.artifact.contentis a whole file, never a diff and never a fragment.reviewmay returnkind: "none"; the other three lanes always return YAML.- Send
adapter. Without it you get a dialect guess recorded inassumptions, and the constraints in acontractrun may name something your warehouse does not enforce. - Send
schema_ymlwhen you have it. Without it, thedocumentationandtest_coveragechecks can only report absence, and acontractrun has fewer justified types and more placeholders. - Nothing here executes anything. There is no warehouse, no
manifest.json, nocatalog.jsonand no network on the other side.verify_commandsare commands you run; the model never claims it ran them. posture: "blocked"can also mean "this is not a dbt model". If you post a macro, a snapshot block or a plain SQL script, the envelope stays valid, one critical finding says exactly what was pasted, andbodycomes back with empty arrays. That is the honest answer, not a failure.