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.
| Status | Meaning |
|---|---|
400 | Malformed JSON body, or material missing entirely. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large deal file). |
404 | Unknown job id. |
429 | Too many runs in flight — back off and retry. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export 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
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
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
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 field | Type | Notes |
|---|---|---|
material | string, required | The 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. |
context | string, optional | The 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. |
facts | string, optional | Plain 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_note | string, optional | Reserved — 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
/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
| Line | Value |
|---|---|
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
| Heading | Contents |
|---|---|
## Deal snapshot | The 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 overview | One bullet per point: what the business is, products, customers, competitive position, management — as the material shows them, not as the company pitches them. |
## Investment thesis | One 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 analysis | One bullet per finding — revenue and margin trajectory, quality of earnings, working capital, capex — with the figures quoted from the file. |
## Deal terms & structure | One bullet per term the material states: valuation and implied multiples, sources and uses, leverage, governance, key legal terms. May be - None stated. |
## Returns analysis | One 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 factors | The 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 steps | What 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
| Rule | Detail |
|---|---|
| Every body line is a bullet | Each 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 likelihood | Each 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. |
mitigant | A 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 sections | A 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 rule | A 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 rule | When 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.