ADR Studio — API

Paste a decision, get a committable ADR.

API tokens Open the app

Write and review architecture decision records from your own scripts

Send the material behind a technical decision — meeting notes, a design-doc excerpt, or an existing ADR draft — plus the options considered, the drivers and which way the team is leaning, and get back one structured JSON object: a ready / needs_work / not_ready posture, a section-by-section audit of the eight MADR sections, prioritized findings across context, options, drivers, outcome, consequences, scope, process and language, and a complete drafted ADR with title, status, context, decision drivers, options with pros and cons, the decision outcome, positive, negative and neutral consequences, and a confirmation step. The same review the web form runs, callable from a pre-merge hook, a design-review bot, or a script that sweeps every ADR already in docs/adr/. One paste in, one record out, no follow-up calls and no session state to carry.

ADR Studio is derived from the @wshobson/architecture-decision-records skill (MIT). POST /estimate is free; POST /run and POST /run-stream are billed in credits, and both should carry an Idempotency-Key header so a network retry cannot start a second, double-charged run.

Base URL and envelope

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

Every endpoint below is relative to that base, takes and returns JSON, and authenticates with Authorization: Bearer <token>. Every response is a one-key envelope: a success is {"data": …} and a failure is {"error": {"code": "…", "message": "…"}} with a matching HTTP status. Read data on 2xx and error.message otherwise — the helper in step 0 does exactly that, and every later sample assumes it.

StatusMeaning
400Malformed body — usually invalid JSON or a prescan_facts entry missing its id.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest reviewing a very large paste).
404Unknown job or record id.
409An Idempotency-Key replay that conflicts with a different body.
429Rate limited — back off and retry.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your shell environment in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered review runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"adr-studio"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "adr-studio"})["token"]
const { token } = await api("POST", "/guest", { slug: "adr-studio" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "adr-studio"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"adr-studio"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "adr-studio" })["token"]
$token = api("POST", "/guest", ["slug" => "adr-studio"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "adr-studio" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:adr-studio, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before reviewing a long design document or a whole directory of ADRs.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'

# {"subject_type":"user","subject_id":"usr_...","credits":41200}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are sweeping a directory of existing ADRs and want a ceiling before spending credits.

Input fieldTypeNotes
materialstringThe main paste and the primary source of truth — decision notes, meeting minutes, a design-doc excerpt, or an existing ADR draft. Everything in the drafted record traces back to this text (plus options, drivers and context); nothing else is invented. Clipped middle-out if very long, with a marker showing where.
projectstring, optionalThe system or team the decision belongs to, e.g. OrderFlow checkout. Used to name the review and to scope the ADR title.
decision_titlestring, optionalYour working title. A vague one (Primary datastore) is replaced by a concrete Use X for Y title in the drafted ADR, and the swap is raised as a finding.
optionsstring, optionalOptions considered, roughly one per line — PostgreSQL\nDynamoDB\nKeep MySQL. Leading bullets or 1. numbering are stripped. Omit it and the options are extracted from material instead; a decision with fewer than two options is always flagged.
driversstring, optionalThe constraints and decision drivers: throughput floors, deadlines, compliance, team experience. These become adr.decision_drivers, and their absence is the single most common not_ready cause.
leaningstring, optionalThe option the team chose or leans toward, e.g. PostgreSQL. It seeds adr.decision and the chosen verdict in options_analysis — but it is audited, not accepted: if the material does not support it, a finding says so.
contextstring, optionalAnything else that shapes the record: team size, the stack, deadlines, what ops already runs, an ADR numbering convention to follow. Clipped if very long.
prescan_factsobject, optionalWhat a client-side scan mechanically recognized: {"items": [], "flags": []}. Each entry is {id, label}. Item ids look like mat:words, opt:count and sec:<section> (a detected MADR heading); flag ids are <check>:<name>struct:missing-context, struct:missing-decision, struct:missing-consequences, options:single, options:no-cons, drivers:none, rev:none, lang:weasel, lang:tbd, title:generic. Every flag id you send comes back in coverage_check. The web UI fills this from its own free prescan; API callers may omit the field or send the two empty arrays.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out.
cat > input.json <<'JSON'
{
  "project": "OrderFlow checkout",
  "decision_title": "Primary datastore",
  "material": "Meeting notes 2026-03-11. We need to pick the primary datastore for the new OrderFlow checkout service. MySQL is what we run today, but schema migrations block deploys and we hit row-lock contention on the orders table at peak. We compared PostgreSQL and DynamoDB. PostgreSQL keeps the relational model, adds JSONB for the line-item payloads, and builds indexes concurrently. DynamoDB scales without babysitting but forces us to model access patterns up front and we lose ad-hoc reporting. Leaning PostgreSQL. Ops already runs one Postgres cluster for billing.",
  "options": "PostgreSQL\nDynamoDB\nKeep MySQL",
  "drivers": "Must sustain 3k checkout writes/sec at peak. Migrations must not require downtime. Finance needs ad-hoc SQL against orders. Team of 6; two engineers have run Postgres in production, none have run DynamoDB.",
  "leaning": "PostgreSQL",
  "context": "Go services on EKS, cutover must land in the Q3 release.",
  "prescan_facts": {
    "items": [
      {"id": "mat:words", "label": "88 words of decision material"},
      {"id": "opt:count", "label": "3 options considered: PostgreSQL; DynamoDB; Keep MySQL"}
    ],
    "flags": [
      {"id": "title:generic", "label": "Title \"Primary datastore\" is too generic"},
      {"id": "rev:none", "label": "Nothing on reversibility - say what undoing this would cost"},
      {"id": "options:no-cons", "label": "No drawback, cost or trade-off vocabulary found anywhere"}
    ]
  }
}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
payload = {
    "project": "OrderFlow checkout",
    "decision_title": "Primary datastore",
    "material": (
        "Meeting notes 2026-03-11. We need to pick the primary datastore for the new "
        "OrderFlow checkout service. MySQL is what we run today, but schema migrations "
        "block deploys and we hit row-lock contention on the orders table at peak. We "
        "compared PostgreSQL and DynamoDB. PostgreSQL keeps the relational model, adds "
        "JSONB for the line-item payloads, and builds indexes concurrently. DynamoDB "
        "scales without babysitting but forces us to model access patterns up front and "
        "we lose ad-hoc reporting. Leaning PostgreSQL. Ops already runs one Postgres "
        "cluster for billing."
    ),
    "options": "PostgreSQL\nDynamoDB\nKeep MySQL",
    "drivers": (
        "Must sustain 3k checkout writes/sec at peak. Migrations must not require "
        "downtime. Finance needs ad-hoc SQL against orders. Team of 6; two engineers "
        "have run Postgres in production, none have run DynamoDB."
    ),
    "leaning": "PostgreSQL",
    "context": "Go services on EKS, cutover must land in the Q3 release.",
    "prescan_facts": {
        "items": [
            {"id": "mat:words", "label": "88 words of decision material"},
            {"id": "opt:count", "label": "3 options considered: PostgreSQL; DynamoDB; Keep MySQL"},
        ],
        "flags": [
            {"id": "title:generic", "label": 'Title "Primary datastore" is too generic'},
            {"id": "rev:none", "label": "Nothing on reversibility"},
            {"id": "options:no-cons", "label": "No drawback or trade-off vocabulary found"},
        ],
    },
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const payload = {
  project: "OrderFlow checkout",
  decision_title: "Primary datastore",
  material:
    "Meeting notes 2026-03-11. We need to pick the primary datastore for the new " +
    "OrderFlow checkout service. MySQL is what we run today, but schema migrations " +
    "block deploys and we hit row-lock contention on the orders table at peak. We " +
    "compared PostgreSQL and DynamoDB. PostgreSQL keeps the relational model, adds " +
    "JSONB for the line-item payloads, and builds indexes concurrently. DynamoDB " +
    "scales without babysitting but forces us to model access patterns up front and " +
    "we lose ad-hoc reporting. Leaning PostgreSQL. Ops already runs one Postgres " +
    "cluster for billing.",
  options: "PostgreSQL\nDynamoDB\nKeep MySQL",
  drivers:
    "Must sustain 3k checkout writes/sec at peak. Migrations must not require downtime. " +
    "Finance needs ad-hoc SQL against orders. Team of 6; two engineers have run Postgres " +
    "in production, none have run DynamoDB.",
  leaning: "PostgreSQL",
  context: "Go services on EKS, cutover must land in the Q3 release.",
  prescan_facts: {
    items: [
      { id: "mat:words", label: "88 words of decision material" },
      { id: "opt:count", label: "3 options considered: PostgreSQL; DynamoDB; Keep MySQL" },
    ],
    flags: [
      { id: "title:generic", label: 'Title "Primary datastore" is too generic' },
      { id: "rev:none", label: "Nothing on reversibility" },
      { id: "options:no-cons", label: "No drawback or trade-off vocabulary found" },
    ],
  },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
payload := map[string]any{
	"project":        "OrderFlow checkout",
	"decision_title": "Primary datastore",
	"material": "Meeting notes 2026-03-11. We need to pick the primary datastore for the new " +
		"OrderFlow checkout service. MySQL is what we run today, but schema migrations block " +
		"deploys and we hit row-lock contention on the orders table at peak. We compared " +
		"PostgreSQL and DynamoDB. PostgreSQL keeps the relational model, adds JSONB for the " +
		"line-item payloads, and builds indexes concurrently. DynamoDB scales without " +
		"babysitting but forces us to model access patterns up front and we lose ad-hoc " +
		"reporting. Leaning PostgreSQL. Ops already runs one Postgres cluster for billing.",
	"options": "PostgreSQL\nDynamoDB\nKeep MySQL",
	"drivers": "Must sustain 3k checkout writes/sec at peak. Migrations must not require " +
		"downtime. Finance needs ad-hoc SQL against orders. Team of 6; two engineers have " +
		"run Postgres in production, none have run DynamoDB.",
	"leaning": "PostgreSQL",
	"context": "Go services on EKS, cutover must land in the Q3 release.",
	"prescan_facts": map[string]any{
		"items": []any{
			map[string]string{"id": "mat:words", "label": "88 words of decision material"},
			map[string]string{"id": "opt:count", "label": "3 options considered"},
		},
		"flags": []any{
			map[string]string{"id": "title:generic", "label": "Title is too generic"},
			map[string]string{"id": "rev:none", "label": "Nothing on reversibility"},
			map[string]string{"id": "options:no-cons", "label": "No trade-off vocabulary found"},
		},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
// A text block keeps the JSON readable; \\n stays a JSON escape, not a real newline.
String jsonPayload = """
    {"project": "OrderFlow checkout",
     "decision_title": "Primary datastore",
     "material": "Meeting notes 2026-03-11. We need to pick the primary datastore for the new OrderFlow checkout service. MySQL is what we run today, but schema migrations block deploys and we hit row-lock contention on the orders table at peak. We compared PostgreSQL and DynamoDB. PostgreSQL keeps the relational model, adds JSONB for the line-item payloads, and builds indexes concurrently. DynamoDB scales without babysitting but forces us to model access patterns up front and we lose ad-hoc reporting. Leaning PostgreSQL. Ops already runs one Postgres cluster for billing.",
     "options": "PostgreSQL\\nDynamoDB\\nKeep MySQL",
     "drivers": "Must sustain 3k checkout writes/sec at peak. Migrations must not require downtime. Finance needs ad-hoc SQL against orders. Team of 6; two engineers have run Postgres in production, none have run DynamoDB.",
     "leaning": "PostgreSQL",
     "context": "Go services on EKS, cutover must land in the Q3 release.",
     "prescan_facts": {
       "items": [{"id": "mat:words", "label": "88 words of decision material"},
                 {"id": "opt:count", "label": "3 options considered"}],
       "flags": [{"id": "title:generic", "label": "Title is too generic"},
                 {"id": "rev:none", "label": "Nothing on reversibility"},
                 {"id": "options:no-cons", "label": "No trade-off vocabulary found"}]}}
    """;

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
payload = {
  project: "OrderFlow checkout",
  decision_title: "Primary datastore",
  material: "Meeting notes 2026-03-11. We need to pick the primary datastore for the new " \
            "OrderFlow checkout service. MySQL is what we run today, but schema migrations " \
            "block deploys and we hit row-lock contention on the orders table at peak. We " \
            "compared PostgreSQL and DynamoDB. PostgreSQL keeps the relational model, adds " \
            "JSONB for the line-item payloads, and builds indexes concurrently. DynamoDB " \
            "scales without babysitting but forces us to model access patterns up front and " \
            "we lose ad-hoc reporting. Leaning PostgreSQL. Ops already runs one Postgres " \
            "cluster for billing.",
  options: "PostgreSQL\nDynamoDB\nKeep MySQL",
  drivers: "Must sustain 3k checkout writes/sec at peak. Migrations must not require " \
           "downtime. Finance needs ad-hoc SQL against orders. Team of 6; two engineers " \
           "have run Postgres in production, none have run DynamoDB.",
  leaning: "PostgreSQL",
  context: "Go services on EKS, cutover must land in the Q3 release.",
  prescan_facts: {
    items: [{ id: "mat:words", label: "88 words of decision material" },
            { id: "opt:count", label: "3 options considered" }],
    flags: [{ id: "title:generic", label: "Title is too generic" },
            { id: "rev:none", label: "Nothing on reversibility" },
            { id: "options:no-cons", label: "No trade-off vocabulary found" }]
  }
}

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$payload = [
    "project"        => "OrderFlow checkout",
    "decision_title" => "Primary datastore",
    "material"       => "Meeting notes 2026-03-11. We need to pick the primary datastore for "
                      . "the new OrderFlow checkout service. MySQL is what we run today, but "
                      . "schema migrations block deploys and we hit row-lock contention on the "
                      . "orders table at peak. We compared PostgreSQL and DynamoDB. PostgreSQL "
                      . "keeps the relational model, adds JSONB for the line-item payloads, and "
                      . "builds indexes concurrently. DynamoDB scales without babysitting but "
                      . "forces us to model access patterns up front and we lose ad-hoc "
                      . "reporting. Leaning PostgreSQL. Ops already runs one Postgres cluster "
                      . "for billing.",
    "options"        => "PostgreSQL\nDynamoDB\nKeep MySQL",
    "drivers"        => "Must sustain 3k checkout writes/sec at peak. Migrations must not "
                      . "require downtime. Finance needs ad-hoc SQL against orders. Team of 6; "
                      . "two engineers have run Postgres in production, none have run DynamoDB.",
    "leaning"        => "PostgreSQL",
    "context"        => "Go services on EKS, cutover must land in the Q3 release.",
    "prescan_facts"  => [
        "items" => [
            ["id" => "mat:words", "label" => "88 words of decision material"],
            ["id" => "opt:count", "label" => "3 options considered"],
        ],
        "flags" => [
            ["id" => "title:generic", "label" => "Title is too generic"],
            ["id" => "rev:none", "label" => "Nothing on reversibility"],
            ["id" => "options:no-cons", "label" => "No trade-off vocabulary found"],
        ],
    ],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var payload = new {
    project = "OrderFlow checkout",
    decision_title = "Primary datastore",
    material = "Meeting notes 2026-03-11. We need to pick the primary datastore for the new " +
               "OrderFlow checkout service. MySQL is what we run today, but schema migrations " +
               "block deploys and we hit row-lock contention on the orders table at peak. We " +
               "compared PostgreSQL and DynamoDB. PostgreSQL keeps the relational model, adds " +
               "JSONB for the line-item payloads, and builds indexes concurrently. DynamoDB " +
               "scales without babysitting but forces us to model access patterns up front and " +
               "we lose ad-hoc reporting. Leaning PostgreSQL. Ops already runs one Postgres " +
               "cluster for billing.",
    options = "PostgreSQL\nDynamoDB\nKeep MySQL",
    drivers = "Must sustain 3k checkout writes/sec at peak. Migrations must not require " +
              "downtime. Finance needs ad-hoc SQL against orders. Team of 6; two engineers " +
              "have run Postgres in production, none have run DynamoDB.",
    leaning = "PostgreSQL",
    context = "Go services on EKS, cutover must land in the Q3 release.",
    prescan_facts = new {
        items = new[] {
            new { id = "mat:words", label = "88 words of decision material" },
            new { id = "opt:count", label = "3 options considered" },
        },
        flags = new[] {
            new { id = "title:generic", label = "Title is too generic" },
            new { id = "rev:none", label = "Nothing on reversibility" },
            new { id = "options:no-cons", label = "No trade-off vocabulary found" },
        },
    },
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

prescan_facts.flags is how you make the review answer for things you already know about. Send {"items": [{"id": "mat:words", "label": "88 words"}], "flags": [{"id": "options:single", "label": "Only one option is recorded"}, {"id": "rev:none", "label": "Nothing on reversibility"}]} and every flag id comes back in coverage_check — addressed by a finding, or set aside with the reason. Nothing you flag is silently dropped, which makes it the field to assert on in a pipeline check.

Step 4 — Run the review and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 30–90 s, since the reply carries a fully drafted ADR as well as the audit). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The review is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the posture, the section audit, the prioritized findings and the drafted ADR, then save the whole object to review.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: adr-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# unwrap the review once, then read it
echo "$JOB" | jq -r '.data.output.output' > review.json

jq -r '
  "\(.review_name) [\(.posture)]: \(.verdict)",
  "",
  "SECTION AUDIT",
  (.section_audit[] | "  \(.section) [\(.status)] - \(.reading)"),
  "",
  "FINDINGS",
  (.findings[] | "  [\(.priority)] \(.id) \(.category) \(.section): \(.problem)"),
  "",
  "DRAFTED ADR",
  "  # \(.adr.title)  (\(.adr.status))",
  "  Drivers:      \(.adr.decision_drivers | join("; "))",
  (.adr.options[] | "  Option \(.name): +\(.pros | join(", ")) / -\(.cons | join(", "))"),
  "  Decision:     \(.adr.decision)",
  "  Positive:     \(.adr.consequences.positive | join("; "))",
  "  Negative:     \(.adr.consequences.negative | join("; "))",
  "  Neutral:      \(.adr.consequences.neutral | join("; "))",
  "  Confirmation: \(.adr.confirmation)",
  "",
  "OPTIONS ANALYSIS",
  (.options_analysis[] | "  \(.option) [\(.verdict)] - \(.reason)"),
  "",
  "QUICK WINS",
  (.quick_wins[] | "  - \(.)"),
  "",
  "FOCUS AREAS",
  (.focus_areas[] | "  \(.area) - \(.why)"),
  "",
  "COVERAGE",
  (.coverage_check[] | "  \(.id): \(if .addressed then "ok" else "SET ASIDE" end) - \(.note)")' \
  review.json

# fail the pipeline on anything critical
jq -e '[.findings[] | select(.priority == "critical")] | length == 0' review.json > /dev/null \
  || { echo "critical findings present"; exit 1; }

# and on a record that still carries TODO placeholders
jq -e '[.. | strings | select(test("\\[TODO"))] | length == 0' review.json > /dev/null \
  || { echo "drafted ADR still has TODO placeholders"; exit 1; }
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "adr-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
review = json.loads(raw) if isinstance(raw, str) else raw

print(f'{review["review_name"]} [{review["posture"]}]: {review["verdict"]}')
for s in review["section_audit"]:
    print(f'  {s["section"]:<20} {s["status"]:<9} {s["reading"]}')
for f in review["findings"]:
    print(f'  [{f["priority"]:>8}] {f["id"]} {f["category"]} / {f["section"]}')
    print(f'      L:{f["likelihood"]}/S:{f["severity"]} {f["problem"]}')
    print(f'      fix: {f["fix"]}')
    if f.get("snippet"):
        print("      snippet:", f["snippet"].splitlines()[0], "...")

adr = review["adr"]
print(f'\n# {adr["title"]} ({adr["status"]})')
print("Drivers:", "; ".join(adr["decision_drivers"]))
for o in adr["options"]:
    print(f'  {o["name"]}: +{len(o["pros"])} pros / -{len(o["cons"])} cons')
print("Decision:", adr["decision"])
for kind in ("positive", "negative", "neutral"):
    for c in adr["consequences"].get(kind, []):
        print(f'  {kind}: {c}')
print("Confirmation:", adr["confirmation"])

for o in review["options_analysis"]:
    print(f'  {o["option"]} [{o["verdict"]}] - {o["reason"]}')
for w in review["quick_wins"]:
    print("  win:", w)
for a in review["focus_areas"]:
    print(f'  focus {a["area"]} {a["finding_ids"]} - {a["why"]}')
for c in review["coverage_check"]:
    print(f'  {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')

with open("review.json", "w", encoding="utf-8") as fh:
    json.dump(review, fh, indent=2)

critical = [f for f in review["findings"] if f["priority"] == "critical"]
if critical:
    raise SystemExit(f"{len(critical)} critical finding(s)")
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const review = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${review.review_name} [${review.posture}]: ${review.verdict}`);
for (const s of review.section_audit) {
  console.log(`  ${s.section} [${s.status}]: ${s.reading}`);
}
for (const f of review.findings) {
  console.log(`  [${f.priority}] ${f.id} ${f.category} / ${f.section}`);
  console.log(`      L:${f.likelihood}/S:${f.severity} - ${f.fix}`);
}

const adr = review.adr;
console.log(`\n# ${adr.title} (${adr.status})`);
console.log("Drivers:", adr.decision_drivers.join("; "));
for (const o of adr.options) {
  console.log(`  ${o.name}: ${o.pros.length} pros / ${o.cons.length} cons`);
}
console.log("Decision:", adr.decision);
for (const kind of ["positive", "negative", "neutral"]) {
  for (const c of adr.consequences[kind] ?? []) console.log(`  ${kind}: ${c}`);
}
console.log("Confirmation:", adr.confirmation);

for (const o of review.options_analysis) console.log(`  ${o.option} [${o.verdict}] - ${o.reason}`);
for (const w of review.quick_wins) console.log(`  win: ${w}`);
for (const a of review.focus_areas) {
  console.log(`  focus ${a.area} (${a.finding_ids.join(", ")}): ${a.why}`);
}
for (const c of review.coverage_check) {
  console.log(`  ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}

writeFileSync("review.json", JSON.stringify(review, null, 2));

const critical = review.findings.filter((f) => f.priority === "critical");
if (critical.length) process.exitCode = 1;
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}

// job.Output is {"output": "<json string>"} — unwrap, then unmarshal:
type Review struct {
	ReviewName    string   `json:"review_name"`
	Posture       string   `json:"posture"`
	Verdict       string   `json:"verdict"`
	ExecSummary   string   `json:"exec_summary"`
	Assumptions   []string `json:"assumptions"`
	OpenQuestions []string `json:"open_questions"`
	SectionAudit  []struct {
		Section, Status, Reading string
	} `json:"section_audit"`
	Findings []struct {
		ID, Category, Severity, Likelihood, Priority string
		Section, Problem, Impact, Fix, Snippet       string
	} `json:"findings"`
	ADR struct {
		Title           string   `json:"title"`
		Status          string   `json:"status"`
		Context         string   `json:"context"`
		DecisionDrivers []string `json:"decision_drivers"`
		Options         []struct {
			Name string   `json:"name"`
			Pros []string `json:"pros"`
			Cons []string `json:"cons"`
		} `json:"options"`
		Decision     string `json:"decision"`
		Consequences struct {
			Positive []string `json:"positive"`
			Negative []string `json:"negative"`
			Neutral  []string `json:"neutral"`
		} `json:"consequences"`
		Confirmation string `json:"confirmation"`
	} `json:"adr"`
	OptionsAnalysis []struct {
		Option, Verdict, Reason string
	} `json:"options_analysis"`
	CoverageCheck []struct {
		ID, Note  string
		Addressed bool
	} `json:"coverage_check"`
	QuickWins  []string `json:"quick_wins"`
	FocusAreas []struct {
		Area, Why  string
		FindingIDs []string `json:"finding_ids"`
	} `json:"focus_areas"`
	Summary string `json:"summary"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var review Review
json.Unmarshal([]byte(wrapper.Output), &review)

fmt.Printf("%s [%s]: %s\n", review.ReviewName, review.Posture, review.Verdict)
for _, s := range review.SectionAudit {
	fmt.Printf("  %s [%s]: %s\n", s.Section, s.Status, s.Reading)
}
for _, f := range review.Findings {
	fmt.Printf("  [%s] %s %s %s: %s\n", f.Priority, f.ID, f.Category, f.Section, f.Problem)
}
fmt.Printf("\n# %s (%s)\n", review.ADR.Title, review.ADR.Status)
fmt.Println("Drivers:", strings.Join(review.ADR.DecisionDrivers, "; "))
for _, o := range review.ADR.Options {
	fmt.Printf("  %s: %d pros / %d cons\n", o.Name, len(o.Pros), len(o.Cons))
}
fmt.Println("Decision:", review.ADR.Decision)
fmt.Println("Negative:", strings.Join(review.ADR.Consequences.Negative, "; "))
for _, a := range review.FocusAreas {
	fmt.Printf("  focus %s %v: %s\n", a.Area, a.FindingIDs, a.Why)
}
os.WriteFile("review.json", []byte(wrapper.Output), 0o644)
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The review is at data.output.output as a JSON string — parse it again, then read
// review_name, posture, verdict, exec_summary, assumptions[], open_questions[],
// section_audit[] (section/status/reading),
// findings[] (id/category/severity/likelihood/priority/section/problem/impact/fix/snippet),
// adr (title, status, context, decision_drivers[], options[{name, pros[], cons[]}],
//   decision, consequences{positive[], negative[], neutral[]}, confirmation),
// options_analysis[] (option/verdict/reason), coverage_check[] (id/addressed/note),
// quick_wins[], focus_areas[] (area/why/finding_ids[]) and summary.
// Finally keep the review on disk:
//   Files.writeString(Path.of("review.json"), reviewJson);
started = api("POST", "/run", payload)

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
review = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{review["review_name"]} [#{review["posture"]}]: #{review["verdict"]}"
review["section_audit"].each { |s| puts "  #{s["section"]} [#{s["status"]}]: #{s["reading"]}" }
review["findings"].each do |f|
  puts "  [#{f["priority"]}] #{f["id"]} #{f["category"]} / #{f["section"]}"
  puts "      L:#{f["likelihood"]}/S:#{f["severity"]} - #{f["fix"]}"
end

adr = review["adr"]
puts "\n# #{adr["title"]} (#{adr["status"]})"
puts "Drivers: #{adr["decision_drivers"].join("; ")}"
adr["options"].each { |o| puts "  #{o["name"]}: #{o["pros"].size} pros / #{o["cons"].size} cons" }
puts "Decision: #{adr["decision"]}"
%w[positive negative neutral].each do |kind|
  (adr["consequences"][kind] || []).each { |c| puts "  #{kind}: #{c}" }
end
puts "Confirmation: #{adr["confirmation"]}"

review["options_analysis"].each { |o| puts "  #{o["option"]} [#{o["verdict"]}] - #{o["reason"]}" }
review["quick_wins"].each { |w| puts "  win: #{w}" }
review["focus_areas"].each { |a| puts "  focus #{a["area"]} #{a["finding_ids"].join(", ")}" }
review["coverage_check"].each { |c| puts "  #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }

File.write("review.json", JSON.pretty_generate(review))
exit 1 if review["findings"].any? { |f| f["priority"] == "critical" }
$started = api("POST", "/run", $payload);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$review = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$review['review_name']} [{$review['posture']}]: {$review['verdict']}\n";
foreach ($review["section_audit"] as $s) {
    echo "  {$s['section']} [{$s['status']}]: {$s['reading']}\n";
}
foreach ($review["findings"] as $f) {
    echo "  [{$f['priority']}] {$f['id']} {$f['category']} / {$f['section']}\n";
    echo "      L:{$f['likelihood']}/S:{$f['severity']} - {$f['fix']}\n";
}

$adr = $review["adr"];
echo "\n# {$adr['title']} ({$adr['status']})\n";
echo "Drivers: " . implode("; ", $adr["decision_drivers"]) . "\n";
foreach ($adr["options"] as $o) {
    echo "  {$o['name']}: " . count($o["pros"]) . " pros / " . count($o["cons"]) . " cons\n";
}
echo "Decision: {$adr['decision']}\n";
foreach (["positive", "negative", "neutral"] as $kind) {
    foreach ($adr["consequences"][$kind] ?? [] as $c) {
        echo "  $kind: $c\n";
    }
}
echo "Confirmation: {$adr['confirmation']}\n";

foreach ($review["options_analysis"] as $o) {
    echo "  {$o['option']} [{$o['verdict']}] - {$o['reason']}\n";
}
foreach ($review["quick_wins"] as $w) {
    echo "  win: $w\n";
}
foreach ($review["focus_areas"] as $a) {
    echo "  focus {$a['area']}: " . implode(", ", $a["finding_ids"]) . "\n";
}
foreach ($review["coverage_check"] as $c) {
    echo "  {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}

file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var review = doc.RootElement;

Console.WriteLine($"{review.GetProperty("review_name")} " +
                  $"[{review.GetProperty("posture")}]: {review.GetProperty("verdict")}");
foreach (var s in review.GetProperty("section_audit").EnumerateArray())
{
    Console.WriteLine($"  {s.GetProperty("section")} [{s.GetProperty("status")}]: " +
                      $"{s.GetProperty("reading")}");
}
foreach (var f in review.GetProperty("findings").EnumerateArray())
{
    Console.WriteLine($"  [{f.GetProperty("priority")}] {f.GetProperty("id")} " +
                      $"{f.GetProperty("category")} / {f.GetProperty("section")} " +
                      $"(L:{f.GetProperty("likelihood")}/S:{f.GetProperty("severity")})");
}

var adr = review.GetProperty("adr");
Console.WriteLine($"\n# {adr.GetProperty("title")} ({adr.GetProperty("status")})");
foreach (var d in adr.GetProperty("decision_drivers").EnumerateArray())
    Console.WriteLine($"  driver: {d}");
foreach (var o in adr.GetProperty("options").EnumerateArray())
    Console.WriteLine($"  option {o.GetProperty("name")}: " +
                      $"{o.GetProperty("pros").GetArrayLength()} pros / " +
                      $"{o.GetProperty("cons").GetArrayLength()} cons");
Console.WriteLine($"  decision: {adr.GetProperty("decision")}");
foreach (var c in adr.GetProperty("consequences").GetProperty("negative").EnumerateArray())
    Console.WriteLine($"  negative: {c}");
foreach (var a in review.GetProperty("focus_areas").EnumerateArray())
    Console.WriteLine($"  focus {a.GetProperty("area")}: {a.GetProperty("why")}");

await File.WriteAllTextAsync("review.json", rawText!);

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run.

The review object — output schema

One JSON object, always the same shape. Every array is present, and the record is grounded in the material you pasted alone: every claim in the drafted ADR traces back to material, options, drivers or context, and benchmark numbers, vendor limits, headcounts and dates are never invented — anything the record needs but the paste does not supply arrives as an explicit [TODO: …] placeholder in the ADR text plus an entry in open_questions. Expect three to ten findings on a typical paste; a genuinely complete ADR draft may honestly yield one or two, and findings is never empty.

FieldTypeMeaning
review_namestringA short name for the review, naming the decision and the system — e.g. Primary datastore for OrderFlow checkout.
posturestringready | needs_work | not_ready. See the table below.
verdictstringOne sentence: the posture and the single most important thing to fix.
exec_summarystringTwo to four short paragraphs, separated by blank lines: what is being decided, how well the material supports a durable record, what the drafted ADR says, and what must happen before it is accepted.
assumptionsstring[]Assumptions the review had to make. Read these first — a wrong assumption invalidates the record built on it.
open_questionsstring[]Questions the team must answer before accepting the ADR. Each [TODO] in the drafted text has a matching question here.
section_auditarray{section, status, reading} — one row per MADR section: Title, Status, Context, Decision drivers, Options, Decision, Consequences, Confirmation. status is solid | thin | missing; reading is one sentence on what the paste provides for that section.
findingsarrayThe prioritized findings table — ids AD-001, AD-002, … in sequence, at least one entry. Columns are listed below.
adrobjectThe drafted record, MADR-shaped: title, status (proposed | accepted), context, decision_drivers (string[]), options ({name, pros[], cons[]}), decision, consequences ({positive[], negative[], neutral[]}) and confirmation. title, context and decision are always present and non-empty, and consequences always carries at least one positive and one negative entry.
options_analysisarray{option, verdict, reason}verdict is chosen | rejected | viable, with one sentence of reasoning. Exactly one chosen entry unless no option can be chosen yet.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. See the semantics below.
quick_winsstring[]Small edits the team can make today — naming the confirmation date, adding the status-quo option, deleting one unfalsifiable sentence. May be empty.
focus_areasarray{area, why, finding_ids} — what to fix first, one sentence tied to the review, and the finding ids that motivate it. Every id in finding_ids exists in findings.
summarystringTwo or three sentences a tech lead can paste into the PR that adds this ADR.

The three posture values:

postureWhat it means
readyThe drafted ADR carries no [TODO] markers, has at least two options with real pros and cons, named drivers, and both positive and negative consequences. Findings still exist, but they are polish, not repairs. A genuinely complete paste lands here rather than having severity manufactured for it.
needs_workA solid core with real gaps: options listed without drawbacks, drivers implied but never stated, no reversibility or exit cost recorded, a confirmation step nobody owns.
not_readyCore sections had to be invented as placeholders: one option and a conclusion instead of a decision, no context a future reader could use, or a paste covering several unrelated decisions at once.

Each entry in findings:

ColumnMeaning
idSequential AD-001, AD-002, … — the stable handle referenced from focus_areas[].finding_ids.
categorycontext | options | drivers | outcome | consequences | scope | process | language. scope is for a paste holding several decisions; language for unfalsifiable wording ("obviously", "industry standard").
severitylow | medium | high — how much it costs when it bites.
likelihoodlow | medium | high — how likely it is to bite.
prioritycritical | high | medium | low — severity by likelihood. critical is reserved for gaps that make the record unusable or the decision unsafe (no alternatives considered, no recorded cost on an irreversible choice, a decision that contradicts a stated driver), so sort on this field and work top-down. This is also the field to gate a pipeline on.
sectionThe ADR section the finding concerns — one of the eight section_audit section names.
problemWhat is wrong or missing in the pasted material, quoting its actual text.
impactWhat it costs a future reader or the team.
fixThe specific change to make, or the question to answer.
snippetQuoted text from the paste, or suggested replacement text. Empty string when quoting would add nothing.

coverage_check semantics:

CaseWhat you get
Every flag id you sentEach prescan_facts.flags id appears in coverage_check exactly once. Nothing you flagged is silently dropped, which makes this the field to assert on in a pipeline check. Ids in prescan_facts.items are not reconciled here — they ground the readings in section_audit instead.
addressed: trueThe flag is covered by the review; note says how it was confirmed and where the drafted ADR fixes it.
addressed: falseThe flag was deliberately set aside; note gives the reason — a lint that fired but is not a real problem for this decision (a two-word title that is a well-known internal codename, a "missing" section the paste covers under another heading).
Nothing sentOmit prescan_facts, or send the two empty arrays, and coverage_check comes back empty. The rest of the review is unaffected.

A small, realistic result for the OrderFlow datastore notes above, trimmed for length:

{
  "review_name": "Primary datastore for OrderFlow checkout",
  "posture": "needs_work",
  "verdict": "The decision and its drivers are sound, but nothing records what undoing the
              migration would cost, so the record cannot tell a future team how much scrutiny
              this choice deserved.",
  "exec_summary": "The team is choosing the primary datastore for a new checkout service and is
                   leaning PostgreSQL, with MySQL as the status quo and DynamoDB as the
                   scale-out alternative.

                   The material supports a real record: three named options, a throughput
                   floor, a no-downtime migration constraint and honest team-experience
                   limits. What it does not supply is the cost of reversing the move, any
                   drawback for the chosen option, or who confirms the 3k writes/sec claim.

                   The drafted ADR chooses PostgreSQL because it satisfies the ad-hoc SQL
                   driver that DynamoDB cannot, and records the operational cost of a second
                   cluster as a negative consequence. Confirmation is a [TODO] until the team
                   names a load-test target and a review date.",
  "assumptions": [
    "The 3k writes/sec figure is a peak requirement, not a measured current load.",
    "'Keep MySQL' means the existing cluster and schema, not a managed migration to a newer version."
  ],
  "open_questions": [
    "What would migrating back off PostgreSQL cost once checkout writes are live?",
    "Who owns the load test that confirms 3k writes/sec, and by when?"
  ],
  "section_audit": [
    { "section": "Title", "status": "thin",
      "reading": "'Primary datastore' names a topic, not a decision; the record needs the chosen option in the title." },
    { "section": "Status", "status": "missing",
      "reading": "Nothing in the paste says whether this is proposed or already accepted." },
    { "section": "Context", "status": "solid",
      "reading": "Migration-blocked deploys and row-lock contention on the orders table give a concrete problem statement." },
    { "section": "Decision drivers", "status": "solid",
      "reading": "Throughput floor, no-downtime migrations, finance's ad-hoc SQL and the team's Postgres-only experience are all stated." },
    { "section": "Options", "status": "solid",
      "reading": "Three options are named, with real differences between them." },
    { "section": "Decision", "status": "thin",
      "reading": "'Leaning PostgreSQL' states a preference but never ties it to a driver." },
    { "section": "Consequences", "status": "thin",
      "reading": "DynamoDB's costs are described; PostgreSQL's are not mentioned at all." },
    { "section": "Confirmation", "status": "missing",
      "reading": "No measurement, review date or fitness function appears anywhere in the paste." }
  ],
  "findings": [
    { "id": "AD-001", "category": "consequences",
      "severity": "high", "likelihood": "high", "priority": "critical",
      "section": "Consequences",
      "problem": "The paste records costs only for the rejected option ('we lose ad-hoc reporting'
                  for DynamoDB) and none for the chosen one.",
      "impact": "A record whose chosen option has only upsides reads as advocacy, and a future
                 team cannot tell what was knowingly accepted.",
      "fix": "State the operational cost explicitly: a second Postgres cluster to run, connection
              pooling at 3k writes/sec, and the data migration itself.",
      "snippet": "suggested negative: 'Ops now runs a second Postgres cluster; checkout writes at
                  peak require a pooler in front of it.'" },
    { "id": "AD-002", "category": "outcome",
      "severity": "medium", "likelihood": "high", "priority": "high",
      "section": "Decision",
      "problem": "'Leaning PostgreSQL' is a preference, not a decision tied to a driver.",
      "impact": "The one thing a future reader needs — why this option beat the others — is left implicit.",
      "fix": "Write the outcome as 'Chosen option: PostgreSQL, because it is the only candidate
              that satisfies finance's ad-hoc SQL driver while clearing the throughput floor.'",
      "snippet": "" },
    { "id": "AD-003", "category": "process",
      "severity": "medium", "likelihood": "medium", "priority": "medium",
      "section": "Confirmation",
      "problem": "No reversibility note and no confirmation step: the paste never says what
                  migrating back would cost or how the throughput claim gets verified.",
      "impact": "An irreversible-looking choice gets the same scrutiny as a cheap one, and the
                 3k writes/sec driver is never tested.",
      "fix": "Add a one-line exit cost and name a load-test target plus a review date.",
      "snippet": "" }
  ],
  "adr": {
    "title": "Use PostgreSQL as the primary datastore for OrderFlow checkout",
    "status": "proposed",
    "context": "OrderFlow checkout is being rebuilt as a separate service. The existing MySQL
                cluster blocks deploys on schema migrations and shows row-lock contention on the
                orders table at peak. Checkout must sustain 3k writes/sec, migrations must not
                require downtime, and finance runs ad-hoc SQL against orders. The team is six
                backend engineers on Go services on EKS; two have run PostgreSQL in production
                and none have run DynamoDB. Cutover must land in the Q3 release.",
    "decision_drivers": [
      "Sustain 3k checkout writes/sec at peak",
      "Schema migrations must not require downtime",
      "Finance needs ad-hoc SQL against orders",
      "Only two engineers have production experience with any non-MySQL store",
      "Cutover must land in the Q3 release"
    ],
    "options": [
      { "name": "PostgreSQL",
        "pros": ["Keeps the relational model and finance's ad-hoc SQL",
                 "JSONB covers the line-item payloads without a second store",
                 "Concurrent index builds avoid the downtime that blocks deploys today",
                 "Ops already runs one cluster for billing"],
        "cons": ["A second production cluster to operate",
                 "3k writes/sec at peak needs a connection pooler in front of it",
                 "[TODO: cost of migrating the existing orders data]"] },
      { "name": "DynamoDB",
        "pros": ["Scales without operational babysitting"],
        "cons": ["Access patterns must be modelled up front",
                 "Ad-hoc reporting is lost, which contradicts a stated driver",
                 "No one on the team has run it in production"] },
      { "name": "Keep MySQL (status quo)",
        "pros": ["No migration, no new operational surface", "Every engineer already knows it"],
        "cons": ["Migrations keep blocking deploys",
                 "Row-lock contention on orders is the problem being escaped",
                 "Reviewer-added option: the paste does not evaluate it explicitly"] }
    ],
    "decision": "Chosen option: PostgreSQL, because it is the only candidate that satisfies the
                 ad-hoc SQL driver while clearing the throughput floor with a migration path the
                 team has operated before. DynamoDB is rejected because losing ad-hoc reporting
                 contradicts a stated driver and no one has run it in production; keeping MySQL
                 does not address the contention that motivated the rebuild.",
    "consequences": {
      "positive": ["Finance keeps ad-hoc SQL against orders with no new tooling",
                   "Concurrent index builds remove migration downtime from the deploy path",
                   "Reuses the operational knowledge from the billing cluster"],
      "negative": ["Ops runs a second production Postgres cluster",
                   "A connection pooler becomes a required component at peak write volume",
                   "The orders data migration must fit inside the Q3 window"],
      "neutral": ["Line-item payloads move to JSONB rather than a separate document store",
                  "MySQL stays in place for the legacy admin tooling until it is retired"]
    },
    "confirmation": "[TODO: name the load test that demonstrates 3k writes/sec on the new cluster,
                     its owner, and a 90-day review date after cutover]"
  },
  "options_analysis": [
    { "option": "PostgreSQL", "verdict": "chosen",
      "reason": "Only option that clears the throughput floor without giving up ad-hoc SQL." },
    { "option": "DynamoDB", "verdict": "rejected",
      "reason": "Losing ad-hoc reporting contradicts a stated driver, and no one has operated it." },
    { "option": "Keep MySQL (status quo)", "verdict": "rejected",
      "reason": "Leaves the row-lock contention and migration downtime that motivated the rebuild." }
  ],
  "coverage_check": [
    { "id": "title:generic", "addressed": true,
      "note": "AD-002 and the drafted title: 'Use PostgreSQL as the primary datastore for OrderFlow checkout'." },
    { "id": "rev:none", "addressed": true,
      "note": "AD-003; the exit cost is now an open question and a [TODO] in confirmation." },
    { "id": "options:no-cons", "addressed": false,
      "note": "Set aside — the paste does record DynamoDB's costs inline ('we lose ad-hoc
               reporting'); the lint fired only because it uses none of the cost vocabulary.
               The real gap is the chosen option's costs, raised as AD-001." }
  ],
  "quick_wins": [
    "Rename the record to name the chosen option, so search finds it by decision rather than topic.",
    "Add the status line: proposed, pending the load test.",
    "Write one sentence on the exit cost — it is what makes the scrutiny level defensible."
  ],
  "focus_areas": [
    { "area": "Honest costs for the chosen option",
      "why": "A record with drawbacks only for the rejected options will not be trusted by the
              team that inherits it.",
      "finding_ids": ["AD-001"] },
    { "area": "Tying the outcome to a driver",
      "why": "The decision sentence is the part future readers quote; it must carry the reason.",
      "finding_ids": ["AD-002", "AD-003"] }
  ],
  "summary": "Adds ADR: use PostgreSQL as the primary datastore for OrderFlow checkout. The
              context, drivers and options are solid; before accepting, fill the confirmation
              [TODO] with a named load test and owner, and keep the negative consequences as
              written — the second cluster and the pooler are the real price of this choice."
}

This is AI-generated review and drafting of the material as pasted, not an architectural sign-off: it sees only what you sent, never your running system, your benchmarks or your org's constraints. Check assumptions and open_questions, resolve every [TODO] before the record is accepted, and keep a human in the loop.

Step 5 — Stream the review as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because a fully drafted ADR makes for a long reply. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "review_name", "section_audit", "findings", "adr", "options_analysis" and "coverage_check" keys as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the review from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: adr-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"review_name\":\"Primary datastore for"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":612,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "adr-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

review = json.loads(result["output"]["output"])            # authoritative
print("charged:", result["charged_credits"], "-", review["review_name"])
print("posture:", review["posture"])
for f in review["findings"]:
    print(f'  [{f["priority"]}] {f["id"]} {f["section"]}: {f["problem"]}')
print("ADR title:", review["adr"]["title"])
print("negatives:", "; ".join(review["adr"]["consequences"]["negative"]))
with open("review.json", "w", encoding="utf-8") as fh:
    json.dump(review, fh, indent=2)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const review = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${review.review_name} [${review.posture}]`);
for (const f of review.findings) console.log(`  [${f.priority}] ${f.id} ${f.section}`);
console.log("ADR title:", review.adr.title);
console.log("negatives:", review.adr.consequences.negative.join("; "));
writeFileSync("review.json", JSON.stringify(review, null, 2));
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "adr-001")

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the review JSON —
// unmarshal it into the Review struct from step 4, then write it to review.json.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "adr-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// review_name, posture, verdict, section_audit[], findings[], adr (with options[],
// consequences and confirmation), options_analysis[], coverage_check[], quick_wins[],
// focus_areas[] and the rest.
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "adr-001"
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

review = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{review["review_name"]} [#{review["posture"]}]"
review["findings"].each { |f| puts "  [#{f["priority"]}] #{f["id"]} #{f["section"]}" }
puts "ADR title: #{review["adr"]["title"]}"
puts "negatives: #{review["adr"]["consequences"]["negative"].join("; ")}"
File.write("review.json", JSON.pretty_generate(review))
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: adr-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$review = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$review['review_name']} [{$review['posture']}]\n";
foreach ($review["findings"] as $f) {
    echo "  [{$f['priority']}] {$f['id']} {$f['section']}\n";
}
echo "ADR title: {$review['adr']['title']}\n";
echo "negatives: " . implode("; ", $review["adr"]["consequences"]["negative"]) . "\n";
file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "adr-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var reviewDoc = JsonDocument.Parse(text!);
var review = reviewDoc.RootElement;
Console.WriteLine($"{review.GetProperty("review_name")} [{review.GetProperty("posture")}]");
foreach (var f in review.GetProperty("findings").EnumerateArray())
    Console.WriteLine($"  [{f.GetProperty("priority")}] {f.GetProperty("id")} {f.GetProperty("section")}");
Console.WriteLine($"ADR title: {review.GetProperty("adr").GetProperty("title")}");
await File.WriteAllTextAsync("review.json", text!);

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.