Microcopy Studio — API

The words the interface says, written from your own tools.

API tokens Open the app

Write the interface copy from your own pipeline

Send a description of the product moment — the screen or flow, what the user is trying to do, how they feel, what the copy must communicate and what it may not promise — optionally with the strings the team ships today, and get back the microcopy to ship: recommended strings labeled per element, two to four alternatives with the tone and the trade-off named, the rationale for the team, a review of the copy you pasted, localization notes for translators and the open questions that could still change a string — all under a verdict where any open question rules out Use as is. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it into a design-system pipeline, a content-review bot on your pull requests, or a localization hand-off. 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 slug (microcopy-studio) is bound to the token when you mint it at /guest, and every later call is scoped by that token alone. 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. Estimates are free; runs are metered against your credit balance. There is a single run task — one brief in, one copy set out, no follow-up calls and no session state to carry.

StatusMeaning
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 brief).
404Unknown job id.
409An Idempotency-Key was replayed with a different body.
422The input object failed validation — usually a missing brief or an enum value outside the lists below.
429Rate limited — 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, headers = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  headers.each { |k, v| req[k] = v }
  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, array $extra = []): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => array_merge([
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ], $extra),
        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 copy 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 the one call that names the slug, which is why nothing later in this page carries it.

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

The app stores this browser's token under the localStorage key skillsafe_app_token:microcopy-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 long brief.

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"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, 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 and model names the model that will write the copy. Nothing is charged and no job is created, so estimating is free.

The body is the input object itself — not wrapped in an {"input": …} envelope. These are all the fields the app sends:

Input fieldTypeNotes
briefstring, requiredThe product moment in prose: which screen or flow, what the user is trying to do, how they feel, what the copy must communicate, and the constraints — tone rules, character limits, things that must or must not be said. This is the model's only evidence about your product; anything the brief does not establish comes back as an open question rather than a plausible guess. The app truncates at 12,000 characters and appends [brief truncated].
existingstring, optionalThe strings the team ships today, one per line and ideally labeled — Primary button: Submit. Sending this is what fills the Review of your copy section; send an empty string and that section comes back as - None.. The app truncates at 8,000 characters.
elementstringOne of auto, cta, error, empty, confirm, tooltip, loading, onboarding, notification, label. auto lets the brief decide, and is right whenever the moment spans several elements. Anything outside the list is coerced to auto.
tonestringOne of neutral, friendly, formal, playful, reassuring. Anything outside the list is coerced to neutral.
limitstringA character budget for the recommended strings, as a string of digits"25", not 25. Send the empty string "" for no limit. The app accepts one to four digits and drops anything else to "".
copyscanstringA compact, plain-text summary of what the free in-browser linter matched, passed to the model as an untrusted hint. Two clauses joined by . : brief mentions: <element labels> from the brief scan, and existing-copy lint: <Finding title (where)>; … (or no findings across N lines) from the lint of existing. API callers can send the empty string "" — the run is unaffected apart from losing the hint.
retry_notestring, optionalOnly set by the app's automatic reformat retry, when a first reply did not match the output contract; it restates the required shape verbatim. Leave it out of a first call.
cat > brief.txt <<'BRIEF'
We are reworking the flow in Ledgerline where a freelancer disconnects a connected
bank feed. Three pieces of copy are in scope: the confirmation dialog behind the
Disconnect button in bank settings, the toast shown when the disconnect request
fails, and the empty state that replaces the transaction list once the feed is gone.
Users hesitate here because they assume disconnecting deletes their history, so the
copy must make three things unmistakable: invoices, categories and imported
transactions stay in the account; the feed can be reconnected at any time without
redoing setup; and new transactions stop importing from the moment of disconnect.
Tone should be reassuring and matter of fact, never alarming and never scolding.
Buttons must name the action rather than agree or dismiss.
BRIEF

cat > existing.txt <<'EXISTING'
Primary button: Submit
Dialog title: Are you sure you want to disconnect your bank feed?
Error toast: An error occurred. Try again later.
Dialog buttons: OK / Cancel
Success toast: Bank feed disconnected!!
EXISTING

jq -n --rawfile brief brief.txt --rawfile existing existing.txt \
  '{brief: $brief,
    existing: $existing,
    element: "confirm",
    tone: "reassuring",
    limit: "25",
    copyscan: ""}' > 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, model}'
BRIEF = """We are reworking the flow in Ledgerline where a freelancer disconnects a
connected bank feed. Three pieces of copy are in scope: the confirmation dialog behind
the Disconnect button in bank settings, the toast shown when the disconnect request
fails, and the empty state that replaces the transaction list once the feed is gone.
Users hesitate here because they assume disconnecting deletes their history, so the copy
must make three things unmistakable: invoices, categories and imported transactions stay
in the account; the feed can be reconnected at any time without redoing setup; and new
transactions stop importing from the moment of disconnect. Tone should be reassuring and
matter of fact, never alarming and never scolding. Buttons must name the action rather
than agree or dismiss."""

EXISTING = """Primary button: Submit
Dialog title: Are you sure you want to disconnect your bank feed?
Error toast: An error occurred. Try again later.
Dialog buttons: OK / Cancel
Success toast: Bank feed disconnected!!"""

payload = {
    "brief": BRIEF,
    "existing": EXISTING,
    "element": "confirm",     # auto | cta | error | empty | confirm |
                              # tooltip | loading | onboarding | notification | label
    "tone": "reassuring",     # neutral | friendly | formal | playful | reassuring
    "limit": "25",            # digits as a string, or "" for no budget
    "copyscan": "",           # the browser linter's hint; "" from a script
}

est = api("POST", "/estimate", payload)
print("worst case:", est["hold_credits"], "credits on", est["model"])
const brief = [
  "We are reworking the flow in Ledgerline where a freelancer disconnects a connected bank feed.",
  "Three pieces of copy are in scope: the confirmation dialog behind the Disconnect button in bank",
  "settings, the toast shown when the disconnect request fails, and the empty state that replaces",
  "the transaction list once the feed is gone. Users hesitate here because they assume",
  "disconnecting deletes their history, so the copy must make three things unmistakable: invoices,",
  "categories and imported transactions stay in the account; the feed can be reconnected at any",
  "time without redoing setup; and new transactions stop importing from the moment of disconnect.",
  "Tone should be reassuring and matter of fact, never alarming and never scolding. Buttons must",
  "name the action rather than agree or dismiss.",
].join(" ");

const existing = [
  "Primary button: Submit",
  "Dialog title: Are you sure you want to disconnect your bank feed?",
  "Error toast: An error occurred. Try again later.",
  "Dialog buttons: OK / Cancel",
  "Success toast: Bank feed disconnected!!",
].join("\n");

const payload = {
  brief,
  existing,
  element: "confirm",   // auto | cta | error | empty | confirm |
                        // tooltip | loading | onboarding | notification | label
  tone: "reassuring",   // neutral | friendly | formal | playful | reassuring
  limit: "25",          // digits as a string, or "" for no budget
  copyscan: "",         // the browser linter's hint; "" from a script
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits, "credits on", est.model);
const brief = "We are reworking the flow in Ledgerline where a freelancer disconnects a " +
	"connected bank feed. Three pieces of copy are in scope: the confirmation dialog behind " +
	"the Disconnect button in bank settings, the toast shown when the disconnect request " +
	"fails, and the empty state that replaces the transaction list once the feed is gone. " +
	"Users hesitate here because they assume disconnecting deletes their history, so the copy " +
	"must make three things unmistakable: invoices, categories and imported transactions stay " +
	"in the account; the feed can be reconnected at any time without redoing setup; and new " +
	"transactions stop importing from the moment of disconnect. Tone should be reassuring and " +
	"matter of fact, never alarming and never scolding. Buttons must name the action rather " +
	"than agree or dismiss."

const existing = "Primary button: Submit\n" +
	"Dialog title: Are you sure you want to disconnect your bank feed?\n" +
	"Error toast: An error occurred. Try again later.\n" +
	"Dialog buttons: OK / Cancel\n" +
	"Success toast: Bank feed disconnected!!"

payload := map[string]any{
	"brief":    brief,
	"existing": existing,
	"element":  "confirm",    // auto|cta|error|empty|confirm|tooltip|loading|onboarding|notification|label
	"tone":     "reassuring", // neutral|friendly|formal|playful|reassuring
	"limit":    "25",         // digits as a string, "" for no budget
	"copyscan": "",           // browser-linter hint; "" from a script
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
err := call("POST", "/estimate", payload, &est)
String brief = """
    We are reworking the flow in Ledgerline where a freelancer disconnects a connected bank
    feed. Three pieces of copy are in scope: the confirmation dialog behind the Disconnect
    button in bank settings, the toast shown when the disconnect request fails, and the empty
    state that replaces the transaction list once the feed is gone. Users hesitate here because
    they assume disconnecting deletes their history, so the copy must make three things
    unmistakable: invoices, categories and imported transactions stay in the account; the feed
    can be reconnected at any time without redoing setup; and new transactions stop importing
    from the moment of disconnect. Tone should be reassuring and matter of fact, never alarming
    and never scolding. Buttons must name the action rather than agree or dismiss.
    """;

String existing = """
    Primary button: Submit
    Dialog title: Are you sure you want to disconnect your bank feed?
    Error toast: An error occurred. Try again later.
    Dialog buttons: OK / Cancel
    Success toast: Bank feed disconnected!!
    """;

// element: auto|cta|error|empty|confirm|tooltip|loading|onboarding|notification|label
// tone:    neutral|friendly|formal|playful|reassuring
// limit:   digits as a STRING, or "" for no budget
String jsonPayload = """
    {"brief": %s,
     "existing": %s,
     "element": "confirm",
     "tone": "reassuring",
     "limit": "25",
     "copyscan": ""}
    """.formatted(toJsonString(brief), toJsonString(existing));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits, the model name at data.model
BRIEF = <<~BRIEF
  We are reworking the flow in Ledgerline where a freelancer disconnects a connected bank
  feed. Three pieces of copy are in scope: the confirmation dialog behind the Disconnect
  button in bank settings, the toast shown when the disconnect request fails, and the empty
  state that replaces the transaction list once the feed is gone. Users hesitate here because
  they assume disconnecting deletes their history, so the copy must make three things
  unmistakable: invoices, categories and imported transactions stay in the account; the feed
  can be reconnected at any time without redoing setup; and new transactions stop importing
  from the moment of disconnect. Tone should be reassuring and matter of fact, never alarming
  and never scolding. Buttons must name the action rather than agree or dismiss.
BRIEF

EXISTING = <<~EXISTING
  Primary button: Submit
  Dialog title: Are you sure you want to disconnect your bank feed?
  Error toast: An error occurred. Try again later.
  Dialog buttons: OK / Cancel
  Success toast: Bank feed disconnected!!
EXISTING

payload = { brief: BRIEF,
            existing: EXISTING,
            element: "confirm",     # auto|cta|error|empty|confirm|tooltip|
                                    # loading|onboarding|notification|label
            tone: "reassuring",     # neutral|friendly|formal|playful|reassuring
            limit: "25",            # digits as a string, "" for no budget
            copyscan: "" }          # browser-linter hint; "" from a script

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"]} credits on #{est["model"]}"
$brief = <<<'BRIEF'
We are reworking the flow in Ledgerline where a freelancer disconnects a connected bank
feed. Three pieces of copy are in scope: the confirmation dialog behind the Disconnect
button in bank settings, the toast shown when the disconnect request fails, and the empty
state that replaces the transaction list once the feed is gone. Users hesitate here because
they assume disconnecting deletes their history, so the copy must make three things
unmistakable: invoices, categories and imported transactions stay in the account; the feed
can be reconnected at any time without redoing setup; and new transactions stop importing
from the moment of disconnect. Tone should be reassuring and matter of fact, never alarming
and never scolding. Buttons must name the action rather than agree or dismiss.
BRIEF;

$existing = <<<'EXISTING'
Primary button: Submit
Dialog title: Are you sure you want to disconnect your bank feed?
Error toast: An error occurred. Try again later.
Dialog buttons: OK / Cancel
Success toast: Bank feed disconnected!!
EXISTING;

$payload = [
    "brief"    => $brief,
    "existing" => $existing,
    "element"  => "confirm",     // auto|cta|error|empty|confirm|tooltip|
                                 // loading|onboarding|notification|label
    "tone"     => "reassuring",  // neutral|friendly|formal|playful|reassuring
    "limit"    => "25",          // digits as a STRING, "" for no budget
    "copyscan" => "",            // browser-linter hint; "" from a script
];

$est = api("POST", "/estimate", $payload);
echo "worst case: {$est['hold_credits']} credits on {$est['model']}\n";
var brief = """
    We are reworking the flow in Ledgerline where a freelancer disconnects a connected bank
    feed. Three pieces of copy are in scope: the confirmation dialog behind the Disconnect
    button in bank settings, the toast shown when the disconnect request fails, and the empty
    state that replaces the transaction list once the feed is gone. Users hesitate here because
    they assume disconnecting deletes their history, so the copy must make three things
    unmistakable: invoices, categories and imported transactions stay in the account; the feed
    can be reconnected at any time without redoing setup; and new transactions stop importing
    from the moment of disconnect. Tone should be reassuring and matter of fact, never alarming
    and never scolding. Buttons must name the action rather than agree or dismiss.
    """;

var existing = """
    Primary button: Submit
    Dialog title: Are you sure you want to disconnect your bank feed?
    Error toast: An error occurred. Try again later.
    Dialog buttons: OK / Cancel
    Success toast: Bank feed disconnected!!
    """;

var payload = new {
    brief,
    existing,
    element = "confirm",      // auto|cta|error|empty|confirm|tooltip|
                              // loading|onboarding|notification|label
    tone = "reassuring",      // neutral|friendly|formal|playful|reassuring
    limit = "25",             // digits as a STRING, "" for no budget
    copyscan = "",            // browser-linter hint; "" from a script
};

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

limit is a budget, not a hard truncation: it tells the model what the design system allows, and the app re-measures every recommended string in the browser afterwards (each one carries its own character count). If a string has to run long to stay honest, the model says so in the rationale rather than silently clipping it.

Step 4 — Run it and wait for the copy

POST /run
GET /jobs/{job_id}

/run takes the same input object as /estimate, 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 20–60 s). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run — replaying the same key with the same body returns the original job instead of billing again. The reply is in output — usually nested as output.output — and it is plain text, not JSON: the tagged, sectioned shape described 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: mcs-$(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 reply is plain text, nested one level down
echo "$JOB" | jq -r '.data.output.output // .data.output' > copy.md

# the strings to ship: the bullets under "## Recommended copy"
awk '/^## Recommended copy/{f=1;next} /^## /{f=0} f && /^- /{sub(/^- /,"");print}' copy.md

# gate a pipeline on the verdict
grep -q '^VERDICT: Use as is' copy.md \
  || { echo "not shippable as is - read the open questions"; exit 1; }
import re, time, uuid

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

def tag(name):
    m = re.search(rf"^{name}:\s*(.*)$", text, re.M)
    return m.group(1).strip() if m else ""

def section(heading):
    m = re.search(rf"^##\s+{re.escape(heading)}\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
    if not m:
        return []
    items = [re.sub(r"^\s*[-*+]\s+", "", ln).strip()
             for ln in m.group(1).splitlines() if ln.strip().startswith(("-", "*", "+"))]
    return [] if items == ["None."] else items

print(tag("VERDICT"), "/", tag("ELEMENT"), "/", tag("TONE"), "/", tag("CONFIDENCE") + "%")
print(tag("SUMMARY"))

for bullet in section("Recommended copy"):
    label, _, string = bullet.partition(": ")
    print(f"  {label:<20} {string}  ({len(string)} chars)")
for bullet in section("Alternatives"):
    copy, tone, when = (bullet.split(" | ") + ["", ""])[:3]
    print(f"  alt {copy}  [{tone}] {when}")
for q in section("Open questions"):
    print("  open:", q)

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

if tag("VERDICT") != "Use as is":
    raise SystemExit(f'verdict is "{tag("VERDICT")}" - answer the open questions first')
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");

const text = job.output?.output ?? job.output;

const tag = (name) => new RegExp(`^${name}:\\s*(.*)$`, "m").exec(text)?.[1].trim() ?? "";
const section = (heading) => {
  const body = new RegExp(`^## ${heading}\\s*$([\\s\\S]*?)(?=^## |$)`, "m").exec(text)?.[1] ?? "";
  const items = body.split("\n")
    .filter((l) => /^\s*[-*+]\s+/.test(l))
    .map((l) => l.replace(/^\s*[-*+]\s+/, "").trim());
  return items.length === 1 && /^none\.?$/i.test(items[0]) ? [] : items;
};

console.log(`${tag("VERDICT")} / ${tag("ELEMENT")} / ${tag("TONE")} / ${tag("CONFIDENCE")}%`);
console.log(tag("SUMMARY"));

for (const bullet of section("Recommended copy")) {
  const [label, ...rest] = bullet.split(": ");
  const string = rest.join(": ");
  console.log(`  ${label}: ${string} (${string.length} chars)`);
}
for (const bullet of section("Alternatives")) {
  const [copy, tone, when] = bullet.split(" | ");
  console.log(`  alt ${copy} [${tone ?? ""}] ${when ?? ""}`);
}
for (const q of section("Open questions")) console.log("  open:", q);

writeFileSync("copy.md", text);
if (tag("VERDICT") !== "Use as is") process.exitCode = 1;
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 struct {
		Output string `json:"output"`
	} `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)
}

text := job.Output.Output // plain text, not JSON

tag := func(name string) string {
	m := regexp.MustCompile(`(?m)^` + name + `:\s*(.*)$`).FindStringSubmatch(text)
	if m == nil {
		return ""
	}
	return strings.TrimSpace(m[1])
}
section := func(heading string) []string {
	m := regexp.MustCompile(`(?ms)^## `+regexp.QuoteMeta(heading)+`\s*$(.*?)(?:^## |\z)`).
		FindStringSubmatch(text)
	if m == nil {
		return nil
	}
	var out []string
	for _, ln := range strings.Split(m[1], "\n") {
		t := strings.TrimSpace(ln)
		if strings.HasPrefix(t, "- ") {
			out = append(out, strings.TrimSpace(t[2:]))
		}
	}
	if len(out) == 1 && strings.EqualFold(out[0], "None.") {
		return nil
	}
	return out
}

fmt.Printf("%s / %s / %s / %s%%\n", tag("VERDICT"), tag("ELEMENT"), tag("TONE"), tag("CONFIDENCE"))
for _, b := range section("Recommended copy") {
	if label, s, ok := strings.Cut(b, ": "); ok {
		fmt.Printf("  %-20s %s (%d chars)\n", label, s, len(s))
	}
}
for _, b := range section("Alternatives") {
	fmt.Println("  alt", b) // "copy" | Tone | when to prefer it
}
os.WriteFile("copy.md", []byte(text), 0o644)
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 PLAIN TEXT, not JSON. Read it with two regexes:
//   tag lines:  ^(VERDICT|ELEMENT|TONE|CONFIDENCE|SUMMARY):\s*(.*)$   (MULTILINE)
//   sections:   ^## (heading)\s*$(.*?)(?=^## |\z)                     (MULTILINE|DOTALL)
String text = /* data.output.output */;

var tag = java.util.regex.Pattern.compile("^(\\w+):\\s*(.*)$", java.util.regex.Pattern.MULTILINE);
var m = tag.matcher(text);
while (m.find()) System.out.println(m.group(1) + " = " + m.group(2));

// The six sections, in this order: Recommended copy, Alternatives, Rationale,
// Review of your copy, Localization notes, Open questions.
// Recommended-copy bullets are "Label: string"; Alternatives bullets are
// "copy" | Tone | when to prefer it. A tail section that is just "- None." is empty.
// Files.writeString(Path.of("copy.md"), text);
require "securerandom"

started = api("POST", "/run", payload, { "Idempotency-Key" => SecureRandom.uuid })

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"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
text = raw.to_s

def tag(text, name) = text[/^#{name}:\s*(.*)$/, 1].to_s.strip

def section(text, heading)
  body = text[/^\#\# #{Regexp.escape(heading)}\s*$(.*?)(?=^\#\# |\z)/m, 1].to_s
  items = body.lines.grep(/^\s*[-*+]\s+/).map { |l| l.sub(/^\s*[-*+]\s+/, "").strip }
  items == ["None."] ? [] : items
end

puts "#{tag(text, "VERDICT")} / #{tag(text, "ELEMENT")} / " \
     "#{tag(text, "TONE")} / #{tag(text, "CONFIDENCE")}%"
puts tag(text, "SUMMARY")

section(text, "Recommended copy").each do |bullet|
  label, string = bullet.split(": ", 2)
  puts "  #{label.ljust(20)} #{string} (#{string.to_s.length} chars)"
end
section(text, "Alternatives").each do |bullet|
  copy, tone, when_to = bullet.split(" | ", 3)
  puts "  alt #{copy} [#{tone}] #{when_to}"
end
section(text, "Open questions").each { |q| puts "  open: #{q}" }

File.write("copy.md", text)
exit 1 unless tag(text, "VERDICT") == "Use as is"
$started = api("POST", "/run", $payload,
    ["Idempotency-Key: mcs-" . bin2hex(random_bytes(8))]);

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");
}

$raw  = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$text = (string) $raw;   // plain text, not JSON

function tag(string $text, string $name): string {
    return preg_match('/^' . $name . ':\s*(.*)$/m', $text, $m) ? trim($m[1]) : "";
}

function section(string $text, string $heading): array {
    $re = '/^## ' . preg_quote($heading, "/") . '\s*$(.*?)(?=^## |\z)/ms';
    if (!preg_match($re, $text, $m)) {
        return [];
    }
    $items = [];
    foreach (explode("\n", $m[1]) as $line) {
        if (preg_match('/^\s*[-*+]\s+(.*)$/', $line, $b)) {
            $items[] = trim($b[1]);
        }
    }
    return $items === ["None."] ? [] : $items;
}

echo tag($text, "VERDICT") . " / " . tag($text, "ELEMENT") . " / " .
     tag($text, "TONE") . " / " . tag($text, "CONFIDENCE") . "%\n";

foreach (section($text, "Recommended copy") as $bullet) {
    [$label, $string] = array_pad(explode(": ", $bullet, 2), 2, "");
    echo "  $label: $string (" . mb_strlen($string) . " chars)\n";
}
foreach (section($text, "Alternatives") as $bullet) {
    echo "  alt $bullet\n";      // "copy" | Tone | when to prefer it
}
foreach (section($text, "Open questions") as $q) {
    echo "  open: $q\n";
}

file_put_contents("copy.md", $text);
if (tag($text, "VERDICT") !== "Use as is") {
    exit(1);
}
using System.Text.RegularExpressions;

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);
}

var text = job.GetProperty("output").GetProperty("output").GetString()!; // plain text

string Tag(string name) =>
    Regex.Match(text, $@"^{name}:\s*(.*)$", RegexOptions.Multiline) is { Success: true } m
        ? m.Groups[1].Value.Trim() : "";

string[] Section(string heading)
{
    var m = Regex.Match(text, $@"^## {Regex.Escape(heading)}\s*$(.*?)(?=^## |\z)",
                        RegexOptions.Multiline | RegexOptions.Singleline);
    if (!m.Success) return Array.Empty<string>();
    var items = m.Groups[1].Value.Split('\n')
        .Where(l => Regex.IsMatch(l, @"^\s*[-*+]\s+"))
        .Select(l => Regex.Replace(l, @"^\s*[-*+]\s+", "").Trim())
        .ToArray();
    return items is ["None."] ? Array.Empty<string>() : items;
}

Console.WriteLine($"{Tag("VERDICT")} / {Tag("ELEMENT")} / {Tag("TONE")} / {Tag("CONFIDENCE")}%");
foreach (var bullet in Section("Recommended copy"))
{
    var parts = bullet.Split(": ", 2);
    Console.WriteLine($"  {parts[0]}: {parts[^1]} ({parts[^1].Length} chars)");
}
foreach (var bullet in Section("Alternatives")) Console.WriteLine($"  alt {bullet}");
foreach (var q in Section("Open questions")) Console.WriteLine($"  open: {q}");

await File.WriteAllTextAsync("copy.md", text);
if (Tag("VERDICT") != "Use as is") Environment.ExitCode = 1;

A model sometimes wraps the whole reply in a ``` fence. Strip an outer fence before you parse — that is what the app does, and if the tag lines still aren't there it retries once with retry_note set to a verbatim restatement of the contract. Two failures in a row means you should show the raw text rather than pretend to have parsed it.

The reply — output contract

The reply is plain text, not JSON. It opens with five tag lines, then carries exactly six ## sections in this order. All five tag lines and all six headings are required — anything else is a failed parse.

Tag lineValue
VERDICT:Exactly one of Use as is, Use after answering open questions, Needs more context.
ELEMENT:Exactly one of CTA, Error message, Empty state, Confirmation dialog, Tooltip, Loading state, Onboarding, Notification, Form label, Mixed. Note these are the human-readable names, not the lower-case element values you send — and Mixed is what a moment spanning several elements comes back as.
TONE:Exactly one of Neutral, Friendly, Formal, Playful, Reassuring — capitalized, unlike the tone you send.
CONFIDENCE:A bare integer 0–100. A trailing % is tolerated by the app's parser; values outside the range are a failed parse.
SUMMARY:Two to four sentences. It may wrap over several lines and ends at the first blank line.
SectionBullet shapeEmpty?
## Recommended copy- Label: string — the strings to ship, one per element or slot. A bullet with no Label: prefix is treated as label Copy, and surrounding double quotes are stripped from the string.Never — an empty section is a failed parse.
## Alternatives- "copy" | Tone | when to prefer it — pipe-separated. Tone is one of the five tone values; everything after the second pipe is the trade-off.Never — an empty section is a failed parse.
## RationalePlain bullets, written for the team.Never — an empty section is a failed parse.
## Review of your copyPlain bullets on the strings you sent in existing.- None. when you sent no existing copy.
## Localization notesPlain bullets, written for translators — expansion room, idioms, gendered forms.- None.
## Open questionsPlain bullets: the facts the brief did not establish that would change a string.- None.

Two consistency rules tie the verdict to the last section, and the app checks them rather than trusting them: any open question rules out Use as is, and Needs more context with an empty Open questions section is self-contradictory. If you automate on this reply, assert the same two rules.

A small, realistic reply for the brief above:

VERDICT: Use after answering open questions
ELEMENT: Confirmation dialog
TONE: Reassuring
CONFIDENCE: 78
SUMMARY: The dialog itself can be written from the brief as it stands - the three
facts that defuse the hesitation are all established, so the copy can state them
plainly instead of hedging. The failure toast is the weak point: the brief does not
say what a user can do when the disconnect request fails, so the recommended string
promises only a retry. Answer the two open questions and this set is shippable.

## Recommended copy
- Dialog title: Disconnect this bank feed?
- Dialog body: Your invoices, categories and imported transactions stay in Ledgerline.
  New transactions stop importing now, and you can reconnect this feed later without
  setting it up again.
- Primary button: Disconnect feed
- Secondary button: Keep it connected
- Error toast: We could not disconnect the feed. Try again in a moment.
- Empty state: No new transactions are importing. Reconnect a bank feed to start again.

## Alternatives
- "Stop importing from this bank?" | Reassuring | when research shows the word
  "disconnect" itself reads as deletion
- "Disconnect bank feed?" | Neutral | when the dialog is opened from a screen that
  already names the bank
- "Disconnect" | Formal | when the design system caps primary buttons at one word

## Rationale
- The title names the action rather than asking for agreement, so the primary button
  can name it too and the pair reads as one sentence.
- "stay in Ledgerline" is stated because the brief establishes it; nothing about
  export, deletion timelines or reconnection speed is claimed, because the brief
  does not.
- Both button labels sit under the 25-character budget: 16 and 18 characters.

## Review of your copy
- "Primary button: Submit" is vague at the moment of a destructive-feeling action -
  name the action instead.
- "Are you sure you want to..." asks for agreement rather than stating consequences;
  the consequence is what actually reassures here.
- "An error occurred. Try again later." names no cause and offers no fix.
- "OK / Cancel" gives the user no way to tell which button disconnects.
- "Bank feed disconnected!!" - the exclamation marks celebrate something the user is
  likely anxious about.

## Localization notes
- German and Finnish expand roughly 30% over English; "Keep it connected" is the
  label most at risk of breaking the 25-character budget.
- "feed" has no settled equivalent in several locales - agree a glossary term before
  translation rather than per string.

## Open questions
- When the disconnect request fails, is there anything the user can do besides
  retrying, or should the toast point at support?
- Does reconnecting restore the gap in transactions, or only resume from the
  reconnect date? The empty state's wording depends on the answer.

This is AI-generated copy from the brief you sent, not a legal or product review. Every claim in a recommended string is supposed to trace back to your brief — check that it does before you ship, especially any sentence that promises reversibility, retention or timing.

Step 5 — Stream the copy as it is written

POST /run-stream

/run-stream takes exactly the same body as /run and honours the same Idempotency-Key header, but answers with server-sent events, so you can show progress instead of a spinner. 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.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal. Watching for the ## headings as they arrive gives you a six-step progress list for free.
done{job_id, status, charged_credits, truncated, output}The final, authoritative result — read the copy from output.output rather than trusting concatenated deltas, which can drop the tail. truncated is true when the run ran out of the credits held for it, and the reply is then incomplete.
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: mcs-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"VERDICT: Use after answering"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":312,
#        "truncated":false,"output":{"output":"VERDICT: ..."}}
import json, requests

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

text = result["output"]["output"]                        # authoritative
print("\ncharged:", result["charged_credits"])
if result.get("truncated"):
    print("WARNING: the reply was cut short by the credits held for this run")
with open("copy.md", "w", encoding="utf-8") as fh:
    fh.write(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 text = done.output.output;
console.log(`\n${done.charged_credits} credits`);
if (done.truncated) console.warn("the reply was cut short by the credits held for this run");
writeFileSync("copy.md", text);
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", "mcs-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"])
		}
	}
}

// final["output"].(map[string]any)["output"].(string) is the plain-text reply -
// feed it to the tag/section helpers from step 4, then write it to copy.md.
// 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", "mcs-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`; data.output.output is the plain-text reply (VERDICT/ELEMENT/TONE/
// CONFIDENCE/SUMMARY then the six "## " sections). data.charged_credits is the
// settled price, and data.truncated true means the reply is incomplete.
// Files.writeString(Path.of("copy.md"), text);
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"] = "mcs-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

text = done["output"]["output"]
puts "\n#{done["charged_credits"]} credits"
warn "the reply was cut short by the credits held for this run" if done["truncated"]
File.write("copy.md", text)
$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: mcs-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);

$text = $done["output"]["output"];
echo "\n{$done['charged_credits']} credits\n";
if (!empty($done["truncated"])) {
    fwrite(STDERR, "the reply was cut short by the credits held for this run\n");
}
file_put_contents("copy.md", $text);
var streamReq = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
streamReq.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());

using var res = await Http.SendAsync(streamReq, 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!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine($"\n{final.RootElement.GetProperty("charged_credits")} credits");
await File.WriteAllTextAsync("copy.md", text);

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.