Drive DESIGN.md Desk from your own code
The lint is exact; the judgement is the model's. The model reads your DESIGN.md and the official linter's findings; it never re-lints and is told never to compute a contrast ratio. Re-lint a repaired file yourself (the page does) before you commit it.
Everything the web page does is available over HTTP. Lint the file with the page's own
dmdmd.js and dmdkit.js (the official @google/design.md 0.4.0 linter, built unmodified
for the browser) and deskscan.js, send the file and the facts, and get back a verdict
(blocked, needs_work, ready) and either a review or a repaired
file. The natural loop: lint, review, repair, re-lint, review again. DESIGN.md Desk is derived from
the agent skill @nousresearch/design-md (Hermes Agent).
Two lanes: the task field
task | what comes back | extra input |
|---|---|---|
review | An answer to every finding and check, prose that disagrees with the tokens, gaps, what a coding agent would get wrong, 3-7 priority fixes. | none |
repair | The complete repaired DESIGN.md, every change against the finding it answers, open questions for the author. | review: the review to apply (optional) |
An unknown or missing task is answered as review, and the headline says so.
Input fields
Every field is a string. task, design_md and facts are required.
| field | what it holds |
|---|---|
task | review or repair. |
design_md | The DESIGN.md text. The page sends up to 40,000 characters; past that the front matter goes whole and the middle of the prose is cut on line boundaries with a marker line. |
facts | A JSON string (see below). |
title | Optional name for the run, up to 160 characters. |
context | Optional notes: who reads the file, what worries you. Up to 3,000 characters. |
question | Optional. When present, the first next_steps entry starts with Answer:. |
review | Repair only: the review to apply, as text. The page builds it from a review reply. |
retry_note | Only on a reformat retry, telling the model what was wrong with its last reply. |
The facts string
{
"linter": "@google/design.md 0.4.0 (official, run in the browser)",
"spec_version": "alpha",
"name": "Quayvane",
"summary": {"errors": 1, "warnings": 5, "infos": 2},
"findings": [{"id": "F1", "severity": "error", "rule": "broken-ref", "path": "components.status-chip",
"message": "Reference {colors.accent} does not resolve to any defined token."}, ...],
"checks": [{"id": "P1", "kind": "prose-hex-not-token", "text": "The prose in ## Colors writes #0B5FFF, ..."}],
"tokens": {"colors": {"primary": "#0f2a3d", ...}, "typography": {...}, "rounded": {...}, "spacing": {}, "components": {...}},
"contrast": [{"component": "button-secondary", "background": "#f4f6f8", "text": "#8a94a6", "ratio": 2.82, "passes_aa": false}, ...],
"sections": {"present": ["Overview", "Typography", "Colors", ...], "canonical_order": [...], "missing": ["Elevation & Depth", "Shapes"], "unknown": []},
"hint": "blocked",
"clipped": []
}
findings are the linter's, in its order; checks are the page's own four
prose checks (prose-hex-not-token, prose-ref-unresolved,
role-undocumented, component-undocumented). hint is
blocked when an error stands, needs_work when a warning or check stands,
otherwise ready. From another language you can build findings from
npx @google/design.md lint DESIGN.md (number them F1, F2 ...) and leave
checks empty; the page's own build is exact.
Building the body
The shortest exact path is the page's own modules in Node. Download dmdmd.js, dmdkit.js and deskscan.js next to this script:
// make-body.mjs - node make-body.mjs DESIGN.md review > body.json
import { readFileSync } from "node:fs";
import vm from "node:vm";
const ctx = { console };
ctx.globalThis = ctx; ctx.window = ctx;
vm.createContext(ctx);
for (const f of ["dmdmd.js", "dmdkit.js", "deskscan.js"]) vm.runInContext(readFileSync(f, "utf8"), ctx);
const D = ctx.DeskScan;
const [file = "DESIGN.md", task = "review"] = process.argv.slice(2);
const scan = D.scan(readFileSync(file, "utf8"));
const body = D.buildInput(scan, { lane: task, title: "", context: "", question: "", review: "" });
console.error("lint:", JSON.stringify(scan.summary), "hint:", scan.hint,
"key: design-md-desk:" + task + ":" + D.hashInput(body) + ":a1");
console.log(JSON.stringify(body));
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"design-md-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….
The input object IS the request body. There is no {"input": …}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
The token comes from the token page (Copy token or
Copy shell export); step 2 covers the kinds of token and minting one from code.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="design-md-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://design-md-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "design-md-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://design-md-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "design-md-desk";
// Paste the token from https://design-md-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "design-md-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://design-md-desk.skillsafe.ai/tokens.html
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(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
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 DesignMdDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "design-md-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "design-md-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://design-md-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "design-md-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class DesignMdDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "design-md-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
2. Get a token
The easiest route is the token page: it shows the token this browser
already holds, with Copy token and Copy shell export buttons, and
a sign-in button for a personal token. A guest token, minted with
POST /guest and {"slug":"design-md-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://design-md-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"design-md-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://design-md-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "design-md-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://design-md-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "design-md-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://design-md-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"design-md-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://design-md-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"design-md-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://design-md-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "design-md-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://design-md-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "design-md-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://design-md-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"design-md-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await DesignMdDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra and
markup_bps 1000 (a 10% markup). hold_credits is a
reservation, not the price: it is held against your balance while the run executes
and released afterwards. min_credits is the least balance that can start a run. What
you actually pay is charged_credits, reported on the finished job and in the
done event, and it is usually far lower than the hold. The body is the input object
itself, with no {"input": …} wrapper. /estimate does not validate the
body, so check the shape yourself: an object whose every value is a string, task equal
to review or repair,
design_md and facts non-empty, and facts a JSON string that parses to an object (this is
what the page's own guard, DeskScan.mustBeObject, refuses to spend without).
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.mjs above, or by hand. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("review","repair") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("design_md", "facts")) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.
INPUT = json.load(open("body.json")) # built by make-body.mjs above, or by hand
assert isinstance(INPUT, dict) and INPUT.get("task") in ("review", "repair")
assert all(isinstance(v, str) for v in INPUT.values())
assert all(INPUT.get(k, "").strip() for k in ("design_md", "facts"))
assert isinstance(json.loads(INPUT["facts"]), dict) # facts is a JSON STRING
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.mjs above
if (!INPUT || typeof INPUT !== "object" || !["review", "repair"].includes(INPUT.task)) throw new Error("task must be review or repair");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["design_md", "facts"]) if (!(INPUT[k] || "").trim()) throw new Error(k + " is required");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.mjs above
var input map[string]string // every field is a string, facts included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "review" && input["task"] != "repair" {
panic("task must be review or repair")
}
for _, k := range []string{"design_md", "facts"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.mjs above
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(review|repair)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task review or repair");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(review|repair)\".*", "$1");
for (String k : new String[] {"design_md", "facts"})
if (!input.contains("\"" + k + "\"")) throw new IllegalStateException(k + " is required");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.mjs above
raise "task must be review or repair" unless %w[review repair].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[design_md facts].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "facts must hold an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.mjs above
if (!is_array($input) || !in_array($input["task"] ?? "", ["review", "repair"], true)) { throw new Exception("task must be review or repair"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["design_md", "facts"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
if (!is_array(json_decode($input["facts"], true))) { throw new Exception("facts must be a JSON string"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.mjs above
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "review" && lane != "repair") throw new Exception("task must be review or repair");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
var est = await DesignMdDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output: JSON.parse
it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the
attempt number, design-md-desk:<lane>:<hash>:a<attempt> (for example
design-md-desk:review:mt6z7s12xnl3e:a1), so a retried request returns the same job instead
of billing a second run. Use one key per distinct input: an edited DESIGN.md (so changed facts), changed
notes or a changed review are a new hash, the same file in the other lane is a new key, and replaying
an old key with a different body is a 409. The page uses
DeskScan.hashInput(body) for the hash (it covers task, design_md,
facts, title, context, review and question;
make-body.mjs prints the key); any stable digest of the body works from other
languages. Leave retry_note out of the hash and bump the attempt instead.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])') # review or repair
KEY="design-md-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"review\",\"verdict\":\"needs_work\",\"headline\":\"...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"design-md-desk:{INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `design-md-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("design-md-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "design-md-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "design-md-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "design-md-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"design-md-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await DesignMdDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
6. Or stream it
POST /run-stream takes the same body and headers and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -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 "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"review\",\"verdict\":\"needs_work\",\"headline\":\"The"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
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(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, 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, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(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(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
The reply is one JSON object as a string. Strip optional code fences, take the text from the first
{ to the last } and parse it. If it does not parse, send the same body
once more with retry_note explaining the problem and the next attempt number in the
Idempotency-Key - that is one extra, named run. The page's own parser is
Recon.parseResult and Recon.normalize in recon.js.
Invariants worth asserting
- Every id in
facts.findingsandfacts.checksis answered exactly once infinding_responses, and no other id. - No
errorfinding is markedacceptordispute. verdictis not looser thanfacts.hint(blocked < needs_work < ready).- Every contrast ratio quoted is one in
facts.contrastor a finding message. - Repair: re-lint
design_mdwithnpx @google/design.md lintand diff it withnpx @google/design.md diff original.md repaired.md- no errors,"regression": false, every removed or changed token named inchanges.
The output contract
{
"lane": "review" | "repair",
"verdict": "blocked" | "needs_work" | "ready",
"headline": "...",
"finding_responses": [{"ref": "F1", "action": "fix" | "accept" | "dispute", "note": "..."}],
// review:
"prose_vs_tokens": [{"where": "## Colors", "issue": "...", "fix": "..."}],
"gaps": [{"area": "colors|typography|layout|shapes|elevation|components|states|accessibility|prose", "issue": "...", "suggestion": "..."}],
"agent_readiness": ["..."],
"priority_fixes": ["...", "...", "..."],
// repair:
"changes": [{"ref": "F1", "change": "..."}],
"design_md": "---\nversion: alpha\n...",
"open_questions": ["..."],
"next_steps": ["..."]
}
Worked example: review
The Quayvane example on the page: a broken {colors.accent} reference, a 2.82:1 secondary button, a misspelled spaceing map. Body (abridged):
{
"task": "review",
"design_md": "---\nversion: alpha\nname: Quayvane\ndescription: A calm, high-density operations console for freight dispatchers who watch it all shift.\ncolors:\n primary: \"#0F2A3D\"\n secondary: \"#... (the whole file)",
"facts": "{\"linter\":\"@google/design.md 0.4.0 ...\",\"summary\":{\"errors\": 1, \"warnings\": 5, \"infos\": 2}, \"findings\":[...], ...}",
"title": "Quayvane dispatch console",
"context": "We hand this file to our coding agents before any UI work. Our Tailwind export seems to ignore the spacing scale.",
"question": "What will an agent get wrong first?",
"review": ""
}
Reply (abridged; the page replays the full reply for free):
{
"lane": "review",
"verdict": "blocked",
"headline": "Quayvane is blocked by one broken reference (status-chip's {colors.accent}) and has a misspelled 'spaceing' key that is silently dropping the entire xs/sm/md/lg spacing scale from exports; fix the reference first, the typo second.",
"finding_responses": [
{
"ref": "F1",
"action": "fix",
"note": "{colors.accent} in components.status-chip does not resolve to any token, so status-chip has no valid backgroundColor. Point it at an existing token, and per the components-list description (on time, at risk, late) split status-chip into per-state sibling components (e.g. status-chip-on-time, status-chip-at-risk, status-chip-late) each with a real color, since a state is a separate sibling component, not a single component with one ambiguous color."
},
{
"ref": "F2",
"action": "fix",
"note": "components.button-secondary's textColor #8a94a6 on backgroundColor #f4f6f8 is 2.82:1, below the 4.5:1 AA minimum. Darken the text or change the background until the pair clears 4.5:1."
},
"..."
],
"prose_vs_tokens": [
{
"where": "## Colors",
"issue": "Prose gives Tertiary as #0B5FFF; colors.tertiary resolves to #0a5cff, a different hex.",
"fix": "Change the prose hex to #0a5cff, or write {colors.tertiary} instead of a literal hex."
},
"..."
],
"gaps": [
{
"area": "states",
"issue": "The Components section says status-chip marks a load as on time, at risk or late, but only one status-chip component is defined and its backgroundColor ({colors.accent}) doesn't resolve. There are no status-chip-on-time / status-chip-at-risk / status-chip-late siblings, so an agent has no way to render the three states with distinct colors.",
"suggestion": "Define status-chip-on-time, status-chip-at-risk and status-chip-late as separate sibling components (per the spec, states are siblings, not a nested map), each with a resolvable backgroundColor."
},
"..."
],
"agent_readiness": [
"It will render every status-chip with no visible background because {colors.accent} doesn't resolve to anything (F1), leaving on-time/at-risk/late loads visually identical and unstyled.",
"It will ship button-secondary with text at 2.82:1 contrast against its background, well under the 4.5:1 AA minimum, making cancel/back buttons hard to read (F2)."
],
"priority_fixes": [
"Fix components.status-chip.backgroundColor: point {colors.accent} at an existing or new documented token instead of an unresolved reference (F1).",
"Rename the 'spaceing' key to 'spacing' so the xs/sm/md/lg scale is recognized and stops being dropped by exports (F7, F8).",
"Fix components.button-secondary's contrast: change textColor or backgroundColor so the pair clears 4.5:1 AA (F2).",
"..."
],
"next_steps": [
"Answer: The status-chip component's backgroundColor reference ({colors.accent}) doesn't resolve, so an agent will render every status chip unstyled before it even gets to spacing or contrast problems. Separately, your Tailwind export is losing the spacing scale because the front matter key is spelled 'spaceing' instead of 'spacing', not because of an export bug.",
"Rename 'spaceing' to 'spacing' in the front matter."
]
}
Worked example: repair
The same file, with the review above handed over as review. Body (abridged):
{
"task": "repair",
"design_md": "---\nversion: alpha\nname: Quayvane\ndescription: A calm, high-density operations console for freight dispatchers who watch it all shift.\ncolors:\n primary: \"#0F2A3D\"\n secondary: \"#... (the whole file)",
"facts": "{\"linter\":\"@google/design.md 0.4.0 ...\",\"summary\":{\"errors\": 1, \"warnings\": 5, \"infos\": 2}, \"findings\":[...], ...}",
"title": "Quayvane dispatch console",
"context": "We hand this file to our coding agents before any UI work. Our Tailwind export seems to ignore the spacing scale.",
"question": "",
"review": "Review verdict: blocked. Quayvane is blocked by one broken reference (status-chip's {colors.accent}) and has a misspelled 'spaceing' key that is silently dropp..."
}
Reply (abridged):
{
"lane": "repair",
"verdict": "needs_work",
"headline": "Quayvane's blocking reference and every warning are fixed by reusing tokens already in the file, but the three new status-chip variants haven't been contrast-checked yet, so this still needs one more re-lint before it's ready.",
"finding_responses": [
{
"ref": "F1",
"action": "fix",
"note": "components.status-chip.backgroundColor referenced {colors.accent}, which does not exist. A single chip also can't show three different states with one colour, so I replaced it with three sibling components (components.status-chip-on-time, components.status-chip-at-risk, components.status-chip-late), each with its own resolvable backgroundColor from tokens already in the file."
},
{
"ref": "F2",
"action": "fix",
"note": "components.button-secondary's textColor #8a94a6 on backgroundColor #f4f6f8 was 2.82:1, below AA. Changed it to backgroundColor {colors.surface} / textColor {colors.on-surface}, the exact pair components.table-row already uses at a verified 14.8:1."
},
"..."
],
"changes": [
{
"ref": "F1",
"change": "Removed components.status-chip (broken backgroundColor {colors.accent}) and added components.status-chip-on-time ({colors.secondary} / {colors.surface}), components.status-chip-at-risk ({colors.brand-sand} / {colors.primary}) and components.status-chip-late ({colors.error} / {colors.surface}), each keeping the original typography and rounded values."
},
{
"ref": "F2",
"change": "components.button-secondary: backgroundColor changed from {colors.neutral} to {colors.surface}, textColor changed from {colors.muted} to {colors.on-surface}."
},
{
"ref": "F3",
"change": "colors.brand-sand is now referenced by components.status-chip-at-risk and documented in ## Colors."
},
"..."
],
"design_md": "---\nversion: alpha\nname: Quayvane\ndescription: A calm, high-density operations console for freight dispatchers who watch it all shift.\ncolors:\n primary: \"#0F2A3D\"\n secondary: \"#4A6275\"\n tertiary: \"#0A5CFF\"\n neutral:... (the whole repaired file)",
"open_questions": [
"status-chip-on-time uses colors.secondary and status-chip-at-risk uses colors.brand-sand as stand-in state colours, since the palette has no dedicated green/amber token for 'on schedule' or 'caution'; confirm these read correctly or supply dedicated tokens.",
"The three new status-chip variants have not been through the browser's contrast check; confirm colors.secondary/{colors.surface}, colors.brand-sand/{colors.primary} and colors.error/{colors.surface} each clear 4.5:1 once re-linted."
],
"next_steps": [
"Re-lint the repaired file and confirm the three new status-chip variants pass AA contrast.",
"Confirm or replace the stand-in colours chosen for status-chip-on-time and status-chip-at-risk."
]
}
Truncation and partial results
If your balance sits between min_credits and hold_credits, the run still
executes with a smaller output cap and the job says "truncated": true. The JSON may
then be cut mid-object: close the open strings and brackets, parse what arrived, and treat missing
keys as missing - never as empty. The page does this with Recon.closeJson and says how
many sections were recovered. A repair cut short has no usable design_md; run it again.
DESIGN.md Desk is derived from the agent skill @nousresearch/design-md. The linter is @google/design.md 0.4.0 (Apache-2.0, Copyright 2026 Google LLC; notice).