Postmortem Studio — API

Blameless incident postmortems from raw notes.

API tokens Open the app

Write postmortems from your own scripts

Send raw incident material — Slack scrollback, an on-call log, pager timestamps, a hand-typed recollection — and get back one JSON object: an honest verdict and severity call, a reconstructed timeline that keeps your own timestamps, a 5 Whys chain where every answer carries evidence from the notes, contributing factors, what went well and poorly, and prioritized action items ready to become tickets. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire postmortem drafting into an incident bot, a retro-prep job that runs the morning after a page, or a pipeline that turns a resolved incident channel into a reviewed document. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug postmortem-studio. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Estimates are free; runs are metered against your credit balance. There is a single run task — one pile of notes in, one postmortem out, no follow-up calls and no session state to carry between requests.

StatusMeaning
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 submitting a very long incident channel export).
404Unknown job or record id.
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 postmortem 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":"postmortem-studio"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "postmortem-studio"})["token"]
const { token } = await api("POST", "/guest", { slug: "postmortem-studio" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "postmortem-studio"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"postmortem-studio"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "postmortem-studio" })["token"]
$token = api("POST", "/guest", ["slug" => "postmortem-studio"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "postmortem-studio" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:postmortem-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 feeding in a whole incident channel export.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, 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 about to pipe in a day of Slack scrollback and want a ceiling before spending credits.

Input fieldTypeNotes
notesstring, requiredThe raw incident material: chat logs, the on-call log, pager timestamps, plain prose, or a mix. Messy is fine and timestamps help. Very long pastes may be clipped middle-out, with a [... clipped ...] marker showing where.
severity_hintstringauto | sev1 | sev2 | sev3 | sev4. auto lets the review make the severity call from the impact described in the notes; anything else is your declared severity, which is respected unless the notes plainly contradict it.
audiencestringengineering | leadership | customer — calibrates register and depth. engineering keeps component names and mechanisms; leadership leads with impact, risk and the action plan; customer drops internal names and stays factual about what was affected and what changes.
systemstring, optionalThe service or system involved, e.g. "checkout-api". Used to name the postmortem and anchor the timeline.
contextstring, optionalBackground the notes assume: architecture, deploy process, on-call setup, whether this has happened before. Also clipped middle-out if very long.
prescan_factsobject, optionalWhat a client-side prescan mechanically detected in the notes: {"moments": [], "blame": [], "signals": []}. Each entry is {id, label} — timestamped moments (t:14:19), blame-language hits (blame:fault, blame:should-have) and incident signals (sig:rollback, sig:alert, sig:capacity). Every id you send comes back in coverage_check. The web UI fills this from its own scan; API callers may omit the field or send the three 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 > notes.txt <<'TXT'
09:07 billing-svc v412 auto-deployed on merge
09:12 pagerduty fired: checkout-api 5xx rate above 8%
09:15 on-call acked, saw failures only on paid checkouts
09:21 rolled back billing-svc to v411
09:28 5xx back under 0.2%, declared resolved at 09:38
support counted ~400 failed checkouts. no data loss.
we only alert on 5xx rate, nothing watches the billing call path.
TXT

jq -n --rawfile n notes.txt \
  '{notes: $n, severity_hint: "auto", audience: "engineering", system: "checkout-api",
    context: "Node service on EKS; deploys are continuous on merge, rollback is one click.",
    prescan_facts: {moments: [], blame: [], signals: []}}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
NOTES = """09:07 billing-svc v412 auto-deployed on merge
09:12 pagerduty fired: checkout-api 5xx rate above 8%
09:15 on-call acked, saw failures only on paid checkouts
09:21 rolled back billing-svc to v411
09:28 5xx back under 0.2%, declared resolved at 09:38
support counted ~400 failed checkouts. no data loss.
we only alert on 5xx rate, nothing watches the billing call path."""

payload = {
    "notes": NOTES,
    "severity_hint": "auto",
    "audience": "engineering",
    "system": "checkout-api",
    "context": "Node service on EKS; deploys are continuous on merge, rollback is one click.",
    "prescan_facts": {"moments": [], "blame": [], "signals": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const notes = `09:07 billing-svc v412 auto-deployed on merge
09:12 pagerduty fired: checkout-api 5xx rate above 8%
09:15 on-call acked, saw failures only on paid checkouts
09:21 rolled back billing-svc to v411
09:28 5xx back under 0.2%, declared resolved at 09:38
support counted ~400 failed checkouts. no data loss.
we only alert on 5xx rate, nothing watches the billing call path.`;

const payload = {
  notes,
  severity_hint: "auto",
  audience: "engineering",
  system: "checkout-api",
  context: "Node service on EKS; deploys are continuous on merge, rollback is one click.",
  prescan_facts: { moments: [], blame: [], signals: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const notes = `09:07 billing-svc v412 auto-deployed on merge
09:12 pagerduty fired: checkout-api 5xx rate above 8%
09:15 on-call acked, saw failures only on paid checkouts
09:21 rolled back billing-svc to v411
09:28 5xx back under 0.2%, declared resolved at 09:38
support counted ~400 failed checkouts. no data loss.
we only alert on 5xx rate, nothing watches the billing call path.`

payload := map[string]any{
	"notes":         notes,
	"severity_hint": "auto",
	"audience":      "engineering",
	"system":        "checkout-api",
	"context":       "Node service on EKS; deploys are continuous on merge, rollback is one click.",
	"prescan_facts": map[string]any{
		"moments": []any{}, "blame": []any{}, "signals": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String notes = """
    09:07 billing-svc v412 auto-deployed on merge
    09:12 pagerduty fired: checkout-api 5xx rate above 8%
    09:15 on-call acked, saw failures only on paid checkouts
    09:21 rolled back billing-svc to v411
    09:28 5xx back under 0.2%, declared resolved at 09:38
    support counted ~400 failed checkouts. no data loss.
    we only alert on 5xx rate, nothing watches the billing call path.""";

String jsonPayload = """
    {"notes": %s, "severity_hint": "auto", "audience": "engineering",
     "system": "checkout-api",
     "context": "Node service on EKS; deploys are continuous on merge, rollback is one click.",
     "prescan_facts": {"moments": [], "blame": [], "signals": []}}
    """.formatted(toJsonString(notes));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
NOTES = <<~TXT
  09:07 billing-svc v412 auto-deployed on merge
  09:12 pagerduty fired: checkout-api 5xx rate above 8%
  09:15 on-call acked, saw failures only on paid checkouts
  09:21 rolled back billing-svc to v411
  09:28 5xx back under 0.2%, declared resolved at 09:38
  support counted ~400 failed checkouts. no data loss.
  we only alert on 5xx rate, nothing watches the billing call path.
TXT

payload = { notes: NOTES, severity_hint: "auto", audience: "engineering",
            system: "checkout-api",
            context: "Node service on EKS; deploys are continuous on merge, rollback is one click.",
            prescan_facts: { moments: [], blame: [], signals: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$notes = <<<'TXT'
09:07 billing-svc v412 auto-deployed on merge
09:12 pagerduty fired: checkout-api 5xx rate above 8%
09:15 on-call acked, saw failures only on paid checkouts
09:21 rolled back billing-svc to v411
09:28 5xx back under 0.2%, declared resolved at 09:38
support counted ~400 failed checkouts. no data loss.
we only alert on 5xx rate, nothing watches the billing call path.
TXT;

$payload = [
    "notes"         => $notes,
    "severity_hint" => "auto",
    "audience"      => "engineering",
    "system"        => "checkout-api",
    "context"       => "Node service on EKS; deploys are continuous on merge, rollback is one click.",
    "prescan_facts" => ["moments" => [], "blame" => [], "signals" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var notes = """
    09:07 billing-svc v412 auto-deployed on merge
    09:12 pagerduty fired: checkout-api 5xx rate above 8%
    09:15 on-call acked, saw failures only on paid checkouts
    09:21 rolled back billing-svc to v411
    09:28 5xx back under 0.2%, declared resolved at 09:38
    support counted ~400 failed checkouts. no data loss.
    we only alert on 5xx rate, nothing watches the billing call path.
    """;

var payload = new {
    notes,
    severity_hint = "auto",
    audience = "engineering",
    system = "checkout-api",
    context = "Node service on EKS; deploys are continuous on merge, rollback is one click.",
    prescan_facts = new {
        moments = Array.Empty<object>(), blame = Array.Empty<object>(),
        signals = Array.Empty<object>(),
    },
};

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

prescan_facts is how you make the postmortem answer for things you already know about. Send {"moments": [{"id": "t:09:21", "label": "09:21"}], "blame": [{"id": "blame:should-have", "label": "“should have”"}], "signals": [{"id": "sig:rollback", "label": "Rollback / revert"}]} and every one of those ids comes back in coverage_check — placed in the timeline, converted into a systemic cause, or explained away as a false positive. Blame-language hits are the point of the mechanism: a phrase that pins the incident on a person has to come back as a statement about the system that let it happen. Nothing you flag is silently dropped.

Step 4 — Run the postmortem 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 timeline, the 5 Whys chain and the action items are all written out in full). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The postmortem is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the title and verdict, the severity call, the reconstructed timeline, the 5 Whys chain and the action items.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pm-$(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 postmortem once, then read it
echo "$JOB" | jq -r '.data.output.output' > postmortem.json

jq -r '
  "\(.postmortem_title) [\(.severity) / \(.classification)]",
  "\(.verdict)",
  "",
  "TIMELINE",
  (.timeline[] | "  \(.time)  (\(.kind)) \(.event)"),
  "",
  "FIVE WHYS",
  (.five_whys[] | "  \(.why) -> \(.answer)   [\(.evidence)]"),
  "",
  "ROOT CAUSE",
  "  \(.root_cause)",
  "",
  "ACTION ITEMS",
  (.action_items[] | "  [\(.priority)/\(.type)] \(.action)  (\(.owner_role))")' postmortem.json
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "pm-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"]
pm = json.loads(raw) if isinstance(raw, str) else raw

print(f'{pm["postmortem_title"]} [{pm["severity"]} / {pm["classification"]}]')
print(pm["verdict"])
for ev in pm["timeline"]:
    print(f'  {ev["time"]:>6}  ({ev["kind"]}) {ev["event"]}')
for w in pm["five_whys"]:
    print(f'  {w["why"]} -> {w["answer"]}')
    print(f'      evidence: {w["evidence"]}')
print("root cause:", pm["root_cause"])
for a in pm["action_items"]:
    print(f'  [{a["priority"]}/{a["type"]}] {a["action"]}  ({a["owner_role"]})')
for c in pm["coverage_check"]:
    print(f'  {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')

with open("postmortem.json", "w", encoding="utf-8") as fh:
    json.dump(pm, fh, indent=2)
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 pm = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${pm.postmortem_title} [${pm.severity} / ${pm.classification}]`);
console.log(pm.verdict);
for (const ev of pm.timeline) {
  console.log(`  ${ev.time}  (${ev.kind}) ${ev.event}`);
}
for (const w of pm.five_whys) {
  console.log(`  ${w.why} -> ${w.answer}`);
  console.log(`      evidence: ${w.evidence}`);
}
console.log("root cause:", pm.root_cause);
for (const a of pm.action_items) {
  console.log(`  [${a.priority}/${a.type}] ${a.action}  (${a.owner_role})`);
}
for (const c of pm.coverage_check) {
  console.log(`  ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}

writeFileSync("postmortem.json", JSON.stringify(pm, null, 2));
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, unquote, then unmarshal:
type Postmortem struct {
	Title          string `json:"postmortem_title"`
	Verdict        string `json:"verdict"`
	Severity       string `json:"severity"`
	Classification string `json:"classification"`
	Timeline       []struct {
		Time, Event, Kind string
	} `json:"timeline"`
	FiveWhys []struct {
		Why, Answer, Evidence string
	} `json:"five_whys"`
	RootCause   string `json:"root_cause"`
	ActionItems []struct {
		Priority, Type, Action string
		OwnerRole              string `json:"owner_role"`
	} `json:"action_items"`
	Summary string `json:"summary"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var pm Postmortem
json.Unmarshal([]byte(wrapper.Output), &pm)

fmt.Printf("%s [%s / %s]\n%s\n", pm.Title, pm.Severity, pm.Classification, pm.Verdict)
for _, ev := range pm.Timeline {
	fmt.Printf("  %s  (%s) %s\n", ev.Time, ev.Kind, ev.Event)
}
for _, w := range pm.FiveWhys {
	fmt.Printf("  %s -> %s   [%s]\n", w.Why, w.Answer, w.Evidence)
}
fmt.Println("root cause:", pm.RootCause)
for _, a := range pm.ActionItems {
	fmt.Printf("  [%s/%s] %s  (%s)\n", a.Priority, a.Type, a.Action, a.OwnerRole)
}
os.WriteFile("postmortem.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 postmortem is at data.output.output as a JSON string — parse it again, then read
// postmortem_title, verdict, severity, classification, overview, impact{duration, scope,
// customer_impact}, timeline[] (time/event/kind), five_whys[] (why/answer/evidence),
// root_cause, contributing_factors[], went_well[], went_poorly[], coverage_check[]
// (id/addressed/note), action_items[] (priority/type/action/owner_role), next_steps[]
// and summary. Then persist it:
//   Files.writeString(Path.of("postmortem.json"), postmortemJson);
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"]
pm = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{pm["postmortem_title"]} [#{pm["severity"]} / #{pm["classification"]}]"
puts pm["verdict"]
pm["timeline"].each { |ev| puts "  #{ev["time"]}  (#{ev["kind"]}) #{ev["event"]}" }
pm["five_whys"].each do |w|
  puts "  #{w["why"]} -> #{w["answer"]}"
  puts "      evidence: #{w["evidence"]}"
end
puts "root cause: #{pm["root_cause"]}"
pm["action_items"].each do |a|
  puts "  [#{a["priority"]}/#{a["type"]}] #{a["action"]}  (#{a["owner_role"]})"
end
pm["coverage_check"].each { |c| puts "  #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }

File.write("postmortem.json", JSON.pretty_generate(pm))
$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"];
$pm = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$pm['postmortem_title']} [{$pm['severity']} / {$pm['classification']}]\n";
echo "{$pm['verdict']}\n";
foreach ($pm["timeline"] as $ev) {
    echo "  {$ev['time']}  ({$ev['kind']}) {$ev['event']}\n";
}
foreach ($pm["five_whys"] as $w) {
    echo "  {$w['why']} -> {$w['answer']}\n";
    echo "      evidence: {$w['evidence']}\n";
}
echo "root cause: {$pm['root_cause']}\n";
foreach ($pm["action_items"] as $a) {
    echo "  [{$a['priority']}/{$a['type']}] {$a['action']}  ({$a['owner_role']})\n";
}
foreach ($pm["coverage_check"] as $c) {
    echo "  {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}

file_put_contents("postmortem.json", json_encode($pm, 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 pm = doc.RootElement;

Console.WriteLine($"{pm.GetProperty("postmortem_title")} " +
                  $"[{pm.GetProperty("severity")} / {pm.GetProperty("classification")}]");
Console.WriteLine(pm.GetProperty("verdict"));
foreach (var ev in pm.GetProperty("timeline").EnumerateArray())
{
    Console.WriteLine($"  {ev.GetProperty("time")}  ({ev.GetProperty("kind")}) {ev.GetProperty("event")}");
}
foreach (var w in pm.GetProperty("five_whys").EnumerateArray())
{
    Console.WriteLine($"  {w.GetProperty("why")} -> {w.GetProperty("answer")}");
}
Console.WriteLine($"root cause: {pm.GetProperty("root_cause")}");
foreach (var a in pm.GetProperty("action_items").EnumerateArray())
{
    Console.WriteLine($"  [{a.GetProperty("priority")}/{a.GetProperty("type")}] " +
                      $"{a.GetProperty("action")}  ({a.GetProperty("owner_role")})");
}

await File.WriteAllTextAsync("postmortem.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 postmortem object — output schema

One JSON object, always the same shape. timeline, action_items and root_cause are never empty, and five_whys always has at least two entries; the remaining arrays are empty only if genuinely nothing applies. If the notes are too thin to reconstruct much, you still get this object: what is there gets written up, the verdict says the material is thin, and the specific things you would need to collect land in next_steps. If what arrived is not incident material at all, you still get the object — the verdict says what arrived, severity lands at sev4, and next_steps says what to paste instead.

FieldTypeMeaning
postmortem_titlestringA short document title, built from the system and the failure — not from anyone's name.
verdictstringOne or two sentences: what actually happened and the single most important thing to change.
severitystringsev1 | sev2 | sev3 | sev4. With severity_hint: "auto" this is the review's own honest call from the impact described; with a declared hint it echoes it unless the notes plainly contradict it.
classificationstringoutage | degradation | data-incident | security-incident | near-miss. A failure caught before it reached anyone is a near-miss and is still worth the document.
overviewstringOne or two paragraphs: the shape of the incident for someone who was not on the call.
impactobject{duration, scope, customer_impact} — how long, what was affected, and what people outside the team experienced. Numbers come from the notes; nothing is invented to fill the field.
timelinearray, non-empty{time, event, kind} in order. time keeps your own format verbatim ("09:12", "14:19 UTC", "~2h in"). kind is context, detect, escalate, mitigate or resolve, so the detect-to-mitigate gap is readable at a glance. Events name systems and actions, not individuals.
five_whysarray of 2+{why, answer, evidence} — a chain, each why asked of the previous answer. evidence quotes or paraphrases the line in your notes the answer rests on; the chain stops when it reaches something systemic, not when it reaches a person.
root_causestring, non-emptyThe systemic cause the chain landed on, stated as a property of the system.
contributing_factorsstring[]Conditions that made the incident more likely, longer or worse — not causes on their own.
went_wellstring[]What the response got right, specifically: a fast rollback decision, a clean escalation, a dashboard that answered the question.
went_poorlystring[]Where the response lost time or visibility, phrased about process and tooling.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts item you sent (t:09:21, blame:should-have, sig:rollback, …), saying where the postmortem places it or why it was set aside. Blame hits come back showing the systemic statement that replaced them; a keyword hit can be a false positive and the note says so.
action_itemsarray, non-empty{priority, type, action, owner_role}. priority is p0 | p1 | p2; type is prevent, detect, mitigate or process. action is a single concrete change someone can ticket; owner_role is a role ("on-call platform engineer"), never a person's name.
next_stepsstring[]Ordered and concrete: what to verify, what to measure, and what the notes were missing that would sharpen the next revision.
summarystring3–5 sentences an incident commander could paste into a retro invite.

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

{
  "postmortem_title": "checkout-api paid-checkout failures after billing-svc v412",
  "verdict": "A billing-svc deploy broke the paid checkout path for 26 minutes; detection
              came from a generic 5xx alert, and nothing watched the billing call path.",
  "severity": "sev2",
  "classification": "degradation",
  "overview": "billing-svc v412 auto-deployed on merge at 09:07. Paid checkouts began
               failing; the 5xx-rate alert fired five minutes later. A rollback at 09:21
               restored service, and the incident was declared resolved at 09:38.",
  "impact": {
    "duration": "~21 minutes of elevated failures (09:07-09:28), resolved 09:38",
    "scope": "Paid checkout path only; unpaid flows unaffected.",
    "customer_impact": "Support counted about 400 failed checkout attempts. No data loss."
  },
  "timeline": [
    { "time": "09:07", "event": "billing-svc v412 auto-deployed on merge",
      "kind": "context" },
    { "time": "09:12", "event": "PagerDuty fired on checkout-api 5xx rate above 8%",
      "kind": "detect" },
    { "time": "09:15", "event": "On-call acked; failures isolated to paid checkouts",
      "kind": "escalate" },
    { "time": "09:21", "event": "billing-svc rolled back to v411", "kind": "mitigate" },
    { "time": "09:38", "event": "Declared resolved after 5xx held under 0.2%",
      "kind": "resolve" }
  ],
  "five_whys": [
    { "why": "Why did paid checkouts fail?",
      "answer": "checkout-api calls to billing-svc started returning errors after v412.",
      "evidence": "'09:15 saw failures only on paid checkouts' following the 09:07 deploy." },
    { "why": "Why did a breaking change reach production unnoticed?",
      "answer": "Merges deploy continuously with no verification of the billing call path.",
      "evidence": "'deploys are continuous on merge' with no post-deploy check in the notes." },
    { "why": "Why did detection take five minutes and depend on customer-facing errors?",
      "answer": "The only alert is a generic 5xx rate; no signal covers the billing path.",
      "evidence": "'we only alert on 5xx rate, nothing watches the billing call path.'" }
  ],
  "root_cause": "A dependency path with no independent health signal is deployed
                 continuously, so failures are only visible once customers hit them.",
  "contributing_factors": [
    "Deploy-on-merge with no smoke check on the paid checkout path.",
    "Alerting thresholds tuned to aggregate 5xx, which dilutes a single-path failure."
  ],
  "went_well": [
    "Rollback was chosen quickly instead of attempting a live fix.",
    "Impact was scoped to the paid path within three minutes of ack."
  ],
  "went_poorly": [
    "Detection relied on customers generating enough errors to move an aggregate metric."
  ],
  "coverage_check": [
    { "id": "sig:rollback", "addressed": true,
      "note": "Rollback at 09:21 is the mitigate step in the timeline." },
    { "id": "blame:should-have", "addressed": true,
      "note": "Reframed: the pipeline had no verification gate, so no reviewer could
               have caught this reliably." }
  ],
  "action_items": [
    { "priority": "p0", "type": "detect",
      "action": "Add an alert on checkout-api-to-billing-svc error rate and latency.",
      "owner_role": "on-call platform engineer" },
    { "priority": "p1", "type": "prevent",
      "action": "Gate billing-svc deploys behind a paid-checkout smoke test.",
      "owner_role": "payments team lead" },
    { "priority": "p2", "type": "process",
      "action": "Record rollback-vs-fix decision time in the incident template.",
      "owner_role": "incident commander" }
  ],
  "next_steps": [
    "Confirm the 400 failed checkouts were retried or refunded.",
    "Pull the v412 diff into the retro to see which call path changed.",
    "Collect exact deploy and alert timestamps from the pipeline for the final revision."
  ],
  "summary": "A 21-minute paid-checkout degradation caused by an unverified deploy path. …"
}

This is a draft, not a filed document: it is AI-generated from whatever notes you sent, and the timeline can only be as accurate as your timestamps. Review it with the people who were on the call, correct the impact numbers against your own metrics, and agree the action items before any of them becomes a ticket.

Step 5 — Stream the postmortem 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 full timeline plus 5 Whys plus action items 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).
done{job_id, status, charged_credits, output}The final, authoritative result — read the postmortem 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: pm-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"postmortem_title\":\"checkout-api"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":548,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "pm-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"))

pm = json.loads(result["output"]["output"])             # authoritative
print("charged:", result["charged_credits"], "-", pm["postmortem_title"])
print(pm["severity"], pm["classification"], "-", pm["root_cause"])
for a in pm["action_items"]:
    print(f'  [{a["priority"]}/{a["type"]}] {a["action"]}')
open("postmortem.json", "w", encoding="utf-8").write(json.dumps(pm, 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 pm = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${pm.postmortem_title}`);
console.log(`${pm.severity} / ${pm.classification} - ${pm.root_cause}`);
for (const a of pm.action_items) console.log(`  [${a.priority}/${a.type}] ${a.action}`);
writeFileSync("postmortem.json", JSON.stringify(pm, 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", "pm-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 postmortem JSON —
// unmarshal it into the Postmortem struct from step 4, then print or persist it.
// 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", "pm-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
// postmortem_title, severity, timeline[], five_whys[], root_cause, action_items[] 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"] = "pm-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

pm = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{pm["postmortem_title"]}"
puts "#{pm["severity"]} / #{pm["classification"]} - #{pm["root_cause"]}"
pm["action_items"].each { |a| puts "  [#{a["priority"]}/#{a["type"]}] #{a["action"]}" }
File.write("postmortem.json", JSON.pretty_generate(pm))
$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: pm-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);

$pm = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$pm['postmortem_title']}\n";
echo "{$pm['severity']} / {$pm['classification']} - {$pm['root_cause']}\n";
foreach ($pm["action_items"] as $a) { echo "  [{$a['priority']}/{$a['type']}] {$a['action']}\n"; }
file_put_contents("postmortem.json", json_encode($pm, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "pm-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 pmDoc = JsonDocument.Parse(text!);
var pm = pmDoc.RootElement;
Console.WriteLine(pm.GetProperty("postmortem_title"));
Console.WriteLine($"{pm.GetProperty("severity")} / {pm.GetProperty("classification")}");
foreach (var a in pm.GetProperty("action_items").EnumerateArray())
    Console.WriteLine($"  [{a.GetProperty("priority")}/{a.GetProperty("type")}] {a.GetProperty("action")}");
await File.WriteAllTextAsync("postmortem.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.

Step 6 — Follow-ups: revisions, not conversations

There is no follow-up endpoint and no session to continue: one /run produces one complete postmortem. To revise a document — the retro corrected a timestamp, someone remembered the escalation happened earlier, the impact numbers came back from support — send a new run with the corrections folded into notes or context, and keep the previous output alongside it as your own version history. Two things make this practical:

PatternHow
Correct the recordAppend the corrections to notes as their own dated block ("correction from retro: escalation was 09:14, not 09:15"). Later lines are treated as authoritative over earlier ones.
Re-cut for another audienceSame notes, change audience to leadership or customer and run again. The facts stay; the register, the depth and the internal naming change.
Force a severityRe-run with severity_hint set to your incident manager's official call instead of auto; the impact section and action-item priorities follow it.
Reformat retryretry_note exists only for the app's automatic "that wasn't valid JSON, return the object again" retry. Do not use it to ask for content changes — put those in notes.

A revision run costs a fresh estimate and a fresh charge, so estimate first if the notes grew substantially between passes.

# fold the retro's corrections into the notes, then run again
jq --arg fix $'\ncorrections from retro:\n- escalation was 09:14, not 09:15\n- support confirmed 412 failed checkouts, all retried successfully\n' \
   '.notes += $fix' input.json > input-v2.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input-v2.json | jq '.data.hold_credits'

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

corrections from retro:
- escalation was 09:14, not 09:15
- support confirmed 412 failed checkouts, all retried successfully"""

v2 = {**payload, "notes": payload["notes"] + corrections}

# same notes, different reader
leadership = {**v2, "audience": "leadership"}

job_id = api("POST", "/run", leadership,
             **{"Idempotency-Key": "pm-v2-001"})["job_id"]
const corrections = `

corrections from retro:
- escalation was 09:14, not 09:15
- support confirmed 412 failed checkouts, all retried successfully`;

const v2 = { ...payload, notes: payload.notes + corrections };

// same notes, different reader
const leadership = { ...v2, audience: "leadership" };

const { job_id } = await api("POST", "/run", leadership,
  { "Idempotency-Key": crypto.randomUUID() });
corrections := "\n\ncorrections from retro:\n" +
	"- escalation was 09:14, not 09:15\n" +
	"- support confirmed 412 failed checkouts, all retried successfully"

v2 := map[string]any{}
for k, v := range payload {
	v2[k] = v
}
v2["notes"] = notes + corrections
v2["audience"] = "leadership" // same notes, different reader

var startedV2 struct{ JobID string `json:"job_id"` }
err := call("POST", "/run", v2, &startedV2)
String corrections = """

    corrections from retro:
    - escalation was 09:14, not 09:15
    - support confirmed 412 failed checkouts, all retried successfully""";

String jsonPayloadV2 = """
    {"notes": %s, "severity_hint": "sev2", "audience": "leadership",
     "system": "checkout-api",
     "context": "Node service on EKS; deploys are continuous on merge, rollback is one click.",
     "prescan_facts": {"moments": [], "blame": [], "signals": []}}
    """.formatted(toJsonString(notes + corrections));

String envelope = api("POST", "/run", jsonPayloadV2);
// poll data.job_id exactly as in step 4
corrections = <<~TXT

  corrections from retro:
  - escalation was 09:14, not 09:15
  - support confirmed 412 failed checkouts, all retried successfully
TXT

v2 = payload.merge(notes: payload[:notes] + corrections)

# same notes, different reader
leadership = v2.merge(audience: "leadership")

started = api("POST", "/run", leadership)
puts started["job_id"]
$corrections = "\n\ncorrections from retro:\n"
    . "- escalation was 09:14, not 09:15\n"
    . "- support confirmed 412 failed checkouts, all retried successfully";

$v2 = $payload;
$v2["notes"] = $payload["notes"] . $corrections;
$v2["audience"] = "leadership"; // same notes, different reader

$started = api("POST", "/run", $v2);
echo $started["job_id"] . "\n";
var corrections = """

    corrections from retro:
    - escalation was 09:14, not 09:15
    - support confirmed 412 failed checkouts, all retried successfully
    """;

var v2 = new {
    notes = notes + corrections,
    severity_hint = "sev2",
    audience = "leadership",   // same notes, different reader
    system = "checkout-api",
    context = "Node service on EKS; deploys are continuous on merge, rollback is one click.",
    prescan_facts = new {
        moments = Array.Empty<object>(), blame = Array.Empty<object>(),
        signals = Array.Empty<object>(),
    },
};

var startedV2 = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", v2);
Console.WriteLine(startedV2.GetProperty("job_id"));

Because every run is independent, keep the input alongside the output when you archive a postmortem. The document is only reproducible if you still have the notes it was written from — and a diff of two runs is the cheapest way to show a retro what the corrections actually changed.