Minutes Studio — API

Post the transcript, get the minutes.

API tokens Open the app

Minute your meetings from your own tools

Send whatever the meeting left behind — a transcript with Name: lines, a Slack or Teams log, or the rough notes someone typed during the call — and get back one plain-text document in a fixed shape: the meeting titled and dated from the material, the attendees it names, an honest coverage call, a confidence number, a two-to-four-sentence summary, and then six sections — agenda, key discussion points, decisions, an action-items table carrying owner, deadline and status per task, next steps and the parking lot. Nothing is invented to fill a gap: a task nobody took is Unassigned, a date nobody stated is No deadline, and material too thin to document 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 bot that records your calls, run it nightly over a transcript folder, or post the minutes straight into a wiki. 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":"minutes-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 minutes are written by the gpt-terra model (currently gpt-5.6-terra). Estimates are free; runs are metered against your credit balance. There is a single run task — one paste of material in, one minutes document out, no follow-up calls and no session state to carry.

POST /guest
GET /me
POST /estimate
POST /run
POST /run-stream
StatusMeaning
400Malformed JSON body, or material missing entirely.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest submitting a very large transcript).
404Unknown job id.
429Too many runs in flight — back off and retry.
5xxTransient platform error — retry with backoff.

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

Step 0 — A tiny client

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

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

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

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

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered minute-taking 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":"minutes-studio"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "minutes-studio"})["token"]
const { token } = await api("POST", "/guest", { slug: "minutes-studio" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "minutes-studio"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"minutes-studio"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "minutes-studio" })["token"]
$token = api("POST", "/guest", ["slug" => "minutes-studio"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "minutes-studio" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:minutes-studio, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before sending a two-hour transcript.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are piping a long transcript in and want a ceiling before spending credits. The input object is the request body itself — it is not wrapped in {"input": …}.

Input fieldTypeNotes
materialstring, requiredThe raw meeting material as pasted: a transcript with Name: utterance lines, a chat log, or the rough notes someone typed during the call. Messy, out of order and full of filler is fine. This is the model's only evidence — no calendar, no recording, no CRM is read. If you clip a long transcript, mark the cut in-band with [material truncated - N characters (~M lines) removed from the MIDDLE of the meeting. The opening and the closing of the material are intact; the middle is missing, so treat anything that would have been agreed there as not captured.] so the minutes report 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 transcript carries its decisions and its action items at the finish. A head-only slice(0, 60000) would throw away the part minutes exist to record.
contextstring, optionalWho is taking the minutes and for whom, what to emphasize, the team or project involved — anything the note-taker knows that the transcript does not say. It sharpens what gets compressed; it never licenses invention. The web UI caps it at 6,000 characters. Send "" when you have nothing to add.
factsstring, optionalPlain text, not an object — the summary of a mechanical browser-side prescan of material: detected speaker names with line counts, how many lines look like commitments, how many date-like and time-like fragments appeared. Pure pattern-matching, offered as a hint to cross-check against, never a verdict: where the scan and the material disagree, the material wins. Omit it, or send "", and nothing changes except that the model has one fewer cross-check. The exact wording the app sends is shown below.
retry_notestring, optionalReformat 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 meeting — leave it out of ordinary calls, and put anything you want the minutes to reflect in context.

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

Mechanical scan of the pasted material (pattern-matching, not judgement):
- 138 words over 11 non-empty lines.
- Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).
- 4 lines match commitment patterns ("will ...", "by <day>", "action:").
- 2 date-like and 0 time-like fragments detected.
cat > material.txt <<'MATERIAL'
Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.
Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.
Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.
Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.
Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?
Devon: Yes, two days is enough.
Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.
Tobias: Someone needs to put a DST banner on the booking page before the 8th.
Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.
Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.
Priya: Tabled for next week.
MATERIAL

cat > facts.txt <<'FACTS'
Mechanical scan of the pasted material (pattern-matching, not judgement):
- 138 words over 11 non-empty lines.
- Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).
- 4 lines match commitment patterns ("will ...", "by <day>", "action:").
- 2 date-like and 0 time-like fragments detected.
FACTS

# the input object IS the body — no {"input": ...} wrapper
jq -n --rawfile material material.txt --rawfile facts facts.txt \
  '{material: $material,
    context: "Minutes go on the team wiki and to two people who missed the call. Capture owners and deadlines precisely.",
    facts: $facts}' > 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 = """Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.
Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.
Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.
Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.
Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?
Devon: Yes, two days is enough.
Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.
Tobias: Someone needs to put a DST banner on the booking page before the 8th.
Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.
Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.
Priya: Tabled for next week.
"""

FACTS = (
    "Mechanical scan of the pasted material (pattern-matching, not judgement):\n"
    "- 138 words over 11 non-empty lines.\n"
    "- Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), "
    "Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).\n"
    '- 4 lines match commitment patterns ("will ...", "by <day>", "action:").\n'
    "- 2 date-like and 0 time-like fragments detected."
)

# the input object IS the body — no {"input": ...} wrapper
payload = {
    "material": MATERIAL,
    "context": ("Minutes go on the team wiki and to two people who missed the call. "
                "Capture owners and deadlines precisely."),
    "facts": FACTS,
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits", "on", est.get("model"))
const material = [
  "Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.",
  "Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.",
  "Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.",
  "Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.",
  "Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?",
  "Devon: Yes, two days is enough.",
  "Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.",
  "Tobias: Someone needs to put a DST banner on the booking page before the 8th.",
  "Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.",
  "Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.",
  "Priya: Tabled for next week.",
].join("\n");

const facts = [
  "Mechanical scan of the pasted material (pattern-matching, not judgement):",
  "- 138 words over 11 non-empty lines.",
  "- Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).",
  '- 4 lines match commitment patterns ("will ...", "by <day>", "action:").',
  "- 2 date-like and 0 time-like fragments detected.",
].join("\n");

// the input object IS the body — no {"input": ...} wrapper
const payload = {
  material,
  context:
    "Minutes go on the team wiki and to two people who missed the call. Capture owners and deadlines precisely.",
  facts,
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits on", est.model);
const material = "Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.\n" +
	"Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.\n" +
	"Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.\n" +
	"Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.\n" +
	"Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?\n" +
	"Devon: Yes, two days is enough.\n" +
	"Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.\n" +
	"Tobias: Someone needs to put a DST banner on the booking page before the 8th.\n" +
	"Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.\n" +
	"Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.\n" +
	"Priya: Tabled for next week.\n"

const facts = "Mechanical scan of the pasted material (pattern-matching, not judgement):\n" +
	"- 138 words over 11 non-empty lines.\n" +
	"- Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).\n" +
	"- 4 lines match commitment patterns (\"will ...\", \"by <day>\", \"action:\").\n" +
	"- 2 date-like and 0 time-like fragments detected."

// the input object IS the body — no {"input": ...} wrapper
payload := map[string]any{
	"material": material,
	"context":  "Minutes go on the team wiki and to two people who missed the call. Capture owners and deadlines precisely.",
	"facts":    facts,
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
err := call("POST", "/estimate", payload, &est)
String material = """
    Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.
    Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.
    Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.
    Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.
    Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?
    Devon: Yes, two days is enough.
    Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.
    Tobias: Someone needs to put a DST banner on the booking page before the 8th.
    Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.
    Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.
    Priya: Tabled for next week.
    """;

String facts = """
    Mechanical scan of the pasted material (pattern-matching, not judgement):
    - 138 words over 11 non-empty lines.
    - Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).
    - 4 lines match commitment patterns ("will ...", "by <day>", "action:").
    - 2 date-like and 0 time-like fragments detected.""";

// the input object IS the body — no {"input": ...} wrapper.
// toJsonString() is your JSON library's string escaper.
String jsonPayload = """
    {"material": %s,
     "context": "Minutes go on the team wiki and to two people who missed the call. Capture owners and deadlines precisely.",
     "facts": %s}
    """.formatted(toJsonString(material), toJsonString(facts));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
MATERIAL = <<~MATERIAL
  Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.
  Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.
  Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.
  Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.
  Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?
  Devon: Yes, two days is enough.
  Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.
  Tobias: Someone needs to put a DST banner on the booking page before the 8th.
  Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.
  Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.
  Priya: Tabled for next week.
MATERIAL

FACTS = <<~FACTS.strip
  Mechanical scan of the pasted material (pattern-matching, not judgement):
  - 138 words over 11 non-empty lines.
  - Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).
  - 4 lines match commitment patterns ("will ...", "by <day>", "action:").
  - 2 date-like and 0 time-like fragments detected.
FACTS

# the input object IS the body — no {"input": ...} wrapper
payload = { material: MATERIAL,
            context: "Minutes go on the team wiki and to two people who missed the call. " \
                     "Capture owners and deadlines precisely.",
            facts: FACTS }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits on #{est["model"]}"
$material = <<<'MATERIAL'
Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.
Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.
Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.
Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.
Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?
Devon: Yes, two days is enough.
Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.
Tobias: Someone needs to put a DST banner on the booking page before the 8th.
Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.
Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.
Priya: Tabled for next week.
MATERIAL;

$facts = <<<'FACTS'
Mechanical scan of the pasted material (pattern-matching, not judgement):
- 138 words over 11 non-empty lines.
- Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).
- 4 lines match commitment patterns ("will ...", "by <day>", "action:").
- 2 date-like and 0 time-like fragments detected.
FACTS;

// the input object IS the body — no {"input": ...} wrapper
$payload = [
    "material" => $material,
    "context"  => "Minutes go on the team wiki and to two people who missed the call. "
                  . "Capture owners and deadlines precisely.",
    "facts"    => $facts,
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var material = """
    Priya: Morning all - fifteen minutes on 4.2, then the timezone backlog.
    Devon: 4.2 is ready except the reminder-scheduler migration script. Marisol wrote it, nobody has reviewed it.
    Marisol: It rewrites the reminders table and backfills about 400k rows. I don't want it merged without a second reviewer.
    Devon: Then we ship Monday the 9th, not Friday. I'll get the review done by Thursday.
    Priya: Agreed - Monday the 9th. Does the customer webinar on the 11th still hold?
    Devon: Yes, two days is enough.
    Nadia: February churn came in at 4.1 percent, up from 3.2. I'll pull the reasons into a one-pager by the 10th.
    Tobias: Someone needs to put a DST banner on the booking page before the 8th.
    Priya: Nobody has bandwidth this week. Leave it unowned for now and I'll find someone.
    Tobias: Can we take the booking widget direction next week? I need a full thirty minutes.
    Priya: Tabled for next week.
    """;

var facts = """
    Mechanical scan of the pasted material (pattern-matching, not judgement):
    - 138 words over 11 non-empty lines.
    - Speaker-prefixed lines detected: Priya (3 lines), Devon (3 lines), Marisol (2 lines), Nadia (1 lines), Tobias (2 lines).
    - 4 lines match commitment patterns ("will ...", "by <day>", "action:").
    - 2 date-like and 0 time-like fragments detected.
    """;

// the input object IS the body — no {"input": ...} wrapper
var payload = new {
    material,
    context = "Minutes go on the team wiki and to two people who missed the call. "
            + "Capture owners and deadlines precisely.",
    facts,
};

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 claims a speaker the material does not support, the material wins and the extra name is dropped. Its real value is the count of commitment-shaped lines — a large gap between that count and the number of rows under ## Action items is worth a look, because it usually means the transcript discussed tasks nobody actually took.

Step 4 — Write the minutes and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same body as /estimate — the input object itself — places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 30–90 s for a normal sync, longer for an hour of transcript). 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: ms-$(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 minutes are plain text — -r keeps them readable
echo "$JOB" | jq -r '.data.output.output' > minutes.md

head -6 minutes.md                                  # the six header lines
sed -n '/^## Action items$/,/^## /p' minutes.md      # just the action table

# gate anything automated on the coverage line
grep -q '^COVERAGE: Full minutes$' minutes.md \
  || { echo "coverage is not Full minutes — read before circulating"; exit 1; }

# unowned tasks die quietly; surface them
grep '^- ' minutes.md | grep ' | Unassigned | ' || true
import time

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

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

head, sections = parse_minutes(minutes_text)   # see the next section
print(head["title"], "|", head["date"], "|", head["attendees"])
print(head["coverage"], head["confidence"], "-", head["summary"])
for row in head["actions"]:
    print(f'  {row["task"]}  ->  {row["owner"]} / {row["deadline"]} [{row["status"]}]')

if head["coverage"] != "Full minutes":
    raise SystemExit(f'coverage is {head["coverage"]} — 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 minutesText = job.output?.output ?? job.output;
writeFileSync("minutes.md", minutesText);

const minutes = parseMinutes(minutesText);     // see the next section
console.log(`${minutes.title} | ${minutes.date} | ${minutes.attendees}`);
console.log(`${minutes.coverage} ${minutes.confidence} - ${minutes.summary}`);
for (const row of minutes.actions) {
  console.log(`  ${row.task}  ->  ${row.owner} / ${row.deadline} [${row.status}]`);
}
const unowned = minutes.actions.filter((r) => r.owner === "Unassigned");
if (unowned.length) console.warn(`${unowned.length} action item(s) have no owner`);
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 minutes, as plain text>"} — one unwrap, no JSON parse
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
minutesText := wrapper.Output

os.WriteFile("minutes.md", []byte(minutesText), 0o644)

m := parseMinutes(minutesText) // see the next section
fmt.Printf("%s | %s | %s\n", m.Title, m.Date, m.Attendees)
fmt.Printf("%s %d - %s\n", m.Coverage, m.Confidence, m.Summary)
for _, r := range m.Actions {
	fmt.Printf("  %s  ->  %s / %s [%s]\n", r.Task, r.Owner, r.Deadline, r.Status)
}
if m.Coverage != "Full minutes" {
	log.Fatalf("coverage is %s — read before circulating", m.Coverage)
}
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 minutes as PLAIN TEXT — no second JSON parse.
String minutesText = /* data.output.output */;
Files.writeString(Path.of("minutes.md"), minutesText);

// Header lines first (TITLE:, DATE:, ATTENDEES:, COVERAGE:, CONFIDENCE:, SUMMARY:),
// then the six "## " sections in order: Agenda, Key discussion points, Decisions,
// Action items, Next steps, Parking lot. Every body line is a "- " bullet, and the
// Action items bullets split on " | " into task, owner, deadline, status.
// 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"]
minutes_text = raw.is_a?(Hash) ? raw.fetch("output", raw) : raw
File.write("minutes.md", minutes_text)

m = parse_minutes(minutes_text)  # see the next section
puts "#{m[:title]} | #{m[:date]} | #{m[:attendees]}"
puts "#{m[:coverage]} #{m[:confidence]} - #{m[:summary]}"
m[:actions].each { |r| puts "  #{r[:task]}  ->  #{r[:owner]} / #{r[:deadline]} [#{r[:status]}]" }
abort "coverage is #{m[:coverage]} — read before circulating" unless m[:coverage] == "Full minutes"
$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"];
$minutesText = is_array($raw) ? ($raw["output"] ?? "") : $raw;
file_put_contents("minutes.md", $minutesText);

$m = parse_minutes($minutesText);   // see the next section
echo "{$m['title']} | {$m['date']} | {$m['attendees']}\n";
echo "{$m['coverage']} {$m['confidence']} - {$m['summary']}\n";
foreach ($m["actions"] as $r) {
    echo "  {$r['task']}  ->  {$r['owner']} / {$r['deadline']} [{$r['status']}]\n";
}
if ($m["coverage"] !== "Full minutes") {
    fwrite(STDERR, "coverage is {$m['coverage']} — 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 minutesText = job.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("minutes.md", minutesText);

var m = ParseMinutes(minutesText);   // see the next section
Console.WriteLine($"{m.Title} | {m.Date} | {m.Attendees}");
Console.WriteLine($"{m.Coverage} {m.Confidence} - {m.Summary}");
foreach (var r in m.Actions)
    Console.WriteLine($"  {r.Task}  ->  {r.Owner} / {r.Deadline} [{r.Status}]");
if (m.Coverage != "Full minutes")
    Console.Error.WriteLine($"coverage is {m.Coverage} — 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 meeting content.

The minutes — output contract

The reply is plain text, not JSON. It always has the same shape: six header lines, then six ## sections in a fixed order. Every claim in it is grounded in the material and context you sent — no attendee, owner, deadline, date or figure is invented, a suggestion is never upgraded into a decision, and a gap is named as a gap rather than filled with plausible detail.

The six header lines

LineValue
TITLE:First line. The meeting named in one line, from the material or inferred from what it covers. Never wraps.
DATE:Second line. The date and time wording the material uses, copied as-is — or exactly Not stated. Never a date the material does not carry.
ATTENDEES:Third line. Comma-separated names of the people the material shows as present, or exactly Not stated. Partial attendance ("joined late") is noted under Key discussion points, not here.
COVERAGE:Fourth line. Exactly one of Full minutes, Partial - gaps noted, Not enough to minute. This is the field to gate automation on.
CONFIDENCE:Fifth line. A bare integer 0–100 — no percent sign, no range, no words. How confident the minutes are that they faithfully represent the material: high for a clean transcript with named speakers and explicit agreements, low for fragmentary notes where who-agreed-to-what had to be inferred.
SUMMARY:Sixth line onwards. Two to four sentences: what the meeting was, the headline outcomes, anything a reader must know. It may wrap over several lines and ends at the first blank line.

The six sections, in this order

HeadingContents
## AgendaOne bullet per topic the meeting actually covered, in the order covered — reconstructed from the material, not invented.
## Key discussion pointsOne bullet per substantive point: what was said, by whom when the material names them, with the numbers and dates that were quoted. Filler is compressed ruthlessly; numbers, names, dates and commitments never are.
## DecisionsOne bullet per decision the material shows being agreed, stated as the decision, with who agreed it if named. One person's suggestion is a discussion point, not a decision. May be - None.
## Action itemsThe table. One bullet per task, four pipe-separated fields — see below. May be - None.
## Next stepsWhat happens next per the material: follow-ups, the next meeting if mentioned, anything agreed about how the work proceeds. An unowned task usually shows up here as needing an owner.
## Parking lotOnly what the material explicitly defers — "take that offline", "table this for next week". An unresolved disagreement that was not deferred stays under Key discussion points. May be - None.

Bullets, action rows and empty sections

RuleDetail
Every body line is a bulletEach line inside a section starts with - . A long bullet may wrap onto indented continuation lines — fold those into the preceding bullet when parsing.
Action rows have four fields- task | owner | deadline | status, separated by a pipe with spaces. No pipes appear inside a field, so a plain split on | is safe.
ownerThe person the material puts on the hook, or exactly Unassigned when nobody took it. An Unassigned row is the single most useful thing to alert on — unowned tasks die quietly.
deadlineThe deadline the material states, in the material's own wording (Thursday, later this week, the 10th) — vagueness is copied, not converted into a date nobody said. Exactly No deadline when none was agreed.
statusExactly one of To do, In progress, Done, Blocked. To do for anything newly agreed in this meeting; In progress or Done only when the material says work started or finished; Blocked when it says the task waits on something.
Empty sectionsA section with nothing to report contains the single bullet - None. Only Decisions, Action items and Parking lot may legitimately be - None.
The thin-material ruleWhen COVERAGE: is Not enough to minute, both Decisions and Action items must be - None. A reply that breaks this is self-contradictory — the app flags it and you should too.

A small, realistic reply for the transcript above:

TITLE: Weekly product/eng sync - release 4.2 and the timezone backlog
DATE: Not stated
ATTENDEES: Priya, Devon, Marisol, Nadia, Tobias
COVERAGE: Full minutes
CONFIDENCE: 88
SUMMARY: Short weekly sync covering the `4.2` release, the timezone bug backlog, February
churn and the booking widget. The team moved the `4.2` release from Friday to Monday the 9th
so the unreviewed reminder-scheduler migration is not shipped into a weekend, and confirmed
the customer webinar on the 11th still holds. The DST banner for the booking page was agreed
as needed but nobody took it, and the booking widget direction was tabled for next week.

## Agenda
- `4.2` release readiness and the reminder-scheduler migration script
- Timezone bug backlog and the DST banner stopgap
- February churn
- Booking widget direction

## Key discussion points
- Devon said `4.2` is ready apart from the reminder-scheduler migration script, which
  Marisol wrote and nobody has reviewed.
- Marisol said the script rewrites the reminders table and backfills about `400k` rows, and
  she did not want it merged without a second reviewer.
- Nadia reported February churn at `4.1` percent, up from `3.2` percent.
- Tobias said a DST banner is needed on the booking page before the 8th; Priya said nobody
  has bandwidth this week and left it unowned deliberately.

## Decisions
- Ship `4.2` on Monday the 9th rather than Friday, so the unreviewed migration is not
  released going into a weekend; proposed by Devon and agreed by Priya.
- Keep the customer webinar on the 11th despite the release moving; confirmed by Devon.

## Action items
- Review the reminder-scheduler migration script | Devon | Thursday | To do
- Pull the February churn reasons into a one-pager | Nadia | the 10th | To do
- Add a DST banner to the booking page | Unassigned | before the 8th | To do

## Next steps
- Priya will find an owner for the DST banner after this call.
- The booking widget direction gets a full thirty minutes at next week's sync.

## Parking lot
- Booking widget direction, tabled by Tobias and Priya for a thirty-minute slot next week.

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

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

grep '^COVERAGE: ' minutes.md | cut -d' ' -f2-            # Full minutes | Partial - gaps noted | ...
grep '^CONFIDENCE: ' minutes.md | cut -d' ' -f2           # bare integer 0-100

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

section "Decisions"
section "Action items" | while IFS='|' read -r task owner deadline status; do
  printf 'task=%s owner=%s deadline=%s status=%s\n' \
    "${task# - }" "$(echo "$owner" | xargs)" "$(echo "$deadline" | xargs)" "$(echo "$status" | xargs)"
done

# "- None." means the section is empty, not that there is a task called None
section "Parking lot" | grep -qx -- '- None.' && echo "(nothing tabled)"
import re

SECTIONS = ["Agenda", "Key discussion points", "Decisions",
            "Action items", "Next steps", "Parking lot"]

def parse_minutes(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"^(TITLE|DATE|ATTENDEES|COVERAGE|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 six are required
        if name not in body:
            raise ValueError("missing section: " + name)
        if body[name] == ["None."]:
            body[name] = []

    head["confidence"] = int(head["confidence"])
    head["summary"] = " ".join(summary).strip()
    head["actions"] = [
        dict(zip(("task", "owner", "deadline", "status"),
                 [f.strip() for f in row.split("|")] + ["", "", ""]))
        for row in body["Action items"]
    ]
    return head, body
const SECTIONS = ["Agenda", "Key discussion points", "Decisions",
                  "Action items", "Next steps", "Parking lot"];

function parseMinutes(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 = /^(TITLE|DATE|ATTENDEES|COVERAGE|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 six are required
    if (!body[name]) throw new Error("missing section: " + name);
    if (body[name].length === 1 && body[name][0] === "None.") body[name] = [];
  }

  return {
    title: head.title, date: head.date, attendees: head.attendees,
    coverage: head.coverage, confidence: Number(head.confidence),
    summary: summary.join(" ").trim(),
    sections: body,
    actions: body["Action items"].map((row) => {
      const [task = "", owner = "", deadline = "", status = ""] =
        row.split("|").map((f) => f.trim());
      return { task, owner, deadline, status };
    }),
  };
}
type Action struct{ Task, Owner, Deadline, Status string }

type Minutes struct {
	Title, Date, Attendees, Coverage, Summary string
	Confidence                                int
	Sections                                  map[string][]string
	Actions                                   []Action
}

var sectionNames = []string{"Agenda", "Key discussion points", "Decisions",
	"Action items", "Next steps", "Parking lot"}

var headRe = regexp.MustCompile(`^(TITLE|DATE|ATTENDEES|COVERAGE|CONFIDENCE|SUMMARY):\s*(.*)$`)

func parseMinutes(text string) Minutes {
	m := Minutes{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 "TITLE":
				m.Title = v
			case "DATE":
				m.Date = v
			case "ATTENDEES":
				m.Attendees = v
			case "COVERAGE":
				m.Coverage = 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 six are required
		items, ok := m.Sections[name]
		if !ok {
			log.Fatalf("missing section: %s", name)
		}
		if len(items) == 1 && items[0] == "None." {
			m.Sections[name] = nil
		}
	}
	for _, row := range m.Sections["Action items"] {
		f := strings.Split(row, "|")
		for i := range f {
			f[i] = strings.TrimSpace(f[i])
		}
		for len(f) < 4 {
			f = append(f, "")
		}
		m.Actions = append(m.Actions, Action{f[0], f[1], f[2], f[3]})
	}
	return m
}
// Java 17+. record Action(String task, String owner, String deadline, String status) {}
static final List<String> SECTIONS = List.of("Agenda", "Key discussion points",
    "Decisions", "Action items", "Next steps", "Parking lot");

static Map<String, Object> parseMinutes(String text) {
    var head = new LinkedHashMap<String, Object>();
    var body = new LinkedHashMap<String, List<String>>();
    var summary = new StringBuilder();
    var headRe = Pattern.compile("^(TITLE|DATE|ATTENDEES|COVERAGE|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 six are required
        var items = body.get(name);
        if (items == null) throw new IllegalStateException("missing section: " + name);
        if (items.equals(List.of("None."))) items.clear();
    }

    var actions = new ArrayList<Action>();
    for (String row : body.get("Action items")) {
        String[] f = Arrays.copyOf(row.split("\\|"), 4);
        for (int i = 0; i < 4; i++) f[i] = f[i] == null ? "" : f[i].strip();
        actions.add(new Action(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("actions", actions);
    return head;
}
SECTIONS = ["Agenda", "Key discussion points", "Decisions",
            "Action items", "Next steps", "Parking lot"].freeze

def parse_minutes(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(/^(TITLE|DATE|ATTENDEES|COVERAGE|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 six are required
    raise "missing section: #{name}" unless body.key?(name)
    body[name] = [] if body[name] == ["None."]
  end

  head[:confidence] = head[:confidence].to_i
  head[:summary] = summary.join(" ").strip
  head[:sections] = body
  head[:actions] = body["Action items"].map do |row|
    task, owner, deadline, status = row.split("|").map(&:strip)
    { task: task.to_s, owner: owner.to_s, deadline: deadline.to_s, status: status.to_s }
  end
  head
end
const MS_SECTIONS = ["Agenda", "Key discussion points", "Decisions",
                     "Action items", "Next steps", "Parking lot"];

function parse_minutes(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('/^(TITLE|DATE|ATTENDEES|COVERAGE|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 (MS_SECTIONS as $name) {                    // all six are required
        if (!isset($body[$name])) { throw new Exception("missing section: $name"); }
        if ($body[$name] === ["None."]) { $body[$name] = []; }
    }

    $head["confidence"] = (int) $head["confidence"];
    $head["summary"] = trim(implode(" ", $summary));
    $head["sections"] = $body;
    $head["actions"] = array_map(function ($row) {
        $f = array_map("trim", explode("|", $row));
        $f = array_pad($f, 4, "");
        return ["task" => $f[0], "owner" => $f[1], "deadline" => $f[2], "status" => $f[3]];
    }, $body["Action items"]);
    return $head;
}
// .NET 8+
record Action(string Task, string Owner, string Deadline, string Status);

record Minutes(string Title, string Date, string Attendees, string Coverage,
               int Confidence, string Summary,
               Dictionary<string, List<string>> Sections, List<Action> Actions);

static readonly string[] SectionNames =
    { "Agenda", "Key discussion points", "Decisions",
      "Action items", "Next steps", "Parking lot" };

static Minutes ParseMinutes(string text)
{
    var head = new Dictionary<string, string>();
    var body = new Dictionary<string, List<string>>();
    var summary = new List<string>();
    var headRe = new Regex(@"^(TITLE|DATE|ATTENDEES|COVERAGE|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 six are required
    {
        if (!body.TryGetValue(name, out var items)) throw new Exception("missing section: " + name);
        if (items.Count == 1 && items[0] == "None.") items.Clear();
    }

    var actions = body["Action items"].Select(row =>
    {
        var f = row.Split('|').Select(p => p.Trim()).Concat(new[] { "", "", "", "" }).ToArray();
        return new Action(f[0], f[1], f[2], f[3]);
    }).ToList();

    return new Minutes(head["title"], head["date"], head["attendees"], head["coverage"],
        int.Parse(head["confidence"]), string.Join(" ", summary).Trim(), body, actions);
}

These are AI-generated minutes from text you supplied, not a recording and not a legal record. Read COVERAGE: and CONFIDENCE: first — Partial - gaps noted means real gaps remain and the minutes name them where they occur, and Not enough to minute means the material was too thin to document honestly. Check every owner and deadline against the material before circulating, and never treat a recorded decision as authoritative without a human confirming it.

Step 5 — Stream the minutes as they are written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because a full set of minutes for an hour-long call is a long document. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON. An Idempotency-Key header is supported here too, and recommended.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the minutes, 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 ## Decisions and ## Action items headings as they arrive makes a good progress indicator.
done{job_id, status, charged_credits, output}The final, authoritative result — read the minutes 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: ms-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"TITLE: Weekly product/eng sync"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":312,"output":{"output":"TITLE: ..."}}
import json, requests

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

minutes_text = result["output"]["output"]                 # authoritative, plain text
print("\ncharged:", result["charged_credits"])
head, sections = parse_minutes(minutes_text)
print(head["title"], "-", head["coverage"], head["confidence"])
for row in head["actions"]:
    print(f'  {row["task"]}  ->  {row["owner"]} / {row["deadline"]} [{row["status"]}]')
with open("minutes.md", "w", encoding="utf-8") as fh:
    fh.write(minutes_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 minutesText = done.output.output;                // authoritative, plain text
const minutes = parseMinutes(minutesText);
console.log(`\n${done.charged_credits} credits - ${minutes.title} [${minutes.coverage}]`);
for (const row of minutes.actions) {
  console.log(`  ${row.task}  ->  ${row.owner} / ${row.deadline} [${row.status}]`);
}
writeFileSync("minutes.md", minutesText);
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", "ms-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 minutes are plain text at final["output"]["output"] — no JSON parse.
minutesText := final["output"].(map[string]any)["output"].(string)
os.WriteFile("minutes.md", []byte(minutesText), 0o644)
m := parseMinutes(minutesText)
fmt.Printf("\n%s [%s %d]\n", m.Title, m.Coverage, m.Confidence)
for _, r := range m.Actions {
	fmt.Printf("  %s  ->  %s / %s [%s]\n", r.Task, r.Owner, r.Deadline, r.Status)
}
// 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", "ms-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 minutes as PLAIN TEXT.
// Feed it to parseMinutes() from the previous section, then:
//   Files.writeString(Path.of("minutes.md"), minutesText);
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"] = "ms-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

minutes_text = done["output"]["output"]        # authoritative, plain text
File.write("minutes.md", minutes_text)
m = parse_minutes(minutes_text)
puts "\n#{done["charged_credits"]} credits - #{m[:title]} [#{m[:coverage]}]"
m[:actions].each { |r| puts "  #{r[:task]}  ->  #{r[:owner]} / #{r[:deadline]} [#{r[:status]}]" }
$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: ms-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);

$minutesText = $done["output"]["output"];      // authoritative, plain text
file_put_contents("minutes.md", $minutesText);
$m = parse_minutes($minutesText);
echo "\n{$done['charged_credits']} credits - {$m['title']} [{$m['coverage']}]\n";
foreach ($m["actions"] as $r) {
    echo "  {$r['task']}  ->  {$r['owner']} / {$r['deadline']} [{$r['status']}]\n";
}
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "ms-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 minutesText = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("minutes.md", minutesText);

var m = ParseMinutes(minutesText);
Console.WriteLine($"\n{m.Title} [{m.Coverage} {m.Confidence}]");
foreach (var r in m.Actions)
    Console.WriteLine($"  {r.Task}  ->  {r.Owner} / {r.Deadline} [{r.Status}]");

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.