Policy Lens — API

Handbook in, a grounded answer out — every claim quoted from your own paste.

API tokens Open the app

Ask a handbook a question from your own tools

Send an employee handbook — the whole thing, an HR wiki export, or just the policy pages that might matter — together with a question in plain words, and get back a coverage verdict, a one-paragraph answer, the rules explained, the handbook's own exceptions, what it leaves unsaid, the contact path it names, and every supporting sentence quoted verbatim from the material you sent. Nothing is filled in from what companies typically do: a question the handbook does not answer comes back as Not in handbook, and a partly answered one names its own gaps. Everything this app does goes through the SkillSafe App API — JSON in over HTTPS, plain text out — so you can wire it into a helpdesk, answer the same question against last year's handbook and this year's, or gate a policy change on which questions stop being answerable. Every 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. The routes are /guest, /me, /estimate, /run and /run-stream — there is no /apps/{slug}/ path segment anywhere. The app slug policy-lens appears exactly once, in the body of POST /guest, and from then on it is bound to the token you were issued.

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 handbook and one question in, one answer out, no follow-up calls and no session state to carry. Runs go to the gpt-terra model (which resolves to gpt-5.6-terra) at a markup of 1000 basis points.

The request body is the input object itself. Both /estimate and /run take {"handbook": …, "question": …, "context": …, "docscan": …} at the top level. It is not wrapped in {"input": …}. A wrapped body is worse than an error: the wrapper is simply not a field this app reads, so handbook and question arrive empty and you get a priced estimate — or a billed run — against no handbook at all, answered Not in handbook, with no complaint from the API.

StatusError codeMeaning
400validation_errorThe body failed validation — most often a non-string field or malformed JSON. Note that wrapping the input in {"input": …} does not raise this: it is accepted and read as an empty input, so check your body shape rather than waiting for an error.
401unauthorizedMissing, malformed or expired token — mint a new one with POST /guest or sign in again.
402payment_requiredNot enough credits to place the hold — top up at skillsafe.ai/account/credits, or check /estimate first.
404not_foundUnknown job id, or a route that does not exist (check you did not add an /apps/… segment).
429rate_limitedToo many requests — back off and retry. Reuse the same Idempotency-Key on the retry so the run cannot be billed twice.
5xxinternal_errorTransient platform error — retry with backoff and the same idempotency key.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend. A handbook is usually confidential: the paste travels to the model for the length of the run, so send the policy pages the question needs rather than the whole staff directory.

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 TOKEN="YOUR_TOKEN"      # see step 1

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

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 - load it from your secret store in real code

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:
        err = payload.get("error", {})
        raise RuntimeError(f'{err.get("code", res.status_code)}: {err.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 - load it from your secret store 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?.code ?? res.status}: ${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 {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s: %s", method, path, env.Error.Code, 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, extra = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  extra.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)
  unless res.is_a?(Net::HTTPSuccess)
    raise "#{payload.dig("error", "code")}: #{payload.dig("error", "message") || res.message}"
  end
  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"]["code"] ?? "http_$status") . ": "
            . ($payload["error"]["message"] ?? "request failed"));
    }
    return $payload["data"];
}
// .NET 8+ - the client you would actually keep in the repo.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

namespace PolicyLens;

public sealed class SkillSafeException(string code, string message)
    : Exception($"{code}: {message}")
{
    public string Code { get; } = code;
}

public sealed class SkillSafeClient : IDisposable
{
    private const string BaseUrl = "https://api.skillsafe.ai/v1/app-api";
    private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) };

    /// <summary>Exposed so the streaming helper in step 5 can share the connection.</summary>
    internal HttpClient Http => _http;

    public SkillSafeClient(string token) =>
        _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

    public async Task<JsonElement> SendAsync(
        HttpMethod method, string path, object? body = null,
        string? idempotencyKey = null, CancellationToken ct = default)
    {
        using var req = new HttpRequestMessage(method, BaseUrl + path);
        if (body is not null) req.Content = JsonContent.Create(body);
        if (idempotencyKey is not null) req.Headers.Add("Idempotency-Key", idempotencyKey);

        using var res = await _http.SendAsync(req, ct);
        var env = await res.Content.ReadFromJsonAsync<JsonElement>(ct);

        if (!res.IsSuccessStatusCode)
        {
            var err = env.TryGetProperty("error", out var e) ? e : default;
            throw new SkillSafeException(
                err.ValueKind is JsonValueKind.Object ? err.GetProperty("code").GetString()! : $"http_{(int)res.StatusCode}",
                err.ValueKind is JsonValueKind.Object ? err.GetProperty("message").GetString()! : res.ReasonPhrase ?? "request failed");
        }
        return env.GetProperty("data");
    }

    public void Dispose() => _http.Dispose();
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered 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, ready for the examples below. 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 — this is the one and only place the slug is sent.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"policy-lens"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "policy-lens"})["token"]
const { token } = await api("POST", "/guest", { slug: "policy-lens" });
var guest struct {
	Token   string `json:"token"`
	GuestID string `json:"guest_id"`
}
err := call("POST", "/guest", map[string]string{"slug": "policy-lens"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"policy-lens"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "policy-lens" })["token"]
$token = api("POST", "/guest", ["slug" => "policy-lens"])["token"];
// A guest token needs no Authorization header, so mint it with a bare client.
using var bootstrap = new HttpClient();
var res = await bootstrap.PostAsJsonAsync(
    "https://api.skillsafe.ai/v1/app-api/guest",
    new { slug = "policy-lens" });
res.EnsureSuccessStatusCode();

var envelope = await res.Content.ReadFromJsonAsync<JsonElement>();
var token = envelope.GetProperty("data").GetProperty("token").GetString()!;

using var client = new SkillSafeClient(token);

The app stores this browser's token under the localStorage key skillsafe_app_token:policy-lens, on the app's own origin, and remembers the guest id under skillsafe_guest:policy-lens so a later sign-in can carry the guest wallet over. The token page reads and manages both 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 pushing a whole handbook through — the handbook field alone runs to 40,000 characters, and the cost of a run tracks the size of what you paste far more than the length of the question.

curl -s "$API/me" -H "Authorization: Bearer $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)
fmt.Printf("%s %s: %d credits\n", me.SubjectType, me.SubjectID, me.Credits)
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 client.SendAsync(HttpMethod.Get, "/me");

var subjectType = me.GetProperty("subject_type").GetString();
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{subjectType} {me.GetProperty("subject_id").GetString()}: {credits:N0} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run — the input object itself, at the top level of the body. The response's hold_credits is the worst-case cost and model names the model that will do the work (gpt-terra, which resolves to gpt-5.6-terra). Nothing is charged and no job is created, so estimating is free — which is what makes it safe to price a hundred questions against the same handbook before spending anything.

Input fieldTypeNotes
handbookstring, requiredThe policy material, verbatim: an employee handbook, a policy page, an HR wiki export, in plain text or markdown. This is the only source of policy the answer may use. The web UI clips it at 40,000 characters, and never by a blind cut from the front: it splits the paste into sections, keeps the ones whose wording overlaps the question, and marks every removal in place with [... N sections omitted here: <titles> ...] (whole sections) or [... middle of this section omitted ...] (the middle of one long section, both ends kept), closing with a [handbook clipped - ...] line that states the totals. The prompt treats removed material as unseen, not absent, so it will name an omitted section rather than report the handbook silent on it. Clip the same way when you clip it yourself, and always leave a [handbook clipped - ...] line in the text: a cut with no marker is read as the end of the policy. An empty handbook does not error: it comes back as a valid answer with coverage Not in handbook.
questionstring, requiredThe question in the asker's own words, clipped by the web UI at 1,000 characters. A real question beats a topic label: "Can I work from Portugal for six weeks?" gets the policy arithmetic done against the stated limit, where "international remote work policy" only gets the section recited.
contextstring, optionalFacts about the asker's situation — employment type, location, tenure, what they have already used this year — clipped at 2,000 characters. Context informs how the policy is applied to this person; it is never a source of policy, and nothing in it can establish a rule the handbook does not state. Send the empty string when you have none.
docscanstring, optionalA one-line, browser-side scan summary, in the shape N sections, N words. headings: A | B. topics: X, Y. question wording overlaps: Sec (terms). It is pattern matching with no understanding of the text, so it is treated as a hint to verify against the handbook, never as fact. Omit it, or send the empty string, and the answer is unaffected — the model reads the handbook either way.
retry_notestring, optionalOnly set by the app's automatic reformat retry, when a first reply failed to parse. It re-states the required reply shape and must never carry information about the handbook or the question — it changes the layout of the reply, never the coverage, the answer or the quotes. Leave it out.

The body is the object above, sent directly. Do not wrap it: with {"input": {"handbook": …}} the API sees no handbook field at all and answers Not in handbook about an empty paste instead of rejecting the call.

cat > handbook.txt <<'HB'
## Remote work

Employees may work remotely from anywhere within their country of employment
without prior approval.

### Working from another country

Employees may work from another country for up to 30 calendar days in a
calendar year. Any stay longer than 30 calendar days requires written approval
from People Ops and a tax review completed before travel begins.

## Time off

Unused PTO does not carry over past March 31 of the following year.
HB

cat > question.txt <<'Q'
Can I work from Portugal for six weeks?
Q

# the docscan line the browser builds; optional, and only ever a hint
SCAN='3 sections, 96 words. headings: Remote work | Working from another country | Time off. topics: Remote work, PTO. question wording overlaps: Working from another country (work, country, weeks)'

# the input object IS the body - no {"input": ...} wrapper
jq -n --rawfile hb handbook.txt --rawfile q question.txt --arg scan "$SCAN" \
  '{handbook: $hb,
    question: ($q | rtrimstr("\n")),
    context: "Full-time employee, based in Ireland, 3 years tenure.",
    docscan: $scan}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {hold_credits, model}'
HANDBOOK = """## Remote work

Employees may work remotely from anywhere within their country of employment
without prior approval.

### Working from another country

Employees may work from another country for up to 30 calendar days in a
calendar year. Any stay longer than 30 calendar days requires written approval
from People Ops and a tax review completed before travel begins.

## Time off

Unused PTO does not carry over past March 31 of the following year.
"""

# Always mark a cut. The web UI clips by section relevance; a simple clip is
# fine here, but the marker is not optional - an unmarked cut is read as the end
# of the policy, and the answer will report gaps that are really just omissions.
HANDBOOK_LIMIT = 40000
QUESTION_LIMIT = 1000
CONTEXT_LIMIT = 2000
CLIP_NOTICE = "\n\n[handbook clipped - you are seeing part of a longer document; anything you were not shown is unseen, not absent.]"

def clip_handbook(text):
    text = text.strip()
    return text if len(text) <= HANDBOOK_LIMIT else text[:HANDBOOK_LIMIT] + CLIP_NOTICE

# the input object IS the body - no {"input": ...} wrapper
payload = {
    "handbook": clip_handbook(HANDBOOK),
    "question": "Can I work from Portugal for six weeks?"[:QUESTION_LIMIT],
    "context": "Full-time employee, based in Ireland, 3 years tenure."[:CONTEXT_LIMIT],
    "docscan": ("3 sections, 96 words. headings: Remote work | Working from another "
                "country | Time off. topics: Remote work, PTO. question wording "
                "overlaps: Working from another country (work, country, weeks)"),
}

est = api("POST", "/estimate", payload)
print("worst case:", est["hold_credits"], "credits on", est["model"])
import { readFileSync } from "node:fs";

const HANDBOOK_LIMIT = 40000;
const QUESTION_LIMIT = 1000;
const CONTEXT_LIMIT = 2000;
const CLIP_NOTICE = "\n\n[handbook clipped - you are seeing part of a longer document; anything you were not shown is unseen, not absent.]";

// Clip the way the web UI does: keep the beginning and leave the marker, so the
// answer can say it saw only part of the handbook rather than treating the cut
// as the end of the policy.
function clipHandbook(text) {
  const t = text.trim();
  return t.length <= HANDBOOK_LIMIT ? t : t.slice(0, HANDBOOK_LIMIT) + CLIP_NOTICE;
}

const handbook = clipHandbook(readFileSync("handbook.md", "utf8"));

// the input object IS the body - no { input: ... } wrapper
const payload = {
  handbook,
  question: "Can I work from Portugal for six weeks?".slice(0, QUESTION_LIMIT),
  context: "Full-time employee, based in Ireland, 3 years tenure.".slice(0, CONTEXT_LIMIT),
  docscan:
    "3 sections, 96 words. headings: Remote work | Working from another country | " +
    "Time off. topics: Remote work, PTO. question wording overlaps: " +
    "Working from another country (work, country, weeks)",
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits, "credits on", est.model);
const (
	handbookLimit = 40000
	questionLimit = 1000
	contextLimit  = 2000
	clipNotice   = "\n\n[handbook clipped - you are seeing part of a longer document; anything you were not shown is unseen, not absent.]"
)

hbBytes, err := os.ReadFile("handbook.md")
if err != nil {
	log.Fatal(err)
}

// Keep the beginning and mark the cut, exactly as the web UI does.
handbook := strings.TrimSpace(string(hbBytes))
if len(handbook) > handbookLimit {
	handbook = handbook[:handbookLimit] + clipNotice
}

question := "Can I work from Portugal for six weeks?"
if len(question) > questionLimit {
	question = question[:questionLimit]
}

// the input object IS the body - no {"input": ...} wrapper
payload := map[string]any{
	"handbook": handbook,
	"question": question,
	"context":  "Full-time employee, based in Ireland, 3 years tenure.",
	"docscan": "3 sections, 96 words. headings: Remote work | Working from another country | " +
		"Time off. topics: Remote work, PTO. question wording overlaps: " +
		"Working from another country (work, country, weeks)",
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	log.Fatal(err)
}
fmt.Printf("worst case: %d credits on %s\n", est.HoldCredits, est.Model)
final int HANDBOOK_LIMIT = 40_000;
final int QUESTION_LIMIT = 1_000;
final int CONTEXT_LIMIT = 2_000;
final String CLIP_NOTICE = "\n\n[handbook clipped - you are seeing part of a longer document; anything you were not shown is unseen, not absent.]";

String handbook = Files.readString(Path.of("handbook.md")).strip();
// Keep the beginning and mark the cut, exactly as the web UI does.
if (handbook.length() > HANDBOOK_LIMIT) {
    handbook = handbook.substring(0, HANDBOOK_LIMIT) + CLIP_NOTICE;
}

String question = "Can I work from Portugal for six weeks?";
question = question.substring(0, Math.min(question.length(), QUESTION_LIMIT));

String context = "Full-time employee, based in Ireland, 3 years tenure.";
context = context.substring(0, Math.min(context.length(), CONTEXT_LIMIT));

String docscan = "3 sections, 96 words. headings: Remote work | Working from another country "
    + "| Time off. topics: Remote work, PTO. question wording overlaps: "
    + "Working from another country (work, country, weeks)";

// the input object IS the body - no {"input": ...} wrapper.
// toJsonString() is your JSON library's string escaper.
String jsonPayload = """
    {"handbook": %s,
     "question": %s,
     "context": %s,
     "docscan": %s}
    """.formatted(toJsonString(handbook), toJsonString(question),
                  toJsonString(context), toJsonString(docscan));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits, the model at data.model
HANDBOOK_LIMIT = 40_000
QUESTION_LIMIT = 1_000
CONTEXT_LIMIT = 2_000
CLIP_NOTICE = "\n\n[handbook clipped - you are seeing part of a longer document; anything you were not shown is unseen, not absent.]"

handbook = File.read("handbook.md").strip
# Keep the beginning and mark the cut, exactly as the web UI does.
handbook = handbook[0, HANDBOOK_LIMIT] + CLIP_NOTICE if handbook.length > HANDBOOK_LIMIT

question = "Can I work from Portugal for six weeks?"[0, QUESTION_LIMIT]
context  = "Full-time employee, based in Ireland, 3 years tenure."[0, CONTEXT_LIMIT]

docscan = "3 sections, 96 words. headings: Remote work | Working from another country | " \
          "Time off. topics: Remote work, PTO. question wording overlaps: " \
          "Working from another country (work, country, weeks)"

# the input object IS the body - no { input: ... } wrapper
payload = { handbook: handbook, question: question, context: context, docscan: docscan }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"]} credits on #{est["model"]}"
const HANDBOOK_LIMIT = 40000;
const QUESTION_LIMIT = 1000;
const CONTEXT_LIMIT  = 2000;
const CLIP_NOTICE = "\n\n[handbook clipped - you are seeing part of a longer document; anything you were not shown is unseen, not absent.]";

$handbook = trim(file_get_contents("handbook.md"));
// Keep the beginning and mark the cut, exactly as the web UI does.
if (strlen($handbook) > HANDBOOK_LIMIT) {
    $handbook = substr($handbook, 0, HANDBOOK_LIMIT) . CLIP_NOTICE;
}

$question = substr("Can I work from Portugal for six weeks?", 0, QUESTION_LIMIT);
$context  = substr("Full-time employee, based in Ireland, 3 years tenure.", 0, CONTEXT_LIMIT);

$docscan = "3 sections, 96 words. headings: Remote work | Working from another country | "
    . "Time off. topics: Remote work, PTO. question wording overlaps: "
    . "Working from another country (work, country, weeks)";

// the input object IS the body - no ["input" => ...] wrapper
$payload = [
    "handbook" => $handbook,
    "question" => $question,
    "context"  => $context,
    "docscan"  => $docscan,
];

$est = api("POST", "/estimate", $payload);
echo "worst case: {$est['hold_credits']} credits on {$est['model']}\n";
// The request body, as a record - the input object IS the body.
// RetryNote is set only by the app's own reformat retry, so it stays null here.
public sealed record PolicyInput(
    [property: JsonPropertyName("handbook")] string Handbook,
    [property: JsonPropertyName("question")] string Question,
    [property: JsonPropertyName("context")] string Context = "",
    [property: JsonPropertyName("docscan")] string DocScan = "",
    [property: JsonPropertyName("retry_note")]
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    string? RetryNote = null)
{
    public const int HandbookLimit = 40_000;
    public const int QuestionLimit = 1_000;
    public const int ContextLimit = 2_000;
    public const string ClipNotice = "\n\n[handbook clipped - you are seeing part of a longer document; anything you were not shown is unseen, not absent.]";

    /// <summary>
    /// Clips like the web UI does: keep the beginning and append the marker, so
    /// the answer can say it saw only part of the handbook instead of reading
    /// the cut as the end of the policy.
    /// </summary>
    public static PolicyInput FromFile(
        string path, string question, string context = "", string docScan = "")
    {
        var handbook = File.ReadAllText(path).Trim();
        if (handbook.Length > HandbookLimit)
            handbook = handbook[..HandbookLimit] + ClipNotice;

        return new PolicyInput(
            handbook,
            question[..Math.Min(question.Length, QuestionLimit)],
            context[..Math.Min(context.Length, ContextLimit)],
            docScan);
    }
}

var input = PolicyInput.FromFile(
    "handbook.md",
    question: "Can I work from Portugal for six weeks?",
    context: "Full-time employee, based in Ireland, 3 years tenure.",
    docScan: "3 sections, 96 words. headings: Remote work | Working from another country | " +
             "Time off. topics: Remote work, PTO. question wording overlaps: " +
             "Working from another country (work, country, weeks)");

var est = await client.SendAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits").GetInt64():N0} credits " +
                  $"on {est.GetProperty("model").GetString()}");

docscan is a hint, not an instruction, and context is never a source of policy. A scan that points at the wrong section changes nothing: the handbook is read either way, and a hint that cannot be confirmed in the text is dropped rather than echoed back as a finding.

Step 4 — Ask the question and wait for the answer

POST /run
GET /jobs/{job_id}

/run takes the same input 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–90 s, most of it spent reading the handbook you pasted. Always send an Idempotency-Key header so a network retry, a 429 back-off or a crashed worker cannot start a second, double-charged run: replaying the same key 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. Its exact grammar is the next section.

IDEM="pl-$(date +%s)-$RANDOM"

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

[ "$STATUS" = "succeeded" ] || { echo "$JOB" | jq -r '.data.error'; exit 1; }

# plain text, not JSON - unwrap once and keep the whole answer
echo "$JOB" | jq -r '.data.output.output' > answer.md

# the four tag lines
sed -n '1,3p' answer.md

# the gaps and the sources
awk '/^## What the handbook does not say$/{s=1;next} /^## /{s=0} s && /^- /' answer.md
awk '/^## Sources$/{s=1;next} /^## /{s=0} s && /^- /' answer.md

# re-verify every quote against the handbook you sent (what the browser does)
awk '/^## Sources$/{s=1;next} /^## /{s=0} s && /^- /' answer.md \
  | sed -n 's/^- *"\([^"]*\)".*/\1/p' \
  | while IFS= read -r q; do
      if grep -qF "$q" handbook.txt; then echo "verified: $q"
      else echo "NOT FOUND: $q"; fi
    done

grep -q '^COVERAGE: Not in handbook$' answer.md && { echo "handbook does not cover it"; exit 1; }
exit 0
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"]          # plain text, NOT json.loads()

COVERAGES = ("Answered from handbook", "Partially covered", "Not in handbook")
SECTIONS = ["Details", "Exceptions and special cases",
            "What the handbook does not say", "Who to contact", "Sources"]

def bullets(text):
    items = []
    for line in text.splitlines():
        m = re.match(r"^\s*[-*+]\s+(.*)$", line)
        if m:
            items.append(m.group(1).strip())
        elif items and re.match(r"^\s+\S", line):
            items[-1] += " " + line.strip()   # folded continuation line
    return [] if len(items) == 1 and items[0].rstrip(".").lower() == "none" else items

def parse(answer):
    coverage = re.search(r"^COVERAGE:\s*(.+)$", answer, re.M).group(1).strip()
    if coverage not in COVERAGES:
        raise ValueError(f"unknown coverage: {coverage}")
    topic = re.search(r"^TOPIC:\s*(.+)$", answer, re.M).group(1).strip()
    confidence = int(re.search(r"^CONFIDENCE:\s*(\d{1,3})\s*$", answer, re.M).group(1))
    quick = re.search(r"^QUICK ANSWER:\s*(.*?)(?:\n\s*\n|\n## )", answer, re.M | re.S)
    body = {}
    for name in SECTIONS:
        m = re.search(rf"^## {re.escape(name)}\s*$(.*?)(?=^## |\Z)", answer, re.M | re.S)
        body[name] = m.group(1).strip() if m else ""
    return {
        "coverage": coverage,
        "topic": topic,
        "confidence": confidence,
        "quick": " ".join(quick.group(1).split()),
        "details": body["Details"],
        "exceptions": bullets(body["Exceptions and special cases"]),
        "gaps": bullets(body["What the handbook does not say"]),
        "contact": bullets(body["Who to contact"]),
        "sources": bullets(body["Sources"]),
    }

def fold(s):
    s = (s.replace("‘", "'").replace("’", "'")
          .replace("“", '"').replace("”", '"')
          .replace("–", "-").replace("—", "-"))
    return " ".join(s.lower().split())

def verify(handbook, bullet):
    """A Sources bullet is '"<verbatim quote>" - <section>'."""
    m = re.search(r'"([^"]+)"', bullet.replace("“", '"').replace("”", '"'))
    if not m:
        return None, False
    quote = m.group(1)
    return quote, len(fold(quote)) >= 8 and fold(quote) in fold(handbook)

ans = parse(raw)
print(f'{ans["coverage"]} / {ans["topic"]} ({ans["confidence"]}%) - {ans["quick"]}')

for gap in ans["gaps"]:
    print("  gap:", gap)
for who in ans["contact"]:
    print("  contact:", who)
for bullet in ans["sources"]:
    quote, ok = verify(payload["handbook"], bullet)
    print(("  verified: " if ok else "  NOT FOUND: ") + (quote or bullet))

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

# the contract's own consistency rules, worth asserting on
assert not (ans["coverage"] == "Answered from handbook"
            and (ans["gaps"] or not ans["sources"])), "inconsistent coverage"
assert not (ans["coverage"] == "Not in handbook" and ans["sources"]), "inconsistent coverage"
import { writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": 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.parse()
const raw = typeof job.output === "string" ? job.output : job.output.output;

const COVERAGES = ["Answered from handbook", "Partially covered", "Not in handbook"];
const SECTIONS = ["Details", "Exceptions and special cases",
                  "What the handbook does not say", "Who to contact", "Sources"];

const bullets = (text) => {
  const items = [];
  for (const line of text.split("\n")) {
    const m = /^\s*[-*+]\s+(.*)$/.exec(line);
    if (m) items.push(m[1].trim());
    else if (items.length && /^\s+\S/.test(line)) items[items.length - 1] += " " + line.trim();
  }
  return items.length === 1 && /^none\.?$/i.test(items[0]) ? [] : items;
};

function parse(answer) {
  const sections = {};
  for (const name of SECTIONS) {
    const re = new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, "m");
    sections[name] = (re.exec(answer)?.[1] ?? "").trim();
  }
  const coverage = /^COVERAGE:\s*(.+)$/m.exec(answer)[1].trim();
  if (!COVERAGES.includes(coverage)) throw new Error(`unknown coverage: ${coverage}`);

  return {
    coverage,
    topic: /^TOPIC:\s*(.+)$/m.exec(answer)[1].trim(),
    confidence: Number(/^CONFIDENCE:\s*(\d{1,3})\s*$/m.exec(answer)[1]),
    quick: /^QUICK ANSWER:\s*([\s\S]*?)(?:\n\s*\n|\n## )/m.exec(answer)[1]
      .split(/\s+/).join(" ").trim(),
    details: sections["Details"],
    exceptions: bullets(sections["Exceptions and special cases"]),
    gaps: bullets(sections["What the handbook does not say"]),
    contact: bullets(sections["Who to contact"]),
    sources: bullets(sections["Sources"]),
  };
}

// The browser re-checks every quote against the paste; so should you.
const fold = (s) => s.replace(/[‘’]/g, "'").replace(/[“”]/g, '"')
  .replace(/[–—]/g, "-").toLowerCase().replace(/\s+/g, " ").trim();

function verify(handbook, bullet) {
  const quote = /"([^"]+)"/.exec(bullet.replace(/[“”]/g, '"'))?.[1] ?? null;
  const ok = quote !== null && fold(quote).length >= 8 && fold(handbook).includes(fold(quote));
  return { quote, ok };
}

const ans = parse(raw);
console.log(`${ans.coverage} / ${ans.topic} (${ans.confidence}%) - ${ans.quick}`);

for (const g of ans.gaps) console.log("  gap:", g);
for (const c of ans.contact) console.log("  contact:", c);
for (const b of ans.sources) {
  const { quote, ok } = verify(payload.handbook, b);
  console.log(ok ? "  verified:" : "  NOT FOUND:", quote ?? b);
}

writeFileSync("answer.md", raw);

// the contract's consistency rules
if (ans.coverage === "Answered from handbook" && (ans.gaps.length || !ans.sources.length)) {
  console.error("warning: Answered from handbook with gaps or no sources - inconsistent.");
}
if (ans.coverage === "Not in handbook" && ans.sources.length) {
  console.error("warning: Not in handbook with sources - inconsistent.");
}
if (ans.coverage === "Not in handbook") process.exitCode = 1;
idem := uuid.NewString() // any unique, stable-per-attempt string

var started struct {
	JobID string `json:"job_id"`
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
// ...send it and decode data.job_id into started (same envelope as call()).

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)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}

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

var (
	coverageRe = regexp.MustCompile(`(?m)^COVERAGE:\s*(.+)$`)
	topicRe    = regexp.MustCompile(`(?m)^TOPIC:\s*(.+)$`)
	confRe     = regexp.MustCompile(`(?m)^CONFIDENCE:\s*(\d{1,3})\s*$`)
	quoteRe    = regexp.MustCompile(`"([^"]+)"`)
	bulletRe   = regexp.MustCompile(`^\s*[-*+]\s+(.*)$`)
)

// section returns the body under a "## Name" heading.
func section(answer, name string) string {
	re := regexp.MustCompile(`(?ms)^## ` + regexp.QuoteMeta(name) + `\s*$(.*?)(?:^## |\z)`)
	m := re.FindStringSubmatch(answer)
	if m == nil {
		return ""
	}
	return strings.TrimSpace(m[1])
}

func bullets(body string) []string {
	var out []string
	for _, ln := range strings.Split(body, "\n") {
		if m := bulletRe.FindStringSubmatch(ln); m != nil {
			out = append(out, strings.TrimSpace(m[1]))
		} else if len(out) > 0 && strings.TrimSpace(ln) != "" && ln[0] == ' ' {
			out[len(out)-1] += " " + strings.TrimSpace(ln)
		}
	}
	if len(out) == 1 && strings.EqualFold(strings.TrimSuffix(out[0], "."), "None") {
		return nil
	}
	return out
}

// fold matches the browser's check: quote style, dash style, case and runs of
// whitespace are normalized before the verbatim comparison.
func fold(s string) string {
	r := strings.NewReplacer("‘", "'", "’", "'", "“", `"`,
		"”", `"`, "–", "-", "—", "-")
	return strings.Join(strings.Fields(strings.ToLower(r.Replace(s))), " ")
}

coverage := strings.TrimSpace(coverageRe.FindStringSubmatch(answer)[1])
topic := strings.TrimSpace(topicRe.FindStringSubmatch(answer)[1])
confidence, _ := strconv.Atoi(confRe.FindStringSubmatch(answer)[1])
gaps := bullets(section(answer, "What the handbook does not say"))
sources := bullets(section(answer, "Sources"))

fmt.Printf("%s / %s (%d%%)\n", coverage, topic, confidence)
for _, g := range gaps {
	fmt.Println("  gap:", g)
}
for _, b := range sources {
	m := quoteRe.FindStringSubmatch(b)
	if m == nil || !strings.Contains(fold(handbook), fold(m[1])) {
		fmt.Println("  NOT FOUND:", b)
		continue
	}
	fmt.Println("  verified:", m[1])
}

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

// the contract's consistency rules
if coverage == "Answered from handbook" && (len(gaps) > 0 || len(sources) == 0) {
	fmt.Fprintln(os.Stderr, "warning: inconsistent coverage")
}
if coverage == "Not in handbook" {
	os.Exit(1)
}
import java.util.regex.*;

String startEnvelope = api("POST", "/run", jsonPayload);   // add the header below
// Send POST /run with an "Idempotency-Key" header - UUID.randomUUID().toString()
// is fine - so a retry cannot start a second, double-charged run.
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);
}
if (status.equals("failed")) throw new RuntimeException(/* data.error */);

// data.output.output is PLAIN TEXT - do not parse it as JSON.
String answer = /* data.output.output */;

Matcher mCov = Pattern.compile("^COVERAGE:\\s*(.+)$", Pattern.MULTILINE).matcher(answer);
Matcher mTop = Pattern.compile("^TOPIC:\\s*(.+)$", Pattern.MULTILINE).matcher(answer);
Matcher mCon = Pattern.compile("^CONFIDENCE:\\s*(\\d{1,3})\\s*$", Pattern.MULTILINE).matcher(answer);
mCov.find();
mTop.find();
mCon.find();
String coverage = mCov.group(1).strip();
String topic = mTop.group(1).strip();
int confidence = Integer.parseInt(mCon.group(1));

// Body under a "## Name" heading.
java.util.function.BiFunction<String, String, String> section = (doc, name) -> {
    Matcher m = Pattern.compile("^## " + Pattern.quote(name) + "\\s*$(.*?)(?=^## |\\z)",
        Pattern.MULTILINE | Pattern.DOTALL).matcher(doc);
    return m.find() ? m.group(1).strip() : "";
};

java.util.function.Function<String, List<String>> bullets = body -> {
    List<String> items = body.lines()
        .filter(l -> l.stripLeading().startsWith("- "))
        .map(l -> l.strip().substring(2).strip())
        .collect(java.util.stream.Collectors.toList());
    return items.size() == 1 && items.get(0).replace(".", "").equalsIgnoreCase("None")
        ? List.of() : items;
};

List<String> gaps = bullets.apply(section.apply(answer, "What the handbook does not say"));
List<String> sources = bullets.apply(section.apply(answer, "Sources"));

System.out.printf("%s / %s (%d%%)%n", coverage, topic, confidence);
gaps.forEach(g -> System.out.println("  gap: " + g));

// Re-verify each quote verbatim, the way the browser does.
String foldedHandbook = handbook.replace('’', '\'').replace('“', '"')
    .replace('”', '"').toLowerCase().replaceAll("\\s+", " ").strip();
for (String bullet : sources) {
    Matcher q = Pattern.compile("\"([^\"]+)\"").matcher(bullet.replace('“', '"').replace('”', '"'));
    if (!q.find()) { System.out.println("  NOT FOUND: " + bullet); continue; }
    String folded = q.group(1).replace('’', '\'').toLowerCase().replaceAll("\\s+", " ").strip();
    System.out.println((foldedHandbook.contains(folded) ? "  verified: " : "  NOT FOUND: ") + q.group(1));
}

Files.writeString(Path.of("answer.md"), answer);

// Answered from handbook requires no gaps AND at least one source;
// Not in handbook requires no sources.
if (coverage.equals("Answered from handbook") && (!gaps.isEmpty() || sources.isEmpty())) {
    System.err.println("warning: inconsistent coverage");
}
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"

# plain text, not JSON
answer = job["output"].is_a?(Hash) ? job["output"]["output"] : job["output"]

def section(answer, name)
  m = answer.match(/^## #{Regexp.escape(name)}\s*$(.*?)(?=^## |\z)/m)
  m ? m[1].strip : ""
end

def bullets(body)
  items = []
  body.each_line do |line|
    if (m = line.match(/^\s*[-*+]\s+(.*)$/))
      items << m[1].strip
    elsif !items.empty? && line =~ /^\s+\S/
      items[-1] += " " + line.strip
    end
  end
  items.length == 1 && items[0].sub(/\.\z/, "").casecmp?("none") ? [] : items
end

def fold(s)
  s.tr("‘’", "''").tr("“”", '""').tr("–—", "--")
   .downcase.split.join(" ")
end

coverage   = answer[/^COVERAGE:\s*(.+)$/, 1].strip
topic      = answer[/^TOPIC:\s*(.+)$/, 1].strip
confidence = answer[/^CONFIDENCE:\s*(\d{1,3})\s*$/, 1].to_i
quick      = answer[/^QUICK ANSWER:\s*(.*?)(?:\n\s*\n|\n## )/m, 1].split.join(" ")

gaps    = bullets(section(answer, "What the handbook does not say"))
contact = bullets(section(answer, "Who to contact"))
sources = bullets(section(answer, "Sources"))

puts "#{coverage} / #{topic} (#{confidence}%) - #{quick}"
gaps.each    { |g| puts "  gap: #{g}" }
contact.each { |c| puts "  contact: #{c}" }

folded = fold(payload[:handbook])
sources.each do |bullet|
  quote = bullet.tr("“”", '""')[/"([^"]+)"/, 1]
  ok = quote && fold(quote).length >= 8 && folded.include?(fold(quote))
  puts "#{ok ? "  verified: " : "  NOT FOUND: "}#{quote || bullet}"
end

File.write("answer.md", answer)

warn "warning: inconsistent coverage" if coverage == "Answered from handbook" &&
                                         (!gaps.empty? || sources.empty?)
exit 1 if coverage == "Not in handbook"
$started = api("POST", "/run", $payload,
    ["Idempotency-Key: " . bin2hex(random_bytes(16))]);

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
$answer = is_array($job["output"]) ? $job["output"]["output"] : $job["output"];

function section(string $answer, string $name): string {
    $re = "/^## " . preg_quote($name, "/") . '\s*$(.*?)(?=^## |\z)/ms';
    return preg_match($re, $answer, $m) ? trim($m[1]) : "";
}

function bullets(string $body): array {
    $items = [];
    foreach (explode("\n", $body) as $line) {
        if (preg_match('/^\s*[-*+]\s+(.*)$/', $line, $m)) {
            $items[] = trim($m[1]);
        } elseif ($items && preg_match('/^\s+\S/', $line)) {
            $items[count($items) - 1] .= " " . trim($line);
        }
    }
    return count($items) === 1 && strcasecmp(rtrim($items[0], "."), "None") === 0 ? [] : $items;
}

function fold(string $s): string {
    $s = str_replace(["\u{2018}", "\u{2019}", "\u{201c}", "\u{201d}", "\u{2013}", "\u{2014}"],
                     ["'", "'", '"', '"', "-", "-"], $s);
    return trim(preg_replace('/\s+/', " ", mb_strtolower($s)));
}

preg_match('/^COVERAGE:\s*(.+)$/m', $answer, $mc);
preg_match('/^TOPIC:\s*(.+)$/m', $answer, $mt);
preg_match('/^CONFIDENCE:\s*(\d{1,3})\s*$/m', $answer, $mn);
preg_match('/^QUICK ANSWER:\s*(.*?)(?:\n\s*\n|\n## )/ms', $answer, $mq);

$coverage = trim($mc[1]);
echo "{$coverage} / " . trim($mt[1]) . " ({$mn[1]}%) - "
    . preg_replace('/\s+/', " ", trim($mq[1])) . "\n";

$gaps    = bullets(section($answer, "What the handbook does not say"));
$contact = bullets(section($answer, "Who to contact"));
$sources = bullets(section($answer, "Sources"));

foreach ($gaps as $g)    { echo "  gap: $g\n"; }
foreach ($contact as $c) { echo "  contact: $c\n"; }

$folded = fold($payload["handbook"]);
foreach ($sources as $bullet) {
    $hasQuote = preg_match('/"([^"]+)"/', str_replace(["\u{201c}", "\u{201d}"], '"', $bullet), $mq2);
    $ok = $hasQuote && strlen(fold($mq2[1])) >= 8 && str_contains($folded, fold($mq2[1]));
    echo ($ok ? "  verified: " : "  NOT FOUND: ") . ($hasQuote ? $mq2[1] : $bullet) . "\n";
}

file_put_contents("answer.md", $answer);

if ($coverage === "Answered from handbook" && ($gaps || !$sources)) {
    fwrite(STDERR, "warning: inconsistent coverage\n");
}
exit($coverage === "Not in handbook" ? 1 : 0);
using System.Text.RegularExpressions;

// The reply contract, decoded once, in one place - mirror of the app's parser.
public enum Coverage { AnsweredFromHandbook, PartiallyCovered, NotInHandbook }

public sealed record Source(string Bullet, string? Quote, string Section, bool Verified);

public sealed partial class PolicyAnswer
{
    private static readonly string[] SectionNames =
        ["Details", "Exceptions and special cases", "What the handbook does not say",
         "Who to contact", "Sources"];

    [GeneratedRegex(@"^COVERAGE:\s*(.+)$", RegexOptions.Multiline)]
    private static partial Regex CoverageRegex();

    [GeneratedRegex(@"^TOPIC:\s*(.+)$", RegexOptions.Multiline)]
    private static partial Regex TopicRegex();

    [GeneratedRegex(@"^CONFIDENCE:\s*(\d{1,3})\s*$", RegexOptions.Multiline)]
    private static partial Regex ConfidenceRegex();

    [GeneratedRegex(@"^QUICK ANSWER:\s*([\s\S]*?)(?:\n\s*\n|\n## )", RegexOptions.Multiline)]
    private static partial Regex QuickRegex();

    [GeneratedRegex("\"([^\"]+)\"")]
    private static partial Regex QuoteRegex();

    public required Coverage Coverage { get; init; }
    public required string Topic { get; init; }
    public required int Confidence { get; init; }
    public required string Quick { get; init; }
    public required string Details { get; init; }
    public required IReadOnlyList<string> Exceptions { get; init; }
    public required IReadOnlyList<string> Gaps { get; init; }
    public required IReadOnlyList<string> Contacts { get; init; }
    public required IReadOnlyList<Source> Sources { get; init; }
    public required string Raw { get; init; }

    /// <summary>
    /// "Answered from handbook" needs empty gaps AND at least one source;
    /// "Not in handbook" needs no sources at all.
    /// </summary>
    public bool IsConsistent =>
        (Coverage is not Coverage.AnsweredFromHandbook || (Gaps.Count == 0 && Sources.Count > 0)) &&
        (Coverage is not Coverage.NotInHandbook || Sources.Count == 0);

    /// <summary>Quote style, dash style, case and whitespace runs are folded
    /// before the verbatim comparison - exactly what the browser does.</summary>
    private static string Fold(string s) =>
        string.Join(' ', s.Replace('‘', '\'').Replace('’', '\'')
                          .Replace('“', '"').Replace('”', '"')
                          .Replace('–', '-').Replace('—', '-')
                          .ToLowerInvariant()
                          .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));

    public static PolicyAnswer Parse(string answer, string handbook)
    {
        var sections = SectionNames.ToDictionary(
            name => name,
            name => Regex.Match(answer,
                $@"^## {Regex.Escape(name)}\s*$([\s\S]*?)(?=^## |\z)",
                RegexOptions.Multiline) is { Success: true } m ? m.Groups[1].Value.Trim() : "");

        static IReadOnlyList<string> Bullets(string body)
        {
            var items = new List<string>();
            foreach (var line in body.Split('\n'))
            {
                var m = Regex.Match(line, @"^\s*[-*+]\s+(.*)$");
                if (m.Success) items.Add(m.Groups[1].Value.Trim());
                else if (items.Count > 0 && Regex.IsMatch(line, @"^\s+\S"))
                    items[^1] += " " + line.Trim();
            }
            return items is [var only] &&
                   only.TrimEnd('.').Equals("None", StringComparison.OrdinalIgnoreCase)
                ? [] : items;
        }

        var foldedHandbook = Fold(handbook);
        var sources = Bullets(sections["Sources"]).Select(b =>
        {
            var m = QuoteRegex().Match(b.Replace('“', '"').Replace('”', '"'));
            var quote = m.Success ? m.Groups[1].Value : null;
            var rest = quote is null ? b : b[(b.IndexOf(quote, StringComparison.Ordinal) + quote.Length)..];
            var section = rest.Trim().TrimStart('"', ' ', '-').Trim();
            var verified = quote is not null && Fold(quote).Length >= 8 &&
                           foldedHandbook.Contains(Fold(quote), StringComparison.Ordinal);
            return new Source(b, quote, section, verified);
        }).ToArray();

        return new PolicyAnswer
        {
            Coverage = CoverageRegex().Match(answer).Groups[1].Value.Trim() switch
            {
                "Answered from handbook" => Coverage.AnsweredFromHandbook,
                "Partially covered" => Coverage.PartiallyCovered,
                "Not in handbook" => Coverage.NotInHandbook,
                var other => throw new FormatException($"unknown coverage: {other}"),
            },
            Topic = TopicRegex().Match(answer).Groups[1].Value.Trim(),
            Confidence = int.Parse(ConfidenceRegex().Match(answer).Groups[1].ValueSpan),
            Quick = string.Join(' ', QuickRegex().Match(answer).Groups[1].Value.Split(
                (char[]?)null, StringSplitOptions.RemoveEmptyEntries)),
            Details = sections["Details"],
            Exceptions = Bullets(sections["Exceptions and special cases"]),
            Gaps = Bullets(sections["What the handbook does not say"]),
            Contacts = Bullets(sections["Who to contact"]),
            Sources = sources,
            Raw = answer,
        };
    }
}

// --- run it -----------------------------------------------------------------

var started = await client.SendAsync(HttpMethod.Post, "/run", input,
    idempotencyKey: Guid.NewGuid().ToString());   // a retry must never double-bill
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await client.SendAsync(HttpMethod.Get, $"/jobs/{jobId}");
    if (job.GetProperty("status").GetString() is "succeeded" or "failed") break;
    await Task.Delay(TimeSpan.FromSeconds(1.5));
}
if (job.GetProperty("status").GetString() == "failed")
    throw new InvalidOperationException(job.GetProperty("error").GetString());

// output.output is PLAIN TEXT - never JsonDocument.Parse it.
var raw = job.GetProperty("output").GetProperty("output").GetString()!;
var ans = PolicyAnswer.Parse(raw, input.Handbook);

Console.WriteLine($"{ans.Coverage} / {ans.Topic} ({ans.Confidence}%) - {ans.Quick}");
foreach (var gap in ans.Gaps) Console.WriteLine($"  gap: {gap}");
foreach (var who in ans.Contacts) Console.WriteLine($"  contact: {who}");
foreach (var src in ans.Sources)
    Console.WriteLine($"  [{(src.Verified ? "verified" : "not found")}] {src.Quote} - {src.Section}");

await File.WriteAllTextAsync("answer.md", ans.Raw);

if (!ans.IsConsistent)
    Console.Error.WriteLine("warning: coverage contradicts the sections - re-run.");

return ans.Coverage is Coverage.NotInHandbook ? 1 : 0;

The reply is asked for as bare text with nothing before COVERAGE: and no fence around the whole response, but a stray wrapper is always possible. Strip a leading ``` fence line and its trailing partner before parsing — that is what the app does before it falls back to a retry_note reformat run.

The reply — output contract

The output is plain text, not JSON. It is four tagged header lines followed by five ## sections, always all five and always in this order. Anything that breaks the grammar below is a failed parse, and the app retries once with the shape spelled out in retry_note.

Line / sectionGrammar
COVERAGE: The first line. Its value is exactly one of Answered from handbook, Partially covered or Not in handbook, spelled and capitalized that way. There is no fourth value.
TOPIC: A single short line of plain text — the policy area the question lands in: Remote work, PTO, Expenses.
CONFIDENCE: A bare integer from 0 to 100. No percent sign, no range, no word. It is confidence that the answer is correct and complete as written: lower when the mapping needed interpretation, when the handbook arrived clipped, or when two sections conflict.
QUICK ANSWER: One to three sentences that answer the question directly. It may wrap over several lines and ends at the first blank line — join the lines with a space when you read it.
## DetailsThe explanation, as markdown body text. Headings inside it are ### and #### only — never # or ##, because a ## line is a section boundary. This is the one section that is free-form rather than bullets.
## Exceptions and special cases- bullets, or the single bullet - None. The handbook's own carve-outs that touch this question: proration, waiting periods, role or location differences.
## What the handbook does not say- bullets, or - None. The parts of the question the handbook leaves open. This is the section that keeps the answer honest — a gap is named here rather than filled with what companies typically do.
## Who to contact- bullets, or - None. The escalation path the handbook names. For visas, taxes, protected leave and terminations there is always a bullet suggesting the asker confirm with HR or legal, even when the handbook is clear.
## Sources- bullets, or - None. Every bullet is - "<verbatim quote from the handbook>" - <section name>: a double-quoted span copied word for word out of what you sent, no ellipsis inside the quotes, then  -  and the section it appears in.

Those five are the only level-2 headings anywhere in the reply. A bullet may wrap onto indented continuation lines, which a parser should fold back into the bullet above it, and an empty section is the single bullet - None. rather than nothing at all.

The consistency rules worth asserting on: Answered from handbook requires ## What the handbook does not say to be empty and ## Sources to hold at least one quote — an open question rules the value out, with no "minor detail" exception. Not in handbook requires ## Sources to be empty, since quoting text that does not answer the question only dresses up a guess. A reply that breaks either rule is self-inconsistent — the app flags it, and so should your pipeline.

Quotes are re-verified in the browser

The app does not take the ## Sources quotes on trust. Each quoted span is searched for in the handbook you pasted — case, quote style (straight or curly), dash style and runs of whitespace folded first, so formatting never fails an otherwise verbatim quote — and each bullet is marked verified or not found in the rendered answer, with a count of the misses underneath. Quotes shorter than eight folded characters never verify. A paraphrased quote is therefore a visible defect rather than a silent one, and the same check is a handful of lines in any language: the samples in step 4 each carry it.

A small, realistic reply for the handbook and question above:

COVERAGE: Partially covered
TOPIC: Remote work
CONFIDENCE: 78
QUICK ANSWER: Six weeks is about 42 calendar days, which is past the handbook's
30-day limit for working from another country, so this is not something you can just
do - it needs written approval from People Ops plus a tax review finished before you
travel. The handbook does not say whether approval beyond 30 days is actually granted,
or on what basis.

## Details

### The limit that applies
The handbook allows working from another country for `30 calendar days` in a calendar
year. Six weeks is roughly `42 days`, so the trip is over the limit on its own, before
anything else you may have used this year is counted.

### What the handbook requires past the limit
Two things, both before departure: written approval from **People Ops**, and a
completed tax review. The tax review is stated as a precondition of travel, not a
formality to finish while you are away.

## Exceptions and special cases
- Working remotely inside your country of employment needs no approval at all; the
  30-day limit is specifically about working from another country.

## What the handbook does not say
- Whether stays past `30 calendar days` are approved in practice, and what People Ops
  weighs when deciding.
- Whether days already worked abroad earlier this year count toward the 30.
- Anything about Portugal specifically, including visa or right-to-work questions.

## Who to contact
- People Ops, for the written approval and to start the tax review.
- Because this touches tax residency and possibly immigration, confirm with HR before
  booking anything, even if the approval comes through.

## Sources
- "Employees may work from another country for up to 30 calendar days in a calendar
  year." - Working from another country
- "Any stay longer than 30 calendar days requires written approval from People Ops and
  a tax review completed before travel begins." - Working from another country
- "Employees may work remotely from anywhere within their country of employment
  without prior approval." - Remote work

This is an AI-generated reading of the text you pasted, not HR advice and not a legal opinion. The answer can only be as complete as the handbook you sent: if you pasted three policy pages, everything outside them is invisible and lands under What the handbook does not say. Check the not found marks and the gaps before anyone acts on the answer.

Step 5 — Stream the answer as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show the answer forming instead of a spinner — useful here because the coverage line and the quick answer arrive in the first few hundred characters, long before the sources are written. 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.

Send an Idempotency-Key header. The bundled SDK's runStream(input, opts) sets it from opts.idempotencyKey, and every sample below sets it by hand. A stream can drop mid-reply for reasons that have nothing to do with the run; without the key the natural retry starts a second run and bills you twice, and with it the replay returns the original result. On such a replay the server may answer with a plain JSON envelope rather than an event stream, so check the Content-Type before you start parsing frames — the SDK does exactly that.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "reading the handbook".
delta{text}A chunk of the plain-text reply, in order. Append it; the accumulated length is your only progress signal, since the total is not known in advance. Watching for the ## headings as they arrive gives you a step list for free.
done{job_id, status, charged_credits, output}The final, authoritative result — read the answer from output.output rather than trusting concatenated deltas, 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.
# Reuse the same key on a retry and the run cannot be billed twice.
IDEM="pl-$(date +%s)-$RANDOM"

curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"COVERAGE: Partially covered\nTOPIC: Remote work\n"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":384,"output":{"output":"COVERAGE: ..."}}
import json, uuid, requests

idem = str(uuid.uuid4())   # reuse this exact value on any retry
result = None

with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": idem},
    json=payload,          # the input object itself
    stream=True,
) as r:
    r.raise_for_status()
    if "text/event-stream" not in r.headers.get("content-type", ""):
        result = r.json()["data"]                 # idempotent replay
    else:
        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(f'{data.get("code")}: {data.get("message")}')

raw = result["output"]["output"]                  # authoritative plain text
ans = parse(raw)                                  # the parser from step 4
print(f'\ncharged {result["charged_credits"]} - {ans["coverage"]} ({ans["confidence"]}%)')
for bullet in ans["sources"]:
    quote, ok = verify(payload["handbook"], bullet)
    print(("  verified: " if ok else "  NOT FOUND: ") + (quote or bullet))
with open("answer.md", "w", encoding="utf-8") as fh:
    fh.write(raw)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(),   // reuse the same value on a retry
  },
  body: JSON.stringify(payload),       // the input object itself
});

let done;
if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) {
  done = (await res.json()).data;      // idempotent replay
} else {
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  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.code}: ${data.message}`);
    }
  }
}

const raw = done.output.output;        // authoritative plain text
const ans = parse(raw);                // the parser from step 4
console.log(`\n${done.charged_credits} credits - ${ans.coverage} (${ans.confidence}%)`);
for (const b of ans.sources) {
  const { quote, ok } = verify(payload.handbook, b);
  console.log(ok ? "  verified:" : "  NOT FOUND:", quote ?? b);
}
writeFileSync("answer.md", raw);
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", idem) // reuse the same value on a retry

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var final map[string]any
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
	var env struct{ Data map[string]any `json:"data"` }
	json.NewDecoder(res.Body).Decode(&env) // idempotent replay
	final = env.Data
} else {
	var event string
	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.Fatalf("%v: %v", data["code"], data["message"])
			}
		}
	}
}

answer := final["output"].(map[string]any)["output"].(string) // plain text
os.WriteFile("answer.md", []byte(answer), 0o644)
fmt.Println("\n" + coverageRe.FindStringSubmatch(answer)[1]) // parser from step 4
// 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", idem)   // reuse the same value on a retry
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
// If the Content-Type is not text/event-stream this was an idempotent replay:
// the body is a plain {"data": ...} envelope, so read data.output.output directly.

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`, take data.output.output - it is PLAIN TEXT, not JSON - then run
// the step-4 parser over it: COVERAGE, TOPIC, CONFIDENCE, QUICK ANSWER, then the
// five sections, then re-verify each Sources quote against the handbook you sent.
// data.charged_credits is the settled price.
// Files.writeString(Path.of("answer.md"), answer);
require "net/http"
require "json"
require "securerandom"

idem = SecureRandom.uuid   # reuse this exact value on any retry

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem
req.body = payload.to_json   # the input object itself

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    if !res["content-type"].to_s.include?("text/event-stream")
      done = JSON.parse(res.body)["data"]   # idempotent replay
      next
    end
    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["code"]}: #{data["message"]}"
          end
        end
      end
    end
  end
end

answer = done["output"]["output"]   # authoritative plain text
puts "\n#{done["charged_credits"]} credits - #{answer[/^COVERAGE:\s*(.+)$/, 1]}"
File.write("answer.md", answer)
$event = null;
$done  = null;
$idem  = bin2hex(random_bytes(16));   // reuse the same value on a retry

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: $idem",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),   // the input object itself
    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['code']}: {$data['message']}");
                }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$answer = $done["output"]["output"];   // authoritative plain text
preg_match('/^COVERAGE:\s*(.+)$/m', $answer, $mc);
echo "\n{$done['charged_credits']} credits - " . trim($mc[1]) . "\n";
file_put_contents("answer.md", $answer);
// Streaming run, as an extension on the step-0 client (put it in a static
// class of your own). The idempotency key is what makes a dropped stream safe
// to retry - reuse the same value for the same attempt.
public static async Task<PolicyAnswer> RunStreamAsync(
    this SkillSafeClient client, PolicyInput input, string idempotencyKey,
    IProgress<string>? onDelta = null, CancellationToken ct = default)
{
    using var req = new HttpRequestMessage(
        HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream")
    {
        Content = JsonContent.Create(input),   // the input object itself
    };
    req.Headers.Add("Idempotency-Key", idempotencyKey);

    using var res = await client.Http.SendAsync(
        req, HttpCompletionOption.ResponseHeadersRead, ct);
    res.EnsureSuccessStatusCode();

    // An idempotent replay answers with a plain envelope, not an event stream.
    if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
    {
        var replay = await res.Content.ReadFromJsonAsync<JsonElement>(ct);
        return PolicyAnswer.Parse(
            replay.GetProperty("data").GetProperty("output").GetProperty("output").GetString()!,
            input.Handbook);
    }

    using var reader = new StreamReader(await res.Content.ReadAsStreamAsync(ct));
    string? evt = null;
    JsonElement done = default;

    while (await reader.ReadLineAsync(ct) is { } line)
    {
        if (line.StartsWith("event:", StringComparison.Ordinal))
        {
            evt = line[6..].Trim();
        }
        else if (line.StartsWith("data:", StringComparison.Ordinal))
        {
            using var frame = JsonDocument.Parse(line[5..].Trim());
            switch (evt)
            {
                case "delta":
                    onDelta?.Report(frame.RootElement.GetProperty("text").GetString() ?? "");
                    break;
                case "done":
                    done = frame.RootElement.Clone();
                    break;
                case "error":
                    throw new SkillSafeException(
                        frame.RootElement.GetProperty("code").GetString()!,
                        frame.RootElement.GetProperty("message").GetString()!);
            }
        }
    }

    Console.WriteLine($"charged {done.GetProperty("charged_credits").GetInt64():N0} credits");

    // Trust the final payload, not the concatenated deltas.
    return PolicyAnswer.Parse(
        done.GetProperty("output").GetProperty("output").GetString()!, input.Handbook);
}

// --- use it -----------------------------------------------------------------

var streamed = await client.RunStreamAsync(
    input,
    idempotencyKey: Guid.NewGuid().ToString(),
    onDelta: new Progress<string>(_ => Console.Write(".")));

Console.WriteLine();
Console.WriteLine($"{streamed.Coverage} / {streamed.Topic} ({streamed.Confidence}%)");
foreach (var src in streamed.Sources.Where(s => !s.Verified))
    Console.Error.WriteLine($"  quote not found in the handbook: {src.Quote}");
await File.WriteAllTextAsync("answer.md", streamed.Raw);

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 does. Deltas are for progress only: they can be cut short if a run runs out of credits mid-reply, so the answer you keep — and the one you verify the quotes against — is always the one in the done event.