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.
| Status | Error code | Meaning |
|---|---|---|
400 | validation_error | The 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. |
401 | unauthorized | Missing, malformed or expired token — mint a new one with POST /guest or sign in again. |
402 | payment_required | Not enough credits to place the hold — top up at skillsafe.ai/account/credits, or check /estimate first. |
404 | not_found | Unknown job id, or a route that does not exist (check you did not add an /apps/… segment). |
429 | rate_limited | Too many requests — back off and retry. Reuse the same Idempotency-Key on the retry so the run cannot be billed twice. |
5xx | internal_error | Transient 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
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
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
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 field | Type | Notes |
|---|---|---|
handbook | string, required | The 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. |
question | string, required | The 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. |
context | string, optional | Facts 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. |
docscan | string, optional | A 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_note | string, optional | Only 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
/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 / section | Grammar |
|---|---|
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. |
## Details | The 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.