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
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.
| Status | Meaning |
|---|---|
400 | Malformed body — usually invalid JSON or a prescan_facts entry missing its id. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest reviewing a very large paste). |
404 | Unknown job or record id. |
409 | An Idempotency-Key replay that conflicts with a different body. |
429 | Rate limited — back off and retry. |
5xx | Transient 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
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
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
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 field | Type | Notes |
|---|---|---|
material | string | The 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. |
project | string, optional | The system or team the decision belongs to, e.g. OrderFlow checkout. Used to name the review and to scope the ADR title. |
decision_title | string, optional | Your 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. |
options | string, optional | Options 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. |
drivers | string, optional | The 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. |
leaning | string, optional | The 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. |
context | string, optional | Anything 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_facts | object, optional | What 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_note | string, optional | Only 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
/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.
| Field | Type | Meaning |
|---|---|---|
review_name | string | A short name for the review, naming the decision and the system — e.g. Primary datastore for OrderFlow checkout. |
posture | string | ready | needs_work | not_ready. See the table below. |
verdict | string | One sentence: the posture and the single most important thing to fix. |
exec_summary | string | Two 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. |
assumptions | string[] | Assumptions the review had to make. Read these first — a wrong assumption invalidates the record built on it. |
open_questions | string[] | Questions the team must answer before accepting the ADR. Each [TODO] in the drafted text has a matching question here. |
section_audit | array | {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. |
findings | array | The prioritized findings table — ids AD-001, AD-002, … in sequence, at least one entry. Columns are listed below. |
adr | object | The 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_analysis | array | {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_check | array | {id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. See the semantics below. |
quick_wins | string[] | Small edits the team can make today — naming the confirmation date, adding the status-quo option, deleting one unfalsifiable sentence. May be empty. |
focus_areas | array | {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. |
summary | string | Two or three sentences a tech lead can paste into the PR that adds this ADR. |
The three posture values:
| posture | What it means |
|---|---|
ready | The 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_work | A 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_ready | Core 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:
| Column | Meaning |
|---|---|
id | Sequential AD-001, AD-002, … — the stable handle referenced from focus_areas[].finding_ids. |
category | context | options | drivers | outcome | consequences | scope | process | language. scope is for a paste holding several decisions; language for unfalsifiable wording ("obviously", "industry standard"). |
severity | low | medium | high — how much it costs when it bites. |
likelihood | low | medium | high — how likely it is to bite. |
priority | critical | 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. |
section | The ADR section the finding concerns — one of the eight section_audit section names. |
problem | What is wrong or missing in the pasted material, quoting its actual text. |
impact | What it costs a future reader or the team. |
fix | The specific change to make, or the question to answer. |
snippet | Quoted text from the paste, or suggested replacement text. Empty string when quoting would add nothing. |
coverage_check semantics:
| Case | What you get |
|---|---|
| Every flag id you sent | Each 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: true | The flag is covered by the review; note says how it was confirmed and where the drafted ADR fixes it. |
addressed: false | The 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 sent | Omit 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.