Everything the web app does, you can do from your own code: send one canonical set of company
facts and get back a drafted fundraising asset together with the fact base it was built from,
the cross-checks that were run on the numbers, the gaps you still have to fill and the
questions a partner will ask. Useful for regenerating a one-pager whenever your metrics
change, drafting application answers for a batch of accelerators, or refusing to publish
anything that does not come back “Send-ready”.
Base URL https://api.skillsafe.ai/v1/app-api.
There is no /apps/{slug}/ segment in any of these paths —
the app slug is bound to your token when you create it at /guest, or by signing
in on this origin. Getting that wrong returns 404 not_found.
{"ok":true,"data":{…}} on success,
{"ok":false,"error":{"code":"…","message":"…"}} on failure.
Check ok before reading data.
The object you send to /estimate, /run and /run-stream. Only facts and asset are required.
| Field | Type | Meaning |
|---|---|---|
facts | string | Required. The source of truth as written: what the company does, traction with dates, pricing, the raise and its instrument, use of funds, team, milestones. Messy, partial or contradictory notes are expected — conflicts are surfaced rather than papered over. Clipped at 40,000 characters from the middle, keeping both ends, because a founder’s notes open with the company and close with the ask. |
asset | string | Required. Exactly one of One-pager, Investor memo, Application answers, Deck outline — spelled and capitalised exactly that way. Anything else is a validation_error. |
notes | string | Optional. Audience and framing: the stage, the fund or accelerator, tone constraints, what to lead with. For Application answers the questions themselves live here; with none supplied a standard set is answered. Clipped at 6,000 characters. |
existing | string | Optional. A draft you already have. When present the asset comes back as a revision of it — what holds up is kept, what conflicts with facts is fixed, and every substantive change is accounted for under Assumptions and gaps with a Revised: prefix. Clipped at 20,000 characters. |
lint | string | Optional. A summary of mechanical checks you ran yourself over the same text. Treated as an untrusted hint: each finding is re-verified against facts before it is repeated, and anything unconfirmable is dropped. The web app supplies its browser-side fact check here. |
retry_note | string | Optional, and not for humans. Tells the model its previous reply did not parse and to re-emit the same asset in the required shape. Reuse the same Idempotency-Key when you send it. |
Every call carries Authorization: Bearer <token>. Open the token page to sign in, reveal your token and copy a ready-made shell export — it never asks you to open the DevTools console. A guest token is enough for /me and /estimate; a signed-in token is needed to run unless the app is sponsoring guests.
# Every call below reuses this. Get the token from the token page
# linked above - never paste it into a shared shell history.
export SKILLSAFE_TOKEN="YOUR_TOKEN"
export SKILLSAFE_BASE="https://api.skillsafe.ai/v1/app-api"
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(method, path, body=None, extra_headers=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
if data:
req.add_header("Content-Type", "application/json")
for k, v in (extra_headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req) as r:
payload = json.load(r)
if not payload.get("ok"):
raise RuntimeError(payload.get("error"))
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
// Read this from your own secret store - never hard-code it in a repo.
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body, extraHeaders = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
...extraHeaders,
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(payload.error?.message ?? res.statusText);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func call(method, path string, body any, extra map[string]string) (map[string]any, error) {
var rdr *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var payload struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
Error map[string]any `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
if !payload.OK {
return nil, errors.New("app-api error")
}
return payload.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class RaiseReady {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) b = b.header("Content-Type", "application/json");
for (var e : extra.entrySet()) b = b.header(e.getKey(), e.getValue());
b = b.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // parse with your JSON library of choice
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(method, path, body = nil, extra = {})
uri = URI(BASE + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
extra.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise payload["error"].to_s unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
function call(string $method, string $path, ?array $body = null, array $extra = []): array {
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
$headers = ["Authorization: Bearer $token"];
if ($body !== null) { $headers[] = "Content-Type: application/json"; }
foreach ($extra as $k => $v) { $headers[] = "$k: $v"; }
$ch = curl_init(BASE . $path);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) { throw new RuntimeException(json_encode($payload["error"] ?? null)); }
return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class RaiseReady {
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(HttpMethod method, string path,
object? body = null,
IDictionary<string, string>? extra = null) {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
using var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
if (extra is not null)
foreach (var kv in extra) req.Headers.Add(kv.Key, kv.Value);
var res = await Http.SendAsync(req);
var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!payload.GetProperty("ok").GetBoolean())
throw new Exception(payload.GetProperty("error").ToString());
return payload.GetProperty("data");
}
}
# A guest token, no browser involved. Bound to this app's slug.
curl -s -X POST "$SKILLSAFE_BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"raise-ready"}'
# -> {"ok":true,"data":{"token":"...","subject_type":"guest","credits":0}}
guest = call("POST", "/guest", {"slug": "raise-ready"})
TOKEN = guest["token"] # reuse for the calls below
const guest = await call("POST", "/guest", { slug: "raise-ready" });
// reuse guest.token for the calls below
guest, err := call("POST", "/guest", map[string]any{"slug": "raise-ready"}, nil)
if err != nil {
panic(err)
}
_ = guest["token"] // reuse for the calls below
String guest = call("POST", "/guest", "{\"slug\":\"raise-ready\"}", Map.of());
// pull data.token out of the response and reuse it below
guest = call("POST", "/guest", { "slug" => "raise-ready" })
token = guest["token"] # reuse for the calls below
$guest = call("POST", "/guest", ["slug" => "raise-ready"]);
$token = $guest["token"]; // reuse for the calls below
var guest = await Call(HttpMethod.Post, "/guest", new { slug = "raise-ready" });
var token = guest.GetProperty("token").GetString(); // reuse below
GET /me is free. It tells you whether the token is personal or a guest, and the credit balance a run would draw on.
curl -s "$SKILLSAFE_BASE/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","subject_id":"...","credits":124500}}
me = call("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
me, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
fmt.Println(me["subject_type"], me["credits"])
String me = call("GET", "/me", null, Map.of());
System.out.println(me);
me = call("GET", "/me")
puts me["subject_type"], me["credits"]
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
POST /estimate is free: it creates no job and charges nothing.
It returns the model this app is bound to, the publisher markup, and
hold_credits — the amount reserved for a run, which prices the
full output cap. The actual charge is usually far lower. Compare
hold_credits against the balance from /me and refuse to submit when
it is short; a 402 after submit is a failure of your client, not of the user.
# Free. Creates no job and charges nothing.
curl -s -X POST "$SKILLSAFE_BASE/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"facts": "Northwind Freight Accounting - automated settlement for mid-market carriers.\nMRR $8.4k as of May 2026, up from $3.1k in January.\nRaising $1.5M on a SAFE at a $12M post-money cap.",
"asset": "One-pager",
"notes": "Seed fund, first touch."
}'
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":1597,"min_credits":420}}
draft_input = {
"facts": (
"Northwind Freight Accounting - automated settlement for mid-market carriers.\n"
"MRR $8.4k as of May 2026, up from $3.1k in January.\n"
"31 paying carriers, priced at $270/mo per carrier.\n"
"Raising $1.5M on a SAFE at a $12M post-money cap.\n"
"Use of funds: 55% engineering, 25% go-to-market, 20% runway extension.\n"
"Team: two co-founders, ex-Flexport ops and ex-Stripe payments.\n"
"Milestone: $25k MRR and 90 carriers by Q2 2027."
),
"asset": "One-pager",
"notes": "Seed fund, first touch. Lead with the revenue growth.",
"existing": "",
}
est = call("POST", "/estimate", draft_input)
print(est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"])
# Preflight: never submit a run you cannot afford.
if call("GET", "/me")["credits"] < est["hold_credits"]:
raise SystemExit("top up before running")
const draftInput = {
facts: [
"Northwind Freight Accounting - automated settlement for mid-market carriers.",
"MRR $8.4k as of May 2026, up from $3.1k in January.",
"31 paying carriers, priced at $270/mo per carrier.",
"Raising $1.5M on a SAFE at a $12M post-money cap.",
"Use of funds: 55% engineering, 25% go-to-market, 20% runway extension.",
].join("\n"),
asset: "One-pager",
notes: "Seed fund, first touch. Lead with the revenue growth.",
existing: "",
};
const est = await call("POST", "/estimate", draftInput);
console.log(est.model, est.model_alias, est.markup_bps, est.hold_credits);
// Preflight: never submit a run you cannot afford.
const me = await call("GET", "/me");
if (me.credits < est.hold_credits) throw new Error("top up before running");
draftInput := map[string]any{
"facts": "Northwind Freight Accounting - automated settlement for mid-market carriers.\nMRR $8.4k as of May 2026.\nRaising $1.5M on a SAFE at a $12M post-money cap.",
"asset": "One-pager",
"notes": "Seed fund, first touch.",
"existing": "",
}
est, err := call("POST", "/estimate", draftInput, nil)
if err != nil {
panic(err)
}
fmt.Println(est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"])
String draftInput = """
{"facts":"Northwind Freight Accounting - automated settlement for mid-market carriers.\\nMRR $8.4k as of May 2026.\\nRaising $1.5M on a SAFE at a $12M post-money cap.",
"asset":"One-pager",
"notes":"Seed fund, first touch.",
"existing":""}
""";
String est = call("POST", "/estimate", draftInput, Map.of());
System.out.println(est); // assert model / model_alias / markup_bps
draft_input = {
"facts" => "Northwind Freight Accounting - automated settlement for mid-market carriers.\n" \
"MRR $8.4k as of May 2026, up from $3.1k in January.\n" \
"Raising $1.5M on a SAFE at a $12M post-money cap.",
"asset" => "One-pager",
"notes" => "Seed fund, first touch.",
"existing" => "",
}
est = call("POST", "/estimate", draft_input)
puts est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"]
$draftInput = [
"facts" => "Northwind Freight Accounting - automated settlement for mid-market carriers.\n"
. "MRR $8.4k as of May 2026, up from $3.1k in January.\n"
. "Raising $1.5M on a SAFE at a $12M post-money cap.",
"asset" => "One-pager",
"notes" => "Seed fund, first touch.",
"existing" => "",
];
$est = call("POST", "/estimate", $draftInput);
echo $est["model"], " ", $est["model_alias"], " ", $est["hold_credits"], PHP_EOL;
var draftInput = new {
facts = "Northwind Freight Accounting - automated settlement for mid-market carriers.\n" +
"MRR $8.4k as of May 2026, up from $3.1k in January.\n" +
"Raising $1.5M on a SAFE at a $12M post-money cap.",
asset = "One-pager",
notes = "Seed fund, first touch.",
existing = "",
};
var est = await Call(HttpMethod.Post, "/estimate", draftInput);
Console.WriteLine(est.GetProperty("model")); // gpt-5.6-terra
Console.WriteLine(est.GetProperty("model_alias")); // gpt-terra
Console.WriteLine(est.GetProperty("markup_bps")); // 1000
gpt-terra, the balanced OpenAI tier alias, which currently
resolves to gpt-5.6-terra at markup_bps: 1000 (a 10% publisher
cut). Asserting those three fields off /estimate is the cheapest way to confirm
you are talking to the app you think you are.
The request body is the input object directly — there is no
{"input": {…}} wrapper. POST /run returns a
job_id you poll; GET /jobs/{job_id} reports
state and, once terminal, output.output.
Idempotency-Key. Runs are billed. A retried POST
carrying the same key returns the original job instead of starting — and billing —
a second one. Derive it from a hash of the input plus an attempt counter, and reuse the same
key when you resend with retry_note.
# The body IS the input object - there is no {"input": {...}} wrapper.
# Idempotency-Key makes a retried POST return the FIRST job instead of billing twice.
curl -s -X POST "$SKILLSAFE_BASE/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: raise-ready-northwind-onepager-0" \
-d '{"facts":"...","asset":"One-pager","notes":"Seed fund, first touch."}'
# -> {"ok":true,"data":{"job_id":"job_..."}}
# Poll until terminal.
curl -s "$SKILLSAFE_BASE/jobs/job_..." -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import hashlib, time
def idempotency_key(inp, attempt=0):
digest = hashlib.sha256(json.dumps(inp, sort_keys=True).encode()).hexdigest()[:16]
return f"raise-ready-{digest}-{attempt}"
job = call("POST", "/run", draft_input,
{"Idempotency-Key": idempotency_key(draft_input)})
while True:
status = call("GET", "/jobs/" + job["job_id"])
if status["state"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(1.5)
report = status["output"]["output"] # the tagged plain-text reply
print(report)
import { createHash } from "node:crypto";
function idempotencyKey(input, attempt = 0) {
const digest = createHash("sha256")
.update(JSON.stringify(input))
.digest("hex")
.slice(0, 16);
return `raise-ready-${digest}-${attempt}`;
}
const job = await call("POST", "/run", draftInput, {
"Idempotency-Key": idempotencyKey(draftInput),
});
let status;
do {
await new Promise((r) => setTimeout(r, 1500));
status = await call("GET", `/jobs/${job.job_id}`);
} while (!["succeeded", "failed", "cancelled"].includes(status.state));
const report = status.output.output; // the tagged plain-text reply
console.log(report);
job, err := call("POST", "/run", draftInput, map[string]string{
"Idempotency-Key": "raise-ready-northwind-onepager-0",
})
if err != nil {
panic(err)
}
var status map[string]any
for {
status, err = call("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if err != nil {
panic(err)
}
state, _ := status["state"].(string)
if state == "succeeded" || state == "failed" || state == "cancelled" {
break
}
time.Sleep(1500 * time.Millisecond)
}
String job = call("POST", "/run", draftInput,
Map.of("Idempotency-Key", "raise-ready-northwind-onepager-0"));
// Extract data.job_id, then poll GET /jobs/{job_id} until state is
// succeeded, failed or cancelled; the reply text is data.output.output.
require "digest"
def idempotency_key(input, attempt = 0)
digest = Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]
"raise-ready-#{digest}-#{attempt}"
end
job = call("POST", "/run", draft_input,
{ "Idempotency-Key" => idempotency_key(draft_input) })
status = nil
loop do
status = call("GET", "/jobs/#{job['job_id']}")
break if %w[succeeded failed cancelled].include?(status["state"])
sleep 1.5
end
report = status["output"]["output"]
function idempotency_key(array $input, int $attempt = 0): string {
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
return "raise-ready-$digest-$attempt";
}
$job = call("POST", "/run", $draftInput,
["Idempotency-Key" => idempotency_key($draftInput)]);
do {
sleep(2);
$status = call("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($status["state"], ["succeeded", "failed", "cancelled"], true));
$report = $status["output"]["output"];
using System.Security.Cryptography;
static string IdempotencyKey(object input, int attempt = 0) {
var json = JsonSerializer.Serialize(input);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16];
return $"raise-ready-{hash}-{attempt}";
}
var job = await Call(HttpMethod.Post, "/run", draftInput,
new Dictionary<string, string> { ["Idempotency-Key"] = IdempotencyKey(draftInput) });
JsonElement status;
string state;
do {
await Task.Delay(1500);
status = await Call(HttpMethod.Get, $"/jobs/{job.GetProperty("job_id").GetString()}");
state = status.GetProperty("state").GetString()!;
} while (state is not ("succeeded" or "failed" or "cancelled"));
POST /run-stream emits server-sent events: a job event, then
delta events carrying text as the reply is written, then a
done event with the authoritative full output,
charged_credits and truncated. Prefer the done payload
over your accumulated deltas — a stream can drop its tail. If it dies mid-body, keep
what parsed rather than discarding work already paid for.
# Server-sent events: the reply arrives as it is written.
curl -N -X POST "$SKILLSAFE_BASE/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: raise-ready-northwind-onepager-0" \
-H "Accept: text/event-stream" \
-d '{"facts":"...","asset":"One-pager"}'
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"ASSET: One-pager\n"}
# event: done data: {"output":{"output":"..."},"charged_credits":238,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(draft_input).encode(),
method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("Idempotency-Key", idempotency_key(draft_input))
buf = []
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode().rstrip("\n")
if line.startswith("data: "):
payload = json.loads(line[6:])
if "text" in payload:
buf.append(payload["text"])
elif "output" in payload:
print("charged:", payload.get("charged_credits"))
report = "".join(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
"Idempotency-Key": idempotencyKey(draftInput),
},
body: JSON.stringify(draftInput),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let report = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = JSON.parse(line.slice(6));
if (payload.text) report += payload.text;
else if (payload.output) console.log("charged:", payload.charged_credits);
}
}
body, _ := json.Marshal(draftInput)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", "raise-ready-northwind-onepager-0")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var report strings.Builder
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
var payload struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(line[6:]), &payload)
if payload.Text != "" {
report.WriteString(payload.Text)
}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Idempotency-Key", "raise-ready-northwind-onepager-0")
.POST(HttpRequest.BodyPublishers.ofString(draftInput))
.build();
StringBuilder report = new StringBuilder();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data: "))
.forEach(l -> report.append(extractTextField(l.substring(6))));
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Idempotency-Key"] = idempotency_key(draft_input)
req.body = JSON.generate(draft_input)
report = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data: ")
payload = JSON.parse(line[6..])
report << payload["text"] if payload["text"]
end
end
end
end
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
$report = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Accept: text/event-stream",
"Idempotency-Key: " . idempotency_key($draftInput),
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($draftInput));
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$report) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data: ")) {
$payload = json_decode(substr($line, 6), true);
if (isset($payload["text"])) { $report .= $payload["text"]; }
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
using var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", IdempotencyKey(draftInput));
req.Content = new StringContent(JsonSerializer.Serialize(draftInput),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var report = new StringBuilder();
while (await reader.ReadLineAsync() is { } line) {
if (!line.StartsWith("data: ")) continue;
var payload = JsonDocument.Parse(line[6..]).RootElement;
if (payload.TryGetProperty("text", out var t))
report.Append(t.GetString());
}
The reply is plain text, not JSON. Four tag lines, then six
## sections in exactly this order. A reply that breaks any of these rules should
be re-requested with retry_note rather than parsed loosely.
| Element | Rule |
|---|---|
ASSET: | First line. Echoes the requested asset exactly: One-pager, Investor memo, Application answers or Deck outline. |
VERDICT: | Exactly one of Send-ready, Needs your numbers, Not enough to draft. Needs your numbers guarantees at least one bullet under Assumptions and gaps. |
CONFIDENCE: | A bare integer 0–100. No percent sign, no range, no words. |
SUMMARY: | 2–4 sentences. May wrap over several lines; ends at the first blank line. |
## The asset | The drafted material itself, ready to send. Uses ### for internal structure — one per memo section, per application question, or per slide — never ##. |
## Source of truth | Bullets, each <label>: <value>. A number that appears in the asset but not here is a defect. |
## Consistency check | Bullets. Each cross-check run and what it found, with the arithmetic shown. |
## Assumptions and gaps | Bullets, or the single bullet None. Each missing fact matches a <placeholder> in the asset. |
## Red flags | Bullets, or the single bullet None. |
## Investor questions | Bullets. 3–6 questions a partner would ask. |
ASSET: One-pager
VERDICT: Needs your numbers
CONFIDENCE: 72
SUMMARY: Drafted a seed one-pager for Northwind Freight Accounting around the
strongest fact in the material - MRR growing from $3.1k in January to $8.4k in
May 2026. The raise is stated twice with different amounts, so the ask carries a
placeholder until you confirm which figure is right.
## The asset
### What we do
Northwind Freight Accounting automates settlement for mid-market carriers.
### Traction
$8.4k MRR as of May 2026, up from $3.1k in January - 31 paying carriers at
$270/mo each.
### The ask
Raising <the confirmed raise amount> on a SAFE at a $12M post-money cap.
## Source of truth
- What we do: automated settlement for mid-market carriers
- MRR: $8.4k as of May 2026, up from $3.1k in January
- Customers: 31 paying carriers
- Pricing: $270/mo per carrier
- Raise: stated as both $1.5M and $1.2M
- Instrument: SAFE, $12M post-money cap
## Consistency check
- 31 carriers x $270/mo = $8,370/mo, consistent with the stated $8.4k MRR.
- Use of funds 55% + 25% + 20% = 100%, an allocation that adds up.
- Raise appears with two values, $1.5M and $1.2M - the asset uses a placeholder
rather than picking one.
## Assumptions and gaps
- The raise amount: the facts state $1.5M in one line and $1.2M in another.
Confirm which is current.
## Red flags
- MRR of $8.4k is early for a $12M post-money cap; expect the cap to be
questioned against the traction.
## Investor questions
- Which of $1.5M and $1.2M is the round you are actually raising?
- What is monthly logo churn across the 31 carriers?
- What does the $270/mo price look like at 90 carriers - same price, or tiered?
# The reply is plain text, not JSON. Split it on the tag lines and
# the six "## " headings, in the order the contract fixes.
printf '%s\n' "$REPORT" | sed -n '1,4p' # ASSET / VERDICT / CONFIDENCE / SUMMARY
# Pull one section out by name:
printf '%s\n' "$REPORT" | awk '/^## Source of truth$/,/^## Consistency check$/'
# Gate a pipeline on the verdict:
printf '%s\n' "$REPORT" | grep -q '^VERDICT: Send-ready$' || exit 1
import re
HEADINGS = ["The asset", "Source of truth", "Consistency check",
"Assumptions and gaps", "Red flags", "Investor questions"]
def parse_report(text):
out = {}
for tag in ("ASSET", "VERDICT", "CONFIDENCE"):
m = re.search(rf"^{tag}:\s*(.+)$", text, re.M)
if not m:
raise ValueError(f"missing {tag}")
out[tag.lower()] = m.group(1).strip()
out["confidence"] = int(out["confidence"])
m = re.search(r"^SUMMARY:\s*(.*?)(?=\n\s*\n)", text, re.M | re.S)
out["summary"] = m.group(1).strip() if m else ""
for i, head in enumerate(HEADINGS):
nxt = rf"^## {re.escape(HEADINGS[i + 1])}$" if i + 1 < len(HEADINGS) else r"\Z"
m = re.search(rf"^## {re.escape(head)}$\n(.*?)(?={nxt})", text, re.M | re.S)
if not m:
raise ValueError(f"missing section: {head}")
out[head] = m.group(1).strip()
return out
parsed = parse_report(report)
assert parsed["verdict"] in ("Send-ready", "Needs your numbers", "Not enough to draft")
# Gate your pipeline on the verdict.
if parsed["verdict"] != "Send-ready":
raise SystemExit("not ready to send: " + parsed["summary"])
const HEADINGS = ["The asset", "Source of truth", "Consistency check",
"Assumptions and gaps", "Red flags", "Investor questions"];
function parseReport(text) {
const out = {};
for (const tag of ["ASSET", "VERDICT", "CONFIDENCE"]) {
const m = text.match(new RegExp(`^${tag}:\\s*(.+)$`, "m"));
if (!m) throw new Error(`missing ${tag}`);
out[tag.toLowerCase()] = m[1].trim();
}
out.confidence = Number.parseInt(out.confidence, 10);
const s = text.match(/^SUMMARY:\s*([\s\S]*?)(?=\n\s*\n)/m);
out.summary = s ? s[1].trim() : "";
HEADINGS.forEach((head, i) => {
const next = HEADINGS[i + 1] ? `^## ${HEADINGS[i + 1]}$` : "$(?![\\s\\S])";
const m = text.match(new RegExp(`^## ${head}$\\n([\\s\\S]*?)(?=${next})`, "m"));
if (!m) throw new Error(`missing section: ${head}`);
out[head] = m[1].trim();
});
return out;
}
const parsed = parseReport(report);
if (parsed.verdict !== "Send-ready") {
throw new Error(`not ready to send: ${parsed.summary}`);
}
// Split on the six fixed headings, in contract order.
headings := []string{
"The asset", "Source of truth", "Consistency check",
"Assumptions and gaps", "Red flags", "Investor questions",
}
verdict := regexp.MustCompile(`(?m)^VERDICT:\s*(.+)$`).FindStringSubmatch(report.String())
if len(verdict) < 2 {
panic("missing VERDICT")
}
if strings.TrimSpace(verdict[1]) != "Send-ready" {
panic("not ready to send")
}
for _, head := range headings {
if !regexp.MustCompile(`(?m)^## ` + regexp.QuoteMeta(head) + `$`).MatchString(report.String()) {
panic("missing section: " + head)
}
}
// The six headings are fixed and ordered; split on them directly.
List<String> headings = List.of(
"The asset", "Source of truth", "Consistency check",
"Assumptions and gaps", "Red flags", "Investor questions");
Matcher m = Pattern.compile("(?m)^VERDICT:\\s*(.+)$").matcher(report);
if (!m.find()) throw new IllegalStateException("missing VERDICT");
if (!m.group(1).trim().equals("Send-ready")) {
throw new IllegalStateException("not ready to send");
}
for (String head : headings) {
if (!Pattern.compile("(?m)^## " + Pattern.quote(head) + "$").matcher(report).find()) {
throw new IllegalStateException("missing section: " + head);
}
}
HEADINGS = ["The asset", "Source of truth", "Consistency check",
"Assumptions and gaps", "Red flags", "Investor questions"].freeze
def parse_report(text)
out = {}
%w[ASSET VERDICT CONFIDENCE].each do |tag|
m = text.match(/^#{tag}:\s*(.+)$/)
raise "missing #{tag}" unless m
out[tag.downcase] = m[1].strip
end
out["confidence"] = out["confidence"].to_i
HEADINGS.each_with_index do |head, i|
nxt = HEADINGS[i + 1] ? /^## #{Regexp.escape(HEADINGS[i + 1])}$/ : /\z/
m = text.match(/^## #{Regexp.escape(head)}$\n(.*?)(?=#{nxt})/m)
raise "missing section: #{head}" unless m
out[head] = m[1].strip
end
out
end
parsed = parse_report(report)
abort("not ready to send") unless parsed["verdict"] == "Send-ready"
$headings = ["The asset", "Source of truth", "Consistency check",
"Assumptions and gaps", "Red flags", "Investor questions"];
$parsed = [];
foreach (["ASSET", "VERDICT", "CONFIDENCE"] as $tag) {
if (!preg_match("/^$tag:\s*(.+)$/m", $report, $m)) {
throw new RuntimeException("missing $tag");
}
$parsed[strtolower($tag)] = trim($m[1]);
}
foreach ($headings as $i => $head) {
$next = isset($headings[$i + 1])
? "^## " . preg_quote($headings[$i + 1], "/") . "$"
: "\\z";
$re = "/^## " . preg_quote($head, "/") . "$\n(.*?)(?=$next)/ms";
if (!preg_match($re, $report, $m)) {
throw new RuntimeException("missing section: $head");
}
$parsed[$head] = trim($m[1]);
}
if ($parsed["verdict"] !== "Send-ready") { throw new RuntimeException("not ready to send"); }
string[] headings = {
"The asset", "Source of truth", "Consistency check",
"Assumptions and gaps", "Red flags", "Investor questions",
};
var text = report.ToString();
var verdict = Regex.Match(text, @"(?m)^VERDICT:\s*(.+)$");
if (!verdict.Success) throw new InvalidOperationException("missing VERDICT");
if (verdict.Groups[1].Value.Trim() != "Send-ready")
throw new InvalidOperationException("not ready to send");
foreach (var head in headings)
if (!Regex.IsMatch(text, $@"(?m)^## {Regex.Escape(head)}$"))
throw new InvalidOperationException($"missing section: {head}");
Send-ready verdict whose asset still contains a <placeholder>
should be treated as Needs your numbers, and a Needs your numbers
verdict with nothing under Assumptions and gaps should not be trusted as complete.
Failures come back as {"ok":false,"error":{"code":…,"message":…}}.
| Code | HTTP | What to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed, stale or revoked. Mint a new one on the token page. |
payment_required | 402 | The balance is below min_credits. Preflight with /estimate against /me and this never fires after submit. |
not_found | 404 | Almost always a wrong path. There is no /apps/{slug}/ segment — the slug is bound to the token at /guest. |
validation_error | 400 | The input object failed validation — usually asset not being one of the four exact strings, or facts empty. |
rate_limited | 429 | Too many calls. Back off and retry; do not tight-loop. |
sponsor_exhausted | 402 | A guest token hit the app’s daily sponsorship budget. Sign in for a personal token. |
internal_error | 500 | Transient. Retry with the same Idempotency-Key so you are not billed twice. |
The rule this app inherits from
@affaan-m/investor-materials
is that all investor materials must agree with each other — not just that any one
of them is internally consistent. The API makes that enforceable: run the same
facts through each of the four asset types, then compare the
## Source of truth sections. They are guaranteed to be
<label>: <value> bullets, so the same label appearing with two
different numbers across two assets is a conflict you can fail a build on. The web app runs
exactly this check in the browser across your saved drafts.