Drive Diagram Lint from your own code
The lint is deterministic; the judgement is the model's. The model reads your document and this page's lint of it; it never re-lints. Lint a fixed document or a drafted diagram yourself (the page does) before you commit it.
Everything the web page does is available over HTTP. Lint the document with the page's own
mmdlint.js - a rule-by-rule reading of the markdown and mermaid style
guides of the agent skill @k-dense-ai/markdown-mermaid-writing - send the document and
the facts, and get back a verdict (blocked, needs_work, ready)
and a review, a corrected document or a new diagram. The natural loop: lint, review, fix, re-lint,
diagram the prose the review pointed at, review again.
Three lanes: the task field
task | what comes back | extra input |
|---|---|---|
review | An answer to every finding, a note on every diagram, prose that wants a diagram, claims without a source, 3-7 priority fixes. | none |
fix | The complete corrected document, every change against the finding it answers, open questions for the author. | review: the review to apply (optional) |
diagram | One new Mermaid diagram: its type and why, the passage it draws, where it belongs, the code, a caption, open questions. | passage: the prose to diagram (optional; empty = the model picks and quotes one) |
An unknown or missing task is answered as review, and the headline says so.
Input fields
Every field is a string. task, markdown and facts are required.
| field | what it holds |
|---|---|
task | review, fix or diagram. |
markdown | The document. The page sends up to 40,000 characters; past that the middle is cut on line boundaries (never inside a code block) 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 it, where it renders. Up to 3,000 characters. |
question | Optional. When present, the first next_steps entry starts with Answer:. |
review | Fix only: the review to apply, as text. The page builds it from a review reply (Recon.reviewText). Empty in other lanes. |
passage | Diagram only: the prose to turn into a diagram, up to 6,000 characters. Empty in other lanes. |
retry_note | Only on a reformat retry, telling the model what was wrong with its last reply. |
The facts string
{
"linter": "Diagram Lint style lint (this page's reading of the skill's markdown and mermaid style guides)",
"title": "Plasma Sample Intake Pipeline",
"summary": {"errors": 2, "warnings": 13, "infos": 5},
"hint": "blocked",
"findings": [{"id": "F9", "severity": "error", "rule": "mm-acc-descr", "line": 13, "where": "D1",
"message": "D1 (flowchart) has no accDescr. ..."}, ...],
"diagrams": [{"id": "D1", "line": 11, "type": "flowchart", "header": "flowchart LR", "accTitle": "Plasma tube intake steps",
"accDescr": "", "nodes": 7, "edges": 6, "subgraphs": 0, "decisions": 1, "classDefs": 0}, ...],
"outline": [{"level": 1, "text": "Plasma Sample Intake Pipeline", "line": 1}, ...],
"footnotes": {"cited": ["1", "2"], "defined": ["1"], "undefined": ["2"]},
"clipped": []
}
hint is blocked when an error stands, needs_work when a warning
stands, otherwise ready. Errors are things that will not render, or a diagram without
accTitle/accDescr (the guide's one rule with no exceptions); warnings are stated rules of the
guides; notes (info) are heuristics the review may dispute.
Building the body
The shortest exact path is the page's own module in Node. Download mmdlint.js next to this script:
// make-body.mjs - node make-body.mjs doc.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);
vm.runInContext(readFileSync("mmdlint.js", "utf8"), ctx);
const M = ctx.MmdLint;
const [file = "doc.md", task = "review", passage = ""] = process.argv.slice(2);
const scan = M.scan(readFileSync(file, "utf8"));
const body = M.buildInput(scan, { lane: task, title: "", context: "", question: "", review: "", passage });
console.error("lint:", JSON.stringify(scan.summary), "hint:", scan.hint,
"key: diagram-lint:" + task + ":" + M.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":"diagram-lint"} 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="diagram-lint"
TOKEN="$SKILLSAFE_TOKEN" # from https://diagram-lint.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 = "diagram-lint"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://diagram-lint.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 = "diagram-lint";
// Paste the token from https://diagram-lint.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 = "diagram-lint"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://diagram-lint.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 DiagramLint {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "diagram-lint";
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 = "diagram-lint"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://diagram-lint.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 = "diagram-lint";
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 DiagramLint
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "diagram-lint";
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":"diagram-lint"}, 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://diagram-lint.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":"diagram-lint"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://diagram-lint.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": "diagram-lint"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://diagram-lint.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: "diagram-lint" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://diagram-lint.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":"diagram-lint"}`)))
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://diagram-lint.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\":\"diagram-lint\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://diagram-lint.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: "diagram-lint" })
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://diagram-lint.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" => "diagram-lint"]));
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://diagram-lint.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\":\"diagram-lint\"}", 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 DiagramLint.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; what you pay is charged_credits on the
finished job, usually far lower. The hold differs by lane, so price the lane you will run.
/estimate does not validate the body, so check the shape yourself: an object whose
every value is a string, task one of review, fix,
diagram, markdown non-empty and facts a JSON string that parses
to an object (the page's own guard is MmdLint.mustBeObject).
# 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","fix","diagram") and all(isinstance(v,str) for v in b.values()) and b.get("markdown","").strip() 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}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.
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", "fix", "diagram")
assert all(isinstance(v, str) for v in INPUT.values())
assert INPUT.get("markdown", "").strip()
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"])
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", "fix", "diagram"].includes(INPUT.task)) throw new Error("task must be review, fix or diagram");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
if (!(INPUT.markdown || "").trim()) throw new Error("markdown 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);
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())
}
switch input["task"] {
case "review", "fix", "diagram":
default:
panic("task must be review, fix or diagram")
}
if strings.TrimSpace(input["markdown"]) == "" {
panic("markdown 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|fix|diagram)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task review, fix or diagram");
if (!input.contains("\"markdown\"") || !input.contains("\"facts\""))
throw new IllegalStateException("markdown and facts are 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, fix or diagram" unless %w[review fix diagram].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
raise "markdown is required" if INPUT["markdown"].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", "fix", "diagram"], true)) { throw new Exception("task must be review, fix or diagram"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
if (trim($input["markdown"] ?? "") === "") { throw new Exception("markdown 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 != "fix" && lane != "diagram") throw new Exception("task must be review, fix or diagram");
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 DiagramLint.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, diagram-lint:<lane>:<hash>:a<attempt> (for example
diagram-lint:review:ucxlvfu8ef:a1), so a retried request returns the same job instead
of billing a second run. Use one key per distinct input: an edited document (so changed facts), changed
notes, a changed review or passage are a new hash, the same document in another lane is a new key, and
replaying an old key with a different body is a 409. The page uses
MmdLint.hashInput(body) for the hash (it covers every field of the body;
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, fix or diagram
KEY="diagram-lint:$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"diagram-lint:{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 = `diagram-lint:${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("diagram-lint:%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 = "diagram-lint:" + 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 = "diagram-lint:#{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 = "diagram-lint:" . $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 = $"diagram-lint:{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 DiagramLint.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
- Review and fix: every id in
facts.findingsis answered exactly once infinding_responses, and no other id; noerrorfinding is markedacceptordispute. - Review:
verdictis not looser thanfacts.hint(blocked < needs_work < ready); every id infacts.diagramshas exactly onediagram_notesentry; everyquoteis found in your document. - Fix: lint
markdownagain withMmdLint.scan- no errors, no finding you were told was fixed, every URL of the original still present and no new URL, non-Mermaid code blocks byte-identical. - Diagram:
MmdLint.scanDiagram(mermaid, caption, diagram_type)reports no errors, the first keyword matchesdiagram_type, andsource_quoteis in your passage or document.
The output contract
One JSON object. Every lane carries the same envelope; each adds its own keys.
{
"lane": "review" | "fix" | "diagram",
"verdict": "blocked" | "needs_work" | "ready",
"headline": "...",
"finding_responses": [{"ref": "F1", "action": "fix" | "accept" | "dispute", "note": "..."}], // [] in diagram
// review:
"diagram_notes": [{"ref": "D1", "type_fit": "right" | "better_type", "suggested_type": "", "note": "..."}],
"prose_to_diagram": [{"where": "heading", "quote": "verbatim", "suggested_type": "sequence", "why": "..."}],
"citation_gaps": [{"where": "heading", "quote": "verbatim claim", "why": "..."}],
"priority_fixes": ["..."],
// fix:
"changes": [{"ref": "F1" | "D2" | "", "change": "..."}],
"markdown": "the complete corrected document",
"open_questions": ["..."],
// diagram:
"diagram_type": "flowchart" | "sequence" | "state" | ...,
"why_this_type": "...", "source_quote": "verbatim", "placement": "heading",
"mermaid": "flowchart LR\n accTitle: ...", "caption": "...", "open_questions": ["..."],
"next_steps": ["..."]
}
Type names: flowchart, sequence, class, state, er, gantt, pie, git_graph, mindmap, timeline, user_journey, quadrant, requirement, c4, sankey, xy_chart, block, kanban, packet, architecture, radar, treemap, zenuml.
Worked example: review
The intake note example: a flowchart with an end node, no accDescr, an %%{init}%% theme, ids A-F and an undefined footnote. The lint says blocked with 2 errors and 13 warnings. Request body (abridged):
{
"task": "review",
"markdown": "# Plasma Sample Intake Pipeline\n\n_Operations note for the Quillmere biobank intake bench, draft 3_\n\n## 📋 Overview\n\nThis note describes how a plasma tube moves from the courier bag to the freezer. **Every tube must be logged in the LIMS before it leaves the int ...",
"facts": "{\"title\": \"Plasma Sample Intake Pipeline\", \"summary\": {\"errors\": 2, \"warnings\": 13, \"infos\": 5}, \"hint\": \"blocked\", \"findings\": [20 items], \"diagrams\": [2 items], ...}",
"title": "Plasma sample intake note",
"context": "This note goes into our lab wiki repository. Reviewers read it on GitHub, mostly in dark mode.",
"question": "Which diagram problem will reviewers hit first?",
"review": "",
"passage": ""
}
Reply (a saved model run, abridged):
{
"lane": "review",
"verdict": "blocked",
"headline": "This note is blocked by two Mermaid errors in the intake flowchart (D1) - a missing accDescr and a node id of `end` that will likely break parsing - and both the %%{init}%% theme directive and the inline style line will fight GitHub's dark-mode rendering that this doc's reviewers actually use.",
"finding_responses": [
{
"ref": "F1",
"action": "fix",
"note": "The H1 and three headings (\"Intake Steps\", \"Storage And Retrieval\", \"Sequence Of Messages\") are Title Case with no proper nouns in them; drop them to sentence case, e.g. \"Storage and retrieval\"."
},
{
"ref": "F2",
"action": "fix",
"note": "Add a `---` rule after the italic \"Operations note for the Quillmere biobank intake bench, draft 3\" line to separate the title metadata from the Overview section."
},
{
"ref": "F3",
"action": "fix",
"note": "Line 7 bolds the whole sentence \"Every tube must be logged in the LIMS before it leaves the intake bench.\"; bold only the key words, e.g. **logged in the LIMS**, and leave the rest plain."
},
{
"...": "17 more"
}
],
"diagram_notes": [
{
"ref": "D1",
"type_fit": "right",
"suggested_type": "",
"note": "Flowchart is the right type for a sequence of steps with one decision. But it undercuts itself: the ids are meaningless letters (F5), two labels run long - the hemolysis-check node and the \"no\" edge label (F6/F7) - and there is no accDescr (F9), so a screen-reader user gets no summary of what the flow shows. The %%{init}%% theme and inline style (F8/F11) will also fight GitHub's dark-mode rendering, and the `end` node id (F10) is likely to break parsing before a reader sees any of that."
},
{
"ref": "D2",
"type_fit": "right",
"suggested_type": "",
"note": "sequenceDiagram is the right type for a scanner-to-LIMS exchange. accTitle and accDescr are both present and accurately describe what the messages show, participant labels (Bench scanner, LIMS) are clear, and the three messages read as a real back-and-forth. No material problem found here."
}
],
"prose_to_diagram": [
{
"where": "Intake Steps",
"quote": "the bench technician marks it rejected in the LIMS, prints a rejection label, emails the submitting site with the tube barcode and the reason, and moves the tube to the rejection rack, which is emptied every Friday by the biosafety officer.",
"suggested_type": "flowchart",
"why": "This is a chain of steps (mark rejected, print label, email site, move to rack, empty weekly) buried in one long sentence right after the D1 diagram; readers would follow the rejection path faster as flowchart steps than as prose."
},
{
"where": "Storage And Retrieval",
"quote": "Retrieval requests come from the study coordinator, who files a request; the freezer manager approves it, pulls the vials, and the courier signs them out.",
"suggested_type": "sequence",
"why": "This names three roles handing a request to one another in order (coordinator files, freezer manager approves and pulls, courier signs out) - a sequence diagram would show who does what, in what order, more clearly than the semicolon-joined sentence."
}
],
"citation_gaps": [
{
"where": "🔗🔍 Sequence Of Messages",
"quote": "The courier hand-off is audited monthly[^2].",
"why": "This is an external claim about an audit process, marked with [^2], but [^2] has no definition anywhere in the document (F19), so the footnote marker points at no source and will render as literal text."
}
],
"priority_fixes": [
"Fix F10: rename D1's `end` node id (e.g. to `end_node`) so the flowchart parses at all.",
"Fix F9: add an accDescr line to D1 so the diagram meets the accessibility requirement.",
"Fix F8: remove D1's %%{init: {'theme':'forest'}}%% directive so it doesn't break GitHub dark-mode rendering.",
"Fix F11: replace D1's inline `style E fill:#ff0000` with a classDef/class pair that includes color:.",
"Fix F5: rename D1's generic ids (A-F) to snake_case ids that match their labels.",
"Fix F4: add one emoji to the start of the \"Intake Steps\" heading.",
"Fix F19 and F20: add a definition for footnote [^2] and a URL to footnote [^1]'s definition."
],
"next_steps": [
"Answer: reviewers will hit D1's flowchart first - its `end` node id (F10) is a reserved word that will likely make GitHub's Mermaid renderer fail to parse the diagram at all, before they ever notice the missing accDescr (F9) or the dark-mode-breaking %%{init}%% theme (F8).",
"Rename D1's `end` node and add its accDescr so the diagram renders and is accessible.",
"Strip D1's %%{init}%% directive and inline style line; move the style to classDef/class.",
"Rename D1's node ids and shorten its long node and edge labels.",
"Fix the H2 emoji counts (\"Intake Steps\" needs one, \"Sequence Of Messages\" needs to drop to one) and the Title Case headings.",
"Add the missing footnote definition for [^2] and a source URL for [^1], flagging both for the author to supply the real source.",
"Clean up the remaining Markdown items: unbold the full sentence at line 7, add the title rule, rename the \"click here\" link and switch it to https, tag the fenced status-code block as text, add the blank line and rule around <details>, and drop the decorative 🎉."
]
}
Worked example: fix
The same note with the review above handed over as review. The page re-lints the returned markdown and compares it with the original. Request body (abridged):
{
"task": "fix",
"markdown": "# Plasma Sample Intake Pipeline\n\n_Operations note for the Quillmere biobank intake bench, draft 3_\n\n## 📋 Overview\n\nThis note describes how a plasma tube moves from the courier bag to the freezer. **Every tube must be logged in the LIMS before it leaves the int ...",
"facts": "{\"title\": \"Plasma Sample Intake Pipeline\", \"summary\": {\"errors\": 2, \"warnings\": 13, \"infos\": 5}, \"hint\": \"blocked\", \"findings\": [20 items], \"diagrams\": [2 items], ...}",
"title": "Plasma sample intake note",
"context": "This note goes into our lab wiki repository. Reviewers read it on GitHub, mostly in dark mode.",
"question": "",
"review": "Review verdict: blocked. This note is blocked by two Mermaid errors in the intake flowchart (D1) - a missing accDescr and a node id of `end` that will likely break parsing - and both the %%{init}%% th ...",
"passage": ""
}
Reply (a saved model run, abridged):
{
"lane": "fix",
"verdict": "needs_work",
"headline": "The intake note's Mermaid errors and every flagged warning are fixed, but two footnotes still lack a real source URL that only the author can supply, so it needs one more pass before it's ready.",
"finding_responses": [
{
"ref": "F1",
"action": "fix",
"note": "The H1 and three headings (\"Intake Steps\", \"Storage And Retrieval\", \"Sequence Of Messages\") were Title Case with no proper nouns; changed to sentence case."
},
{
"ref": "F2",
"action": "fix",
"note": "Added a --- rule after the italic context line, before the Overview section, matching the title/metadata/content structure."
},
{
"...": "18 more"
}
],
"changes": [
{
"ref": "F1",
"change": "Changed the H1 and three headings to sentence case: \"Plasma sample intake pipeline\", \"Intake steps\", \"Storage and retrieval\", \"Sequence of messages\"."
},
{
"ref": "F2",
"change": "Added a --- rule after the italic context line, before the Overview section."
},
{
"ref": "F3",
"change": "Unbolded the sentence in Overview and bolded only \"logged in the LIMS\"."
},
{
"...": "15 more"
}
],
"markdown": "# Plasma sample intake pipeline\n\n_Operations note for the Quillmere biobank intake bench, draft 3_\n\n---\n\n## 📋 Overview\n\nThis note describes how a plasma tube moves from the courier bag to the freezer. Every tube must be **logged in the LIMS** before it leaves the intake bench. Our 2025 internal audit found that 12% of tubes arrived above 8 C, which is why the temperature check comes first[^1].\n\n## 📥 Intake steps\n\n```mermaid\nflowchart LR\n accTitle: Plasma tube intake steps\n accDescr: A courier tube is scanned, checked for temperature and hemolysis, then quarantined if unsafe or aliquoted ...",
"open_questions": [
"Footnote [^2] (\"The courier hand-off is audited monthly\") has no source in the input; added the placeholder \"[^2]: Source to be added by the author.\" - please supply the real source and its URL.",
"Footnote [^1]'s definition (\"Quillmere biobank internal audit, 2025.\") has no URL in the input; left the citation text unchanged rather than invent one - please add the report's URL."
],
"next_steps": [
"Supply the real source and URL for footnote [^2] (the monthly courier hand-off audit claim).",
"Supply the URL for footnote [^1] (the 2025 internal audit).",
"Confirm the intranet host actually serves https before changing the retrieval-form link from http:// (kept as-is per F13).",
"Preview both Mermaid diagrams on GitHub in dark mode to confirm D1's renamed end_node shape and new classDef render as intended."
]
}
Worked example: diagram
The same note with its rejection paragraph as passage. The page lints the drafted code before it offers to insert it. Request body (abridged):
{
"task": "diagram",
"markdown": "# Plasma Sample Intake Pipeline\n\n_Operations note for the Quillmere biobank intake bench, draft 3_\n\n## 📋 Overview\n\nThis note describes how a plasma tube moves from the courier bag to the freezer. **Every tube must be logged in the LIMS before it leaves the int ...",
"facts": "{\"title\": \"Plasma Sample Intake Pipeline\", \"summary\": {\"errors\": 2, \"warnings\": 13, \"infos\": 5}, \"hint\": \"blocked\", \"findings\": [20 items], \"diagrams\": [2 items], ...}",
"title": "Plasma sample intake note",
"context": "This note goes into our lab wiki repository. Reviewers read it on GitHub, mostly in dark mode.",
"question": "",
"review": "",
"passage": "When the hemolysis index is too high the tube is rejected: the bench technician marks it rejected in the LIMS, prints a rejection label, emails the submitting site with the tube barcode and the reason, and moves the tube to the rejection rack, which is emptied every Friday by the biosafety officer. If the index is acceptable the tube goes on to aliquoting."
}
Reply (a saved model run, abridged):
{
"lane": "diagram",
"verdict": "needs_work",
"headline": "Drafted a flowchart for the hemolysis-index decision in the rejection passage, branching between the reject-and-log path and the aliquoting path; whether the weekly rack emptying is a per-tube step or a standing schedule note is worth the author confirming.",
"finding_responses": [],
"diagram_type": "flowchart",
"why_this_type": "A flowchart fits better than the runner-up sequence diagram because the passage is one branching procedure gated by a single decision (index acceptable or not), not a back-and-forth exchange of messages between the technician, LIMS, the submitting site and the biosafety officer.",
"source_quote": "When the hemolysis index is too high the tube is rejected: the bench technician marks it rejected in the LIMS, prints a rejection label, emails the submitting site with the tube barcode and the reason, and moves the tube to the rejection rack, which is emptied every Friday by the biosafety officer.",
"placement": "Intake Steps",
"mermaid": "flowchart LR\n accTitle: Hemolysis index rejection and aliquoting\n accDescr: If the hemolysis index fails, the bench technician rejects the tube in LIMS, notifies the submitting site, and routes it to the rejection rack that the biosafety officer empties every Friday; if it passes, the tube proceeds to aliquoting.\n hemolysis_index_acceptable{Hemolysis index acceptable?}\n hemolysis_index_acceptable -->|no| mark_tube_rejected_in_lims[❌ Mark tube rejected in LIMS]\n mark_tube_rejected_in_lims --> print_rejection_label[Print rejection label]\n print_rejection_label --> email_submitting_site[📤 Email submitting site]\n email_submitting_site --> move_tube_to_rejection_rack[Move tube to rejection rack]\n move_tube_to_rejection_rack -->|every Friday| biosafety_officer_empties_rack[🔄 Biosafety officer empties rack]\n hemolysis_index_acceptable -->|yes| proceed_to_aliquoting[✅ Proceed to aliquoting]",
"caption": "Shows the two outcomes of the hemolysis index check: the tube is rejected, logged, and routed to the rack for weekly disposal, or it proceeds to aliquoting.",
"open_questions": [
"The passage says the rejection rack 'is emptied every Friday by the biosafety officer' as a standing fact about the rack, not explicitly a per-tube next step; the diagram shows it as the flow's next step after moving the tube to the rack, which the author should confirm is the intended reading."
],
"next_steps": [
"Insert the diagram in the Intake Steps section, after the existing flowchart and before the Storage And Retrieval subsection.",
"Confirm whether the weekly rack-emptying belongs in the flow as a step or is better shown as a note about the rack's schedule.",
"Add the diagram to the document inside a mermaid fenced code block once placement and the open question are confirmed."
]
}
Truncation and partial results
If your balance sits between min_credits and hold_credits, the run still
executes with a reduced output cap and the job reports "truncated": true. The reply may
then stop mid-object: close the open strings, arrays and objects and parse what arrived (the page's
Recon.closeJson does exactly this), and treat every missing key as not answered. A fix
cut short has an incomplete markdown - never commit it; top up and run again.