IC Memo Studio — API

Post the deal file, get the memo.

API tokens Open the app

Write IC memos from your own tools

Send whatever the process has produced — a company overview, management-reported financials, the LOI terms, diligence status by workstream, the returns model output, call notes — and get back one plain-text document in a fixed shape: the company named, a recommendation of Proceed, Conditional proceed or Pass, the transaction as the file states it, a confidence number, a two-to-four-sentence summary, and then eight sections — deal snapshot, company overview, investment thesis, financial analysis, deal terms and structure, returns analysis, a risk table carrying severity, likelihood and mitigant per risk, and the conditions and next steps. Nothing is invented to fill a gap: a figure the file does not carry is not written, a risk with no real mitigant says No mitigant identified, and a file too thin to underwrite gets a Pass that says so instead of guessing. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it to the deal-room exporter, run it over a folder of diligence files, or drop the memo straight into the IC pack. 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. There is no /apps/{slug}/ path segment — the app is bound to the token when you mint it, at POST /guest with {"slug":"ic-memo-studio"}, so every later call is just /me, /estimate, /run or /run-stream. 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. The memo is written by the model this app is bound to — /estimate returns its current name in model. Estimates are free; runs are metered against your credit balance. There is a single run task — one paste of a deal file in, one memo out, no follow-up calls and no session state to carry.

POST /guest
GET /me
POST /estimate
POST /run
POST /run-stream
StatusMeaning
400Malformed JSON body, or material missing entirely.
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 large deal file).
404Unknown job id.
429Too many runs in flight — back off and retry.
5xxTransient platform error — retry with backoff.

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

Step 0 — A tiny client

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

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

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

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")  # see step 1

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 memo 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 — and this is where the app slug is bound, which is why no later call needs it.

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

The app stores this browser's token under the localStorage key skillsafe_app_token:ic-memo-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 sending a full data-room summary.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_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 piping a long deal file in and want a ceiling before spending credits. The input object is the request body itself — it is not wrapped in {"input": …}.

Input fieldTypeNotes
materialstring, requiredThe deal file as pasted: company description, financial history, deal terms, DD findings (commercial, financial, legal, operational), returns analysis, call notes. Messy, partial and out of order is fine. This is the model's only evidence — no data room, no CIM, no model file is read. If you clip a long file, mark the cut in-band with [material truncated - N characters (~M lines) removed from the MIDDLE of the deal file. The opening and the end of the paste are intact; the middle is missing, so treat anything it would have shown as not reviewed and flag the gap.] so the memo reports the gap instead of guessing at the missing part. The web UI clips at 60,000 characters and inserts exactly that marker — and it cuts the middle, never the tail, because a deal file carries its terms, its returns math and its late diligence findings at the end. A head-only slice(0, 60000) would throw away exactly what the memo exists to weigh.
contextstring, optionalThe fund's mandate, target check size, hold period, return hurdles, what this committee tends to push on — anything the deal team knows that the file does not say. It sharpens what the memo argues to; it never licenses invention. The web UI caps it at 6,000 characters. Send "" when you have nothing to add.
factsstring, optionalPlain text, not an object — the summary of a mechanical browser-side prescan of material: counts of money figures, multiples, percentages and fiscal-year fragments, plus how many lines look risk-shaped, terms-shaped or returns-shaped. Pure pattern-matching, offered as a hint to cross-check against, never a verdict: where the scan and the material disagree, the material wins. Omit it, or send "", and nothing changes except that the model has one fewer cross-check. The exact wording the app sends is shown below.
retry_notestring, optionalReserved — reformat retry only. When a first reply does not match the output contract, the app sends the identical input once more with this field carrying a restatement of the required shape. It is not a place for instructions about the deal — nothing in it may appear in the memo as a fact, a term, a risk or a condition. Leave it out of ordinary calls, and put anything you want the memo to reflect in context.

The facts block, in the exact shape the app's own scanner produces:

Mechanical scan of the pasted deal file (pattern-matching, not judgement):
- 214 words over 12 non-empty lines.
- 14 money figures, 6 multiples, 9 percentages, 4 fiscal-year fragments detected.
- 5 lines match risk patterns (churn, concentration, litigation, dependency and similar).
- 4 lines match deal-terms patterns, 2 lines match returns patterns (IRR, MOIC, scenarios).

The examples below send material and context only, since facts is optional; add it as one more string field when you have a prescan of your own.

cat > material.txt <<'MATERIAL'
PROJECT HARBORLIGHT - deal file working notes
Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.
68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.
FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).
Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.
Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.
Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.
LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.
Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.
Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.
Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.
MATERIAL

# the input object IS the body — no {"input": ...} wrapper
jq -n --rawfile material material.txt \
  '{material: $material,
    context: "Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to 2.5x gross MOIC; this IC pushes on customer concentration and on any EBITDA adjustment that has not cleared QofE."}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
MATERIAL = """PROJECT HARBORLIGHT - deal file working notes
Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.
68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.
FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).
Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.
Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.
Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.
LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.
Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.
Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.
Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.
"""

CONTEXT = ("Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to "
           "2.5x gross MOIC; this IC pushes on customer concentration and on any EBITDA "
           "adjustment that has not cleared QofE.")

# the input object IS the body — no {"input": ...} wrapper
payload = {"material": MATERIAL, "context": CONTEXT}   # "facts" is optional

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits", "on", est.get("model"))
const material = [
  "PROJECT HARBORLIGHT - deal file working notes",
  "Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.",
  "68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.",
  "FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).",
  "Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.",
  "Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.",
  "Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.",
  "LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.",
  "Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.",
  "Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.",
  "Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.",
].join("\n");

const context =
  "Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to 2.5x gross MOIC; " +
  "this IC pushes on customer concentration and on any EBITDA adjustment that has not cleared QofE.";

// the input object IS the body — no {"input": ...} wrapper
const payload = { material, context };   // `facts` is optional

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits on", est.model);
const material = "PROJECT HARBORLIGHT - deal file working notes\n" +
	"Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.\n" +
	"68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.\n" +
	"FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).\n" +
	"Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.\n" +
	"Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.\n" +
	"Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.\n" +
	"LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.\n" +
	"Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.\n" +
	"Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.\n" +
	"Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.\n"

const dealContext = "Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to " +
	"2.5x gross MOIC; this IC pushes on customer concentration and on any EBITDA adjustment that has not cleared QofE."

// the input object IS the body — no {"input": ...} wrapper
payload := map[string]any{
	"material": material,
	"context":  dealContext,
	// "facts" is optional — add it as one more string when you have a prescan
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
err := call("POST", "/estimate", payload, &est)
String material = """
    PROJECT HARBORLIGHT - deal file working notes
    Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.
    68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.
    FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).
    Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.
    Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.
    Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.
    LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.
    Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.
    Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.
    Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.
    """;

String dealContext = """
    Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to 2.5x gross MOIC; \
    this IC pushes on customer concentration and on any EBITDA adjustment that has not cleared QofE.""";

// the input object IS the body — no {"input": ...} wrapper.
// toJsonString() is your JSON library's string escaper. "facts" is optional.
String jsonPayload = """
    {"material": %s,
     "context": %s}
    """.formatted(toJsonString(material), toJsonString(dealContext));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
MATERIAL = <<~MATERIAL
  PROJECT HARBORLIGHT - deal file working notes
  Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.
  68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.
  FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).
  Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.
  Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.
  Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.
  LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.
  Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.
  Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.
  Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.
MATERIAL

DEAL_CONTEXT = "Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to " \
               "2.5x gross MOIC; this IC pushes on customer concentration and on any EBITDA " \
               "adjustment that has not cleared QofE."

# the input object IS the body — no {"input": ...} wrapper; :facts is optional
payload = { material: MATERIAL, context: DEAL_CONTEXT }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits on #{est["model"]}"
$material = <<<'MATERIAL'
PROJECT HARBORLIGHT - deal file working notes
Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.
68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.
FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).
Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.
Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.
Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.
LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.
Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.
Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.
Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.
MATERIAL;

$dealContext = "Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to "
             . "2.5x gross MOIC; this IC pushes on customer concentration and on any EBITDA "
             . "adjustment that has not cleared QofE.";

// the input object IS the body — no {"input": ...} wrapper; "facts" is optional
$payload = [
    "material" => $material,
    "context"  => $dealContext,
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var material = """
    PROJECT HARBORLIGHT - deal file working notes
    Target: Meridian Dock and Door Services, Inc. - loading-dock service and repair, US Southeast.
    68% of revenue is recurring maintenance contracts; largest customer Beacon Logistics at 22% of FY2025 revenue.
    FY2023 revenue $38.1mm, FY2024 $43.6mm, FY2025 $49.2mm. Management-adjusted EBITDA FY2025 $8.7mm (17.7% margin).
    Adjustments include $0.9mm of owner add-backs the QofE has NOT yet verified; Calloway Partners QofE is in fieldwork, draft due in three weeks.
    Founder-CEO Dale Whitfield (61) wants to retire within two years; COO Renata Iglesias would step up at close. No CFO.
    Technician turnover 19% in FY2025 vs an 11% five-year average; exit interviews cite wage pressure in Atlanta and Nashville.
    LOI (14 May): majority buyout, 82.5% of equity at a $72mm enterprise value, 8.3x FY2025 adjusted EBITDA. Whitfield rolls 17.5%.
    Financing: $29mm senior term loan committed at 3.3x leverage; $38.6mm equity check; $7.2mm escrow for 18 months.
    Legal: one open OSHA citation (Nashville, June 2025), $148k penalty paid; related civil suit pending, insurer defending, $2mm limit.
    Returns (model v4): base 2.6x MOIC / 21% gross IRR at an 8.0x exit in year 5; downside 1.4x / 7%; upside 3.4x / 28%.
    """;

var context = "Fund: $450mm lower-middle-market buyout fund, 4-6 year holds, underwriting to "
            + "2.5x gross MOIC; this IC pushes on customer concentration and on any EBITDA "
            + "adjustment that has not cleared QofE.";

// the input object IS the body — no {"input": ...} wrapper; "facts" is optional
var payload = new { material, context };

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

facts is a hint, not an instruction: if your prescan counts a figure the material does not support, the material wins. Its real value is the count of risk-shaped lines — a large gap between that count and the number of rows under ## Risk factors is worth a look, because it usually means the file discussed findings the memo did not rank.

Step 4 — Write the memo and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same body as /estimate — the input object itself — 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 40–120 s for a normal deal file, longer for a full data-room summary). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The reply is in output — usually nested as output.output, and it is plain text, not JSON: write it straight to a .md file, or parse it with the snippet in the next section.

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

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

# the memo is plain text — -r keeps it readable
echo "$JOB" | jq -r '.data.output.output' > memo.md

head -5 memo.md                                        # the five header lines
sed -n '/^## Risk factors$/,/^## /p' memo.md           # just the risk table

# gate anything automated on the recommendation
grep -q '^RECOMMENDATION: Proceed$' memo.md \
  || { echo "not an unconditional proceed — read before circulating"; exit 1; }

# risks with no real mitigant are the ones to surface
grep '^- ' memo.md | grep ' | No mitigant identified$' || true
import time

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

with open("memo.md", "w", encoding="utf-8") as fh:
    fh.write(memo_text)

head, sections = parse_memo(memo_text)   # see the next section
print(head["company"], "|", head["deal"])
print(head["recommendation"], head["confidence"], "-", head["summary"])
for row in head["risks"]:
    print(f'  {row["risk"]}  [{row["severity"]}/{row["likelihood"]}]  ->  {row["mitigant"]}')

if head["recommendation"] != "Proceed":
    raise SystemExit(f'recommendation is {head["recommendation"]} — read before circulating')
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");

// plain text, not JSON
const memoText = job.output?.output ?? job.output;
writeFileSync("memo.md", memoText);

const memo = parseMemo(memoText);              // see the next section
console.log(`${memo.company} | ${memo.deal}`);
console.log(`${memo.recommendation} ${memo.confidence} - ${memo.summary}`);
for (const row of memo.risks) {
  console.log(`  ${row.risk}  [${row.severity}/${row.likelihood}]  ->  ${row.mitigant}`);
}
const bare = memo.risks.filter((r) => r.mitigant === "No mitigant identified");
if (bare.length) console.warn(`${bare.length} risk(s) have no mitigant`);
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)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}

// job.Output is {"output": "<the memo, as plain text>"} — one unwrap, no JSON parse
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
memoText := wrapper.Output

os.WriteFile("memo.md", []byte(memoText), 0o644)

m := parseMemo(memoText) // see the next section
fmt.Printf("%s | %s\n", m.Company, m.Deal)
fmt.Printf("%s %d - %s\n", m.Recommendation, m.Confidence, m.Summary)
for _, r := range m.Risks {
	fmt.Printf("  %s  [%s/%s]  ->  %s\n", r.Risk, r.Severity, r.Likelihood, r.Mitigant)
}
if m.Recommendation != "Proceed" {
	log.Fatalf("recommendation is %s — read before circulating", m.Recommendation)
}
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

String job;
String status;
while (true) {
    job = api("GET", "/jobs/" + jobId, null);
    status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}

// data.output.output is the memo as PLAIN TEXT — no second JSON parse.
String memoText = /* data.output.output */;
Files.writeString(Path.of("memo.md"), memoText);

// Header lines first (COMPANY:, RECOMMENDATION:, DEAL:, CONFIDENCE:, SUMMARY:),
// then the eight "## " sections in order: Deal snapshot, Company overview,
// Investment thesis, Financial analysis, Deal terms & structure, Returns analysis,
// Risk factors, Conditions & next steps. Every body line is a "- " bullet; the
// Deal snapshot bullets split on " | " into item and value, and the Risk factors
// bullets into risk, severity, likelihood and mitigant.
// See the parser in the next section.
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"

# plain text, not JSON
raw = job["output"]
memo_text = raw.is_a?(Hash) ? raw.fetch("output", raw) : raw
File.write("memo.md", memo_text)

m = parse_memo(memo_text)  # see the next section
puts "#{m[:company]} | #{m[:deal]}"
puts "#{m[:recommendation]} #{m[:confidence]} - #{m[:summary]}"
m[:risks].each { |r| puts "  #{r[:risk]}  [#{r[:severity]}/#{r[:likelihood]}]  ->  #{r[:mitigant]}" }
abort "recommendation is #{m[:recommendation]} — read before circulating" unless m[:recommendation] == "Proceed"
$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");
}

// plain text, not JSON
$raw = $job["output"];
$memoText = is_array($raw) ? ($raw["output"] ?? "") : $raw;
file_put_contents("memo.md", $memoText);

$m = parse_memo($memoText);   // see the next section
echo "{$m['company']} | {$m['deal']}\n";
echo "{$m['recommendation']} {$m['confidence']} - {$m['summary']}\n";
foreach ($m["risks"] as $r) {
    echo "  {$r['risk']}  [{$r['severity']}/{$r['likelihood']}]  ->  {$r['mitigant']}\n";
}
if ($m["recommendation"] !== "Proceed") {
    fwrite(STDERR, "recommendation is {$m['recommendation']} — read before circulating\n");
    exit(1);
}
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);
}

// plain text, not JSON
var memoText = job.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("memo.md", memoText);

var m = ParseMemo(memoText);   // see the next section
Console.WriteLine($"{m.Company} | {m.Deal}");
Console.WriteLine($"{m.Recommendation} {m.Confidence} - {m.Summary}");
foreach (var r in m.Risks)
    Console.WriteLine($"  {r.Risk}  [{r.Severity}/{r.Likelihood}]  ->  {r.Mitigant}");
if (m.Recommendation != "Proceed")
    Console.Error.WriteLine($"recommendation is {m.Recommendation} — read before circulating");

The model is asked for the bare document and nothing else, but a stray code fence is always possible. Strip a leading ``` line and a trailing one before parsing — that is what the app does before it falls back to a retry_note reformat run. If your parse fails, retry once with retry_note set to a restatement of the shape rather than re-prompting the deal content.

The memo — output contract

The reply is plain text, not JSON. It always has the same shape: five header lines, then eight ## sections in a fixed order. Every claim in it is grounded in the material and context you sent — no revenue, EBITDA, multiple, growth rate, market size or IRR is invented, a management claim is never upgraded into a diligence finding, and a gap is named as a gap rather than filled with plausible detail.

The five header lines

LineValue
COMPANY:First line. The company or deal named in one line, from the material. Never wraps.
RECOMMENDATION:Second line. Exactly one of Proceed, Conditional proceed, Pass. This is the field to gate automation on. Proceed means the file supports investing on the stated terms; Conditional proceed means yes only if named conditions are met, and those conditions appear under Conditions & next steps; Pass means the file, read honestly, does not support the deal.
DEAL:Third line. The transaction as the material words it — structure, size, stake (majority buyout at $72mm EV, Series C growth investment of $40mm) — or exactly Not stated when the material never describes it.
CONFIDENCE:Fourth line. A bare integer 0–100 — no percent sign, no range, no words. How confident the memo is that it faithfully represents the deal file: high for a full data-room summary with financials and terms, low for fragmentary notes where the thesis and the numbers had to be pieced together.
SUMMARY:Fifth line onwards. Two to four sentences: what the deal is, the recommendation and the one or two findings that drive it, in words consistent with the RECOMMENDATION: line. It may wrap over several lines and ends at the first blank line.

The eight sections, in this order

HeadingContents
## Deal snapshotThe table the committee reads first. One bullet per line item, two pipe-separated fields — see below. Typically 4 to 10 rows: enterprise value, implied multiples, revenue, EBITDA and margin, leverage, equity check, target returns, whatever the material states.
## Company overviewOne bullet per point: what the business is, products, customers, competitive position, management — as the material shows them, not as the company pitches them.
## Investment thesisOne bullet per thesis pillar the material actually supports, with the evidence attached. "Management believes" is attribution, not evidence, and does not make a pillar.
## Financial analysisOne bullet per finding — revenue and margin trajectory, quality of earnings, working capital, capex — with the figures quoted from the file.
## Deal terms & structureOne bullet per term the material states: valuation and implied multiples, sources and uses, leverage, governance, key legal terms. May be - None stated.
## Returns analysisOne bullet per scenario or driver the material supports: base, upside and downside, IRR and MOIC where stated, and what the returns depend on. When the file carries no returns math, the memo says so rather than building one. May be - None stated.
## Risk factorsThe risk table. One bullet per risk, four pipe-separated fields — see below. Ranked by what would actually impair the investment, not a boilerplate list.
## Conditions & next stepsWhat must be true, confirmed or done: conditions for a conditional proceed, remaining diligence, next process steps. May be - None stated. — but never when RECOMMENDATION: is Conditional proceed.

Bullets, table rows and empty sections

RuleDetail
Every body line is a bulletEach line inside a section starts with - . A long bullet may wrap onto indented continuation lines — fold those into the preceding bullet when parsing.
Snapshot rows have two fields- item | value, separated by a pipe with spaces. No pipes appear inside a field, so a plain split on | is safe.
Risk rows have four fields- risk | severity | likelihood | mitigant, same separator, same no-pipes-inside rule.
severity and likelihoodEach is exactly one of High, Medium, Low — severity by potential impact on the investment, likelihood by what the material suggests. Not everything is High/High.
mitigantA real protection in, or directly implied by, the material: a contractual term, a structural feature of the deal, a diversification fact, a committed action. "Management is monitoring this" is not one. When there is none, the field is exactly No mitigant identified — that row is the single most useful thing to alert on.
Empty sectionsA section with nothing the material supports contains the single bullet - None stated. Only Deal terms & structure, Returns analysis and Conditions & next steps may legitimately be - None stated.
The thin-file ruleA file too thin to support an overview, a thesis, a financial picture, a snapshot or a risk read cannot be memoed: RECOMMENDATION: is Pass, the summary says the file is insufficient, and ## Risk factors carries Insufficient diligence material to underwrite | High | High | Complete the data room before any recommendation.
The conditional ruleWhen RECOMMENDATION: is Conditional proceed, ## Conditions & next steps must not be - None stated. A conditional yes without conditions is a broken memo — the app flags it and you should too.

A small, realistic reply for the deal file above:

COMPANY: Meridian Dock and Door Services, Inc. (Project Harborlight)
RECOMMENDATION: Conditional proceed
DEAL: Majority buyout of `82.5%` of equity at a `$72mm` enterprise value, `8.3x` FY2025 management-adjusted EBITDA
CONFIDENCE: 74
SUMMARY: Meridian is a Southeast loading-dock service business with `68%` recurring maintenance
revenue growing to `$49.2mm` in FY2025 on `$8.7mm` of management-adjusted EBITDA. The file
supports the thesis, but the entry multiple is struck on an EBITDA figure the QofE has not yet
verified and a single customer is `22%` of revenue, so the recommendation is a conditional
proceed: the add-backs must clear Calloway's draft and the Beacon relationship must be secured
before close.

## Deal snapshot
- Enterprise value | `$72mm`
- Entry multiple | `8.3x` FY2025 management-adjusted EBITDA
- FY2025 revenue | `$49.2mm`
- FY2025 adjusted EBITDA | `$8.7mm` (`17.7%` margin)
- Revenue CAGR FY2023-FY2025 | `12.9%`
- Equity check | `$38.6mm`
- Leverage | `$29mm` senior term loan, `3.3x`
- Base case returns | `2.6x` MOIC / `21%` gross IRR

## Company overview
- Loading-dock equipment maintenance and repair across the US Southeast; `68%` of revenue is
  recurring maintenance contracts, the balance time-and-materials repair and installation.
- Largest customer Beacon Logistics is `22%` of FY2025 revenue.
- Founder-CEO Dale Whitfield (61) intends to retire within two years; COO Renata Iglesias would
  step up at close. The file shows no CFO.

## Investment thesis
- Recurring maintenance revenue at `68%` of the total gives a contracted base the file evidences
  rather than a projection.
- Revenue compounded from `$38.1mm` to `$49.2mm` over FY2023-FY2025, a `12.9%` CAGR the file states.
- A management transition is already identified internally, so the founder's retirement is a
  planned handover rather than an unplanned gap.

## Financial analysis
- Revenue `$38.1mm` FY2023, `$43.6mm` FY2024, `$49.2mm` FY2025; adjusted EBITDA `$8.7mm` at a
  `17.7%` margin in FY2025.
- `$0.9mm` of the adjustments are owner add-backs the QofE has explicitly not verified; on
  unadjusted numbers the entry multiple is higher than `8.3x`.
- Calloway Partners' quality-of-earnings review is in fieldwork with a draft due in three weeks,
  so the earnings base is not yet confirmed.
- Technician turnover rose to `19%` in FY2025 from an `11%` five-year average, with wage pressure
  cited in Atlanta and Nashville.

## Deal terms & structure
- Majority buyout of `82.5%` of equity at a `$72mm` enterprise value per the 14 May LOI, with
  Whitfield rolling `17.5%`.
- Financing: `$29mm` committed senior term loan at `3.3x` leverage plus a `$38.6mm` equity check.
- `$7.2mm` escrow held for 18 months.

## Returns analysis
- Base case `2.6x` MOIC / `21%` gross IRR at an `8.0x` exit in year 5, per deal model v4.
- Downside `1.4x` / `7%`; upside `3.4x` / `28%`.
- Every scenario is struck off the `$8.7mm` adjusted EBITDA, so the returns depend on the
  unverified add-backs surviving the QofE.

## Risk factors
- Customer concentration: Beacon Logistics at `22%` of FY2025 revenue | High | Medium | Contract renewed for 3 years in January 2025
- `$0.9mm` of owner add-backs unverified by the QofE, which the entry multiple is struck on | High | Medium | Calloway QofE draft due in three weeks, before close
- Founder-CEO retiring within two years with no CFO in place | Medium | High | COO Renata Iglesias identified as successor at close
- Technician turnover at `19%` against an `11%` historical average | Medium | High | No mitigant identified
- Pending civil suit arising from the June 2025 Nashville OSHA citation | Medium | Medium | Insurer has accepted defense; `$2mm` per-occurrence limit; `$7.2mm` escrow for 18 months

## Conditions & next steps
- Calloway QofE draft confirms adjusted EBITDA within a stated tolerance of the `$8.7mm`
  marketing figure before close; reprice if it does not.
- Beacon Logistics renewal terms confirmed in writing, and the concentration re-tested at close.
- A CFO search or interim CFO commitment agreed as part of the transition plan.
- Tax review, not yet started per the file, completed before exclusivity expires.

A parser is about twenty-five lines: match the header lines, accumulate SUMMARY: until the first blank line, switch section on ## , collect - bullets, and split the snapshot and risk rows on the pipe. Treat a section holding the single item None stated. as empty.

# The document is already readable, so shell-side "parsing" is mostly slicing.
sed -n '1,5p' memo.md                                     # the header block

grep '^RECOMMENDATION: ' memo.md | cut -d' ' -f2-         # Proceed | Conditional proceed | Pass
grep '^CONFIDENCE: ' memo.md | cut -d' ' -f2              # bare integer 0-100

# one section, without its heading
section() { sed -n "/^## $1\$/,/^## /p" memo.md | sed '1d;$d' | sed '/^$/d'; }

section "Deal snapshot" | while IFS='|' read -r item value; do
  printf 'item=%s value=%s\n' "${item# - }" "$(echo "$value" | xargs)"
done

section "Risk factors" | while IFS='|' read -r risk sev like mitigant; do
  printf 'risk=%s severity=%s likelihood=%s mitigant=%s\n' \
    "${risk# - }" "$(echo "$sev" | xargs)" "$(echo "$like" | xargs)" "$(echo "$mitigant" | xargs)"
done

# "- None stated." means the section is empty, not that there is an item called None
section "Returns analysis" | grep -qx -- '- None stated.' && echo "(no returns math in the file)"
import re

SECTIONS = ["Deal snapshot", "Company overview", "Investment thesis", "Financial analysis",
            "Deal terms & structure", "Returns analysis", "Risk factors",
            "Conditions & next steps"]

def parse_memo(text):
    text = re.sub(r"^```[^\n]*\n|\n```\s*$", "", text.strip())
    head, body, summary, current = {}, {}, [], None
    for line in text.splitlines():
        m = re.match(r"^(COMPANY|RECOMMENDATION|DEAL|CONFIDENCE)\s*:\s*(.*)$", line)
        if m:
            head[m.group(1).lower()] = m.group(2).strip()
            current = None
            continue
        m = re.match(r"^SUMMARY\s*:\s*(.*)$", line)
        if m:
            summary.append(m.group(1).strip())
            current = "__summary__"
            continue
        m = re.match(r"^##\s+(.*?)\s*$", line)
        if m:
            current = m.group(1)
            body[current] = []
            continue
        if current == "__summary__":
            if not line.strip():
                current = None
            else:
                summary.append(line.strip())
        elif current in body:
            if line.startswith("- "):
                body[current].append(line[2:].strip())
            elif body[current] and line[:1].isspace() and line.strip():
                body[current][-1] += " " + line.strip()   # wrapped bullet

    for name in SECTIONS:                                  # all eight are required
        if name not in body:
            raise ValueError("missing section: " + name)
        if body[name] == ["None stated."]:
            body[name] = []

    head["confidence"] = int(head["confidence"])
    head["summary"] = " ".join(summary).strip()
    head["snapshot"] = [
        dict(zip(("item", "value"), [f.strip() for f in row.split("|")] + [""]))
        for row in body["Deal snapshot"]
    ]
    head["risks"] = [
        dict(zip(("risk", "severity", "likelihood", "mitigant"),
                 [f.strip() for f in row.split("|")] + ["", "", ""]))
        for row in body["Risk factors"]
    ]
    return head, body
const SECTIONS = ["Deal snapshot", "Company overview", "Investment thesis",
                  "Financial analysis", "Deal terms & structure", "Returns analysis",
                  "Risk factors", "Conditions & next steps"];

function parseMemo(text) {
  const clean = text.trim().replace(/^```[^\n]*\n/, "").replace(/\n```\s*$/, "");
  const head = {}, body = {}, summary = [];
  let current = null;

  for (const line of clean.split(/\r?\n/)) {
    let m = /^(COMPANY|RECOMMENDATION|DEAL|CONFIDENCE)\s*:\s*(.*)$/.exec(line);
    if (m) { head[m[1].toLowerCase()] = m[2].trim(); current = null; continue; }
    m = /^SUMMARY\s*:\s*(.*)$/.exec(line);
    if (m) { summary.push(m[1].trim()); current = "__summary__"; continue; }
    m = /^##\s+(.*?)\s*$/.exec(line);
    if (m) { current = m[1]; body[current] = []; continue; }

    if (current === "__summary__") {
      if (!line.trim()) current = null;
      else summary.push(line.trim());
    } else if (current && body[current]) {
      if (line.startsWith("- ")) body[current].push(line.slice(2).trim());
      else if (body[current].length && /^\s+\S/.test(line)) {
        body[current][body[current].length - 1] += " " + line.trim();  // wrapped bullet
      }
    }
  }

  for (const name of SECTIONS) {                       // all eight are required
    if (!body[name]) throw new Error("missing section: " + name);
    if (body[name].length === 1 && body[name][0] === "None stated.") body[name] = [];
  }

  return {
    company: head.company, recommendation: head.recommendation, deal: head.deal,
    confidence: Number(head.confidence),
    summary: summary.join(" ").trim(),
    sections: body,
    snapshot: body["Deal snapshot"].map((row) => {
      const [item = "", value = ""] = row.split("|").map((f) => f.trim());
      return { item, value };
    }),
    risks: body["Risk factors"].map((row) => {
      const [risk = "", severity = "", likelihood = "", mitigant = ""] =
        row.split("|").map((f) => f.trim());
      return { risk, severity, likelihood, mitigant };
    }),
  };
}
type SnapshotRow struct{ Item, Value string }
type RiskRow struct{ Risk, Severity, Likelihood, Mitigant string }

type Memo struct {
	Company, Recommendation, Deal, Summary string
	Confidence                             int
	Sections                               map[string][]string
	Snapshot                               []SnapshotRow
	Risks                                  []RiskRow
}

var sectionNames = []string{"Deal snapshot", "Company overview", "Investment thesis",
	"Financial analysis", "Deal terms & structure", "Returns analysis",
	"Risk factors", "Conditions & next steps"}

var headRe = regexp.MustCompile(`^(COMPANY|RECOMMENDATION|DEAL|CONFIDENCE|SUMMARY):\s*(.*)$`)

func parseMemo(text string) Memo {
	m := Memo{Sections: map[string][]string{}}
	var summary []string
	current := ""
	for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
		if h := headRe.FindStringSubmatch(line); h != nil {
			v := strings.TrimSpace(h[2])
			switch h[1] {
			case "COMPANY":
				m.Company = v
			case "RECOMMENDATION":
				m.Recommendation = v
			case "DEAL":
				m.Deal = v
			case "CONFIDENCE":
				m.Confidence, _ = strconv.Atoi(v)
			case "SUMMARY":
				summary = append(summary, v)
				current = "__summary__"
				continue
			}
			current = ""
			continue
		}
		if strings.HasPrefix(line, "## ") {
			current = strings.TrimSpace(line[3:])
			m.Sections[current] = []string{}
			continue
		}
		if current == "__summary__" {
			if strings.TrimSpace(line) == "" {
				current = ""
			} else {
				summary = append(summary, strings.TrimSpace(line))
			}
			continue
		}
		if items, ok := m.Sections[current]; ok && strings.HasPrefix(line, "- ") {
			m.Sections[current] = append(items, strings.TrimSpace(line[2:]))
		}
	}
	m.Summary = strings.Join(summary, " ")
	for _, name := range sectionNames { // all eight are required
		items, ok := m.Sections[name]
		if !ok {
			log.Fatalf("missing section: %s", name)
		}
		if len(items) == 1 && items[0] == "None stated." {
			m.Sections[name] = nil
		}
	}
	fields := func(row string, n int) []string {
		f := strings.Split(row, "|")
		for i := range f {
			f[i] = strings.TrimSpace(f[i])
		}
		for len(f) < n {
			f = append(f, "")
		}
		return f
	}
	for _, row := range m.Sections["Deal snapshot"] {
		f := fields(row, 2)
		m.Snapshot = append(m.Snapshot, SnapshotRow{f[0], f[1]})
	}
	for _, row := range m.Sections["Risk factors"] {
		f := fields(row, 4)
		m.Risks = append(m.Risks, RiskRow{f[0], f[1], f[2], f[3]})
	}
	return m
}
// Java 17+. record SnapshotRow(String item, String value) {}
//           record RiskRow(String risk, String severity, String likelihood, String mitigant) {}
static final List<String> SECTIONS = List.of("Deal snapshot", "Company overview",
    "Investment thesis", "Financial analysis", "Deal terms & structure",
    "Returns analysis", "Risk factors", "Conditions & next steps");

static Map<String, Object> parseMemo(String text) {
    var head = new LinkedHashMap<String, Object>();
    var body = new LinkedHashMap<String, List<String>>();
    var summary = new StringBuilder();
    var headRe = Pattern.compile("^(COMPANY|RECOMMENDATION|DEAL|CONFIDENCE):\\s*(.*)$");
    String current = null;

    for (String line : text.strip().split("\\R")) {
        var h = headRe.matcher(line);
        if (h.matches()) {
            head.put(h.group(1).toLowerCase(), h.group(2).strip());
            current = null;
            continue;
        }
        if (line.startsWith("SUMMARY:")) {
            summary.append(line.substring(8).strip());
            current = "__summary__";
            continue;
        }
        if (line.startsWith("## ")) {
            current = line.substring(3).strip();
            body.put(current, new ArrayList<>());
            continue;
        }
        if ("__summary__".equals(current)) {
            if (line.isBlank()) current = null;
            else summary.append(" ").append(line.strip());
        } else if (current != null && body.containsKey(current) && line.startsWith("- ")) {
            body.get(current).add(line.substring(2).strip());
        }
    }

    for (String name : SECTIONS) {                       // all eight are required
        var items = body.get(name);
        if (items == null) throw new IllegalStateException("missing section: " + name);
        if (items.equals(List.of("None stated."))) items.clear();
    }

    var snapshot = new ArrayList<SnapshotRow>();
    for (String row : body.get("Deal snapshot")) {
        String[] f = Arrays.copyOf(row.split("\\|"), 2);
        for (int i = 0; i < 2; i++) f[i] = f[i] == null ? "" : f[i].strip();
        snapshot.add(new SnapshotRow(f[0], f[1]));
    }

    var risks = new ArrayList<RiskRow>();
    for (String row : body.get("Risk factors")) {
        String[] f = Arrays.copyOf(row.split("\\|"), 4);
        for (int i = 0; i < 4; i++) f[i] = f[i] == null ? "" : f[i].strip();
        risks.add(new RiskRow(f[0], f[1], f[2], f[3]));
    }

    head.put("confidence", Integer.parseInt((String) head.get("confidence")));
    head.put("summary", summary.toString().strip());
    head.put("sections", body);
    head.put("snapshot", snapshot);
    head.put("risks", risks);
    return head;
}
SECTIONS = ["Deal snapshot", "Company overview", "Investment thesis",
            "Financial analysis", "Deal terms & structure", "Returns analysis",
            "Risk factors", "Conditions & next steps"].freeze

def parse_memo(text)
  head = {}
  body = {}
  summary = []
  current = nil

  text.strip.sub(/\A```[^\n]*\n/, "").sub(/\n```\s*\z/, "").each_line do |raw|
    line = raw.chomp
    if (m = line.match(/^(COMPANY|RECOMMENDATION|DEAL|CONFIDENCE)\s*:\s*(.*)$/))
      head[m[1].downcase.to_sym] = m[2].strip
      current = nil
    elsif (m = line.match(/^SUMMARY\s*:\s*(.*)$/))
      summary << m[1].strip
      current = :__summary__
    elsif (m = line.match(/^##\s+(.*?)\s*$/))
      current = m[1]
      body[current] = []
    elsif current == :__summary__
      line.strip.empty? ? (current = nil) : (summary << line.strip)
    elsif body[current] && line.start_with?("- ")
      body[current] << line[2..].strip
    elsif body[current] && !body[current].empty? && line.match?(/^\s+\S/)
      body[current][-1] += " " + line.strip          # wrapped bullet
    end
  end

  SECTIONS.each do |name|                             # all eight are required
    raise "missing section: #{name}" unless body.key?(name)
    body[name] = [] if body[name] == ["None stated."]
  end

  head[:confidence] = head[:confidence].to_i
  head[:summary] = summary.join(" ").strip
  head[:sections] = body
  head[:snapshot] = body["Deal snapshot"].map do |row|
    item, value = row.split("|").map(&:strip)
    { item: item.to_s, value: value.to_s }
  end
  head[:risks] = body["Risk factors"].map do |row|
    risk, severity, likelihood, mitigant = row.split("|").map(&:strip)
    { risk: risk.to_s, severity: severity.to_s,
      likelihood: likelihood.to_s, mitigant: mitigant.to_s }
  end
  head
end
const IC_SECTIONS = ["Deal snapshot", "Company overview", "Investment thesis",
                     "Financial analysis", "Deal terms & structure", "Returns analysis",
                     "Risk factors", "Conditions & next steps"];

function parse_memo(string $text): array {
    $text = preg_replace('/^```[^\n]*\n|\n```\s*$/', "", trim($text));
    $head = [];
    $body = [];
    $summary = [];
    $current = null;

    foreach (preg_split('/\R/', $text) as $line) {
        if (preg_match('/^(COMPANY|RECOMMENDATION|DEAL|CONFIDENCE)\s*:\s*(.*)$/', $line, $m)) {
            $head[strtolower($m[1])] = trim($m[2]);
            $current = null;
        } elseif (preg_match('/^SUMMARY\s*:\s*(.*)$/', $line, $m)) {
            $summary[] = trim($m[1]);
            $current = "__summary__";
        } elseif (preg_match('/^##\s+(.*?)\s*$/', $line, $m)) {
            $current = $m[1];
            $body[$current] = [];
        } elseif ($current === "__summary__") {
            if (trim($line) === "") { $current = null; } else { $summary[] = trim($line); }
        } elseif ($current !== null && isset($body[$current]) && str_starts_with($line, "- ")) {
            $body[$current][] = trim(substr($line, 2));
        }
    }

    foreach (IC_SECTIONS as $name) {                    // all eight are required
        if (!isset($body[$name])) { throw new Exception("missing section: $name"); }
        if ($body[$name] === ["None stated."]) { $body[$name] = []; }
    }

    $head["confidence"] = (int) $head["confidence"];
    $head["summary"] = trim(implode(" ", $summary));
    $head["sections"] = $body;
    $head["snapshot"] = array_map(function ($row) {
        $f = array_pad(array_map("trim", explode("|", $row)), 2, "");
        return ["item" => $f[0], "value" => $f[1]];
    }, $body["Deal snapshot"]);
    $head["risks"] = array_map(function ($row) {
        $f = array_pad(array_map("trim", explode("|", $row)), 4, "");
        return ["risk" => $f[0], "severity" => $f[1],
                "likelihood" => $f[2], "mitigant" => $f[3]];
    }, $body["Risk factors"]);
    return $head;
}
// .NET 8+
record SnapshotRow(string Item, string Value);
record RiskRow(string Risk, string Severity, string Likelihood, string Mitigant);

record Memo(string Company, string Recommendation, string Deal,
            int Confidence, string Summary,
            Dictionary<string, List<string>> Sections,
            List<SnapshotRow> Snapshot, List<RiskRow> Risks);

static readonly string[] SectionNames =
    { "Deal snapshot", "Company overview", "Investment thesis", "Financial analysis",
      "Deal terms & structure", "Returns analysis", "Risk factors",
      "Conditions & next steps" };

static Memo ParseMemo(string text)
{
    var head = new Dictionary<string, string>();
    var body = new Dictionary<string, List<string>>();
    var summary = new List<string>();
    var headRe = new Regex(@"^(COMPANY|RECOMMENDATION|DEAL|CONFIDENCE)\s*:\s*(.*)$");
    string? current = null;

    foreach (var line in text.Trim().Split('\n').Select(l => l.TrimEnd('\r')))
    {
        var h = headRe.Match(line);
        if (h.Success) { head[h.Groups[1].Value.ToLower()] = h.Groups[2].Value.Trim(); current = null; continue; }
        if (line.StartsWith("SUMMARY:")) { summary.Add(line[8..].Trim()); current = "__summary__"; continue; }
        if (line.StartsWith("## ")) { current = line[3..].Trim(); body[current] = new(); continue; }

        if (current == "__summary__")
        {
            if (line.Trim().Length == 0) current = null; else summary.Add(line.Trim());
        }
        else if (current != null && body.ContainsKey(current) && line.StartsWith("- "))
        {
            body[current].Add(line[2..].Trim());
        }
    }

    foreach (var name in SectionNames)                  // all eight are required
    {
        if (!body.TryGetValue(name, out var items)) throw new Exception("missing section: " + name);
        if (items.Count == 1 && items[0] == "None stated.") items.Clear();
    }

    string[] Fields(string row, int n) =>
        row.Split('|').Select(p => p.Trim()).Concat(Enumerable.Repeat("", n)).Take(n).ToArray();

    var snapshot = body["Deal snapshot"]
        .Select(row => { var f = Fields(row, 2); return new SnapshotRow(f[0], f[1]); }).ToList();
    var risks = body["Risk factors"]
        .Select(row => { var f = Fields(row, 4); return new RiskRow(f[0], f[1], f[2], f[3]); }).ToList();

    return new Memo(head["company"], head["recommendation"], head["deal"],
        int.Parse(head["confidence"]), string.Join(" ", summary).Trim(), body, snapshot, risks);
}

This is an AI-drafted memo built from text you supplied, not a diligence report and not investment advice. Read RECOMMENDATION: and CONFIDENCE: first — Conditional proceed means the yes depends on the conditions listed, and a low confidence means the file was too fragmentary to underwrite cleanly. Check every figure, term and mitigant against the deal file before the memo goes to committee, and never treat a recommendation as an investment decision without a human making it.

Step 5 — Stream the memo 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 memo on a full deal file is a long document. 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. An Idempotency-Key header is supported here too, and recommended.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the memo, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). Because the reply is plain text, the partial document is already readable — watching for the ## Financial analysis and ## Risk factors headings as they arrive makes a good progress indicator, and it is exactly what the app's own stage list does.
done{job_id, status, charged_credits, output}The final, authoritative result — read the memo from output.output rather than trusting concatenated deltas (the SSE tail can drop), 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 $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: icm-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"COMPANY: Meridian Dock and Door Services"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":418,"output":{"output":"COMPANY: ..."}}
import json, requests

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

memo_text = result["output"]["output"]                    # authoritative, plain text
print("\ncharged:", result["charged_credits"])
head, sections = parse_memo(memo_text)
print(head["company"], "-", head["recommendation"], head["confidence"])
for row in head["risks"]:
    print(f'  {row["risk"]}  [{row["severity"]}/{row["likelihood"]}]  ->  {row["mitigant"]}')
with open("memo.md", "w", encoding="utf-8") as fh:
    fh.write(memo_text)
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 memoText = done.output.output;                   // authoritative, plain text
const memo = parseMemo(memoText);
console.log(`\n${done.charged_credits} credits - ${memo.company} [${memo.recommendation}]`);
for (const row of memo.risks) {
  console.log(`  ${row.risk}  [${row.severity}/${row.likelihood}]  ->  ${row.mitigant}`);
}
writeFileSync("memo.md", memoText);
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", "icm-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"])
		}
	}
}

// The memo is plain text at final["output"]["output"] — no JSON parse.
memoText := final["output"].(map[string]any)["output"].(string)
os.WriteFile("memo.md", []byte(memoText), 0o644)
m := parseMemo(memoText)
fmt.Printf("\n%s [%s %d]\n", m.Company, m.Recommendation, m.Confidence)
for _, r := range m.Risks {
	fmt.Printf("  %s  [%s/%s]  ->  %s\n", r.Risk, r.Severity, r.Likelihood, r.Mitigant)
}
// 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", "icm-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` as JSON; data.output.output is the memo as PLAIN TEXT.
// Feed it to parseMemo() from the previous section, then:
//   Files.writeString(Path.of("memo.md"), memoText);
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"] = "icm-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

memo_text = done["output"]["output"]           # authoritative, plain text
File.write("memo.md", memo_text)
m = parse_memo(memo_text)
puts "\n#{done["charged_credits"]} credits - #{m[:company]} [#{m[:recommendation]}]"
m[:risks].each { |r| puts "  #{r[:risk]}  [#{r[:severity]}/#{r[:likelihood]}]  ->  #{r[:mitigant]}" }
$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: icm-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);

$memoText = $done["output"]["output"];         // authoritative, plain text
file_put_contents("memo.md", $memoText);
$m = parse_memo($memoText);
echo "\n{$done['charged_credits']} credits - {$m['company']} [{$m['recommendation']}]\n";
foreach ($m["risks"] as $r) {
    echo "  {$r['risk']}  [{$r['severity']}/{$r['likelihood']}]  ->  {$r['mitigant']}\n";
}
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "icm-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!);
// plain text, not JSON
var memoText = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("memo.md", memoText);

var m = ParseMemo(memoText);
Console.WriteLine($"\n{m.Company} [{m.Recommendation} {m.Confidence}]");
foreach (var r in m.Risks)
    Console.WriteLine($"  {r.Risk}  [{r.Severity}/{r.Likelihood}]  ->  {r.Mitigant}");

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.