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.
| Status | Meaning |
|---|---|
400 | Malformed JSON body, or material missing entirely. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large transcript). |
404 | Unknown job id. |
429 | Too many runs in flight — back off and retry. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, os, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see step 1
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
raise RuntimeError(payload.get("error", {}).get("message", res.reason))
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct{ Message string `json:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered 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
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
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 field | Type | Notes |
|---|---|---|
material | string, required | The 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. |
context | string, optional | Who 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. |
facts | string, optional | Plain 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_note | string, optional | Reformat retry only. When a first reply does not match the output contract, the app sends the identical input once more with this field carrying a restatement of the required shape. It is not a place for instructions about the 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
/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
| Line | Value |
|---|---|
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
| Heading | Contents |
|---|---|
## Agenda | One bullet per topic the meeting actually covered, in the order covered — reconstructed from the material, not invented. |
## Key discussion points | One 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. |
## Decisions | One 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 items | The table. One bullet per task, four pipe-separated fields — see below. May be - None. |
## Next steps | What 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 lot | Only 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
| Rule | Detail |
|---|---|
| Every body line is a bullet | Each line inside a section starts with - . A long bullet may wrap onto indented continuation lines — fold those into the preceding bullet when parsing. |
| 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. |
owner | The 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. |
deadline | The 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. |
status | Exactly 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 sections | A section with nothing to report contains the single bullet - None. Only Decisions, Action items and Parking lot may legitimately be - None. |
| The thin-material rule | When 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.