Drive Rollforward Desk from your own code
Everything the web page does is available over HTTP. You send the roll-forward facts for one
balance-sheet account and one period: the schedule, the foot check, the gap, the flags and the
postings behind each line. You get the same review the page shows. It gives a verdict on whether
the schedule can go into the close package, a note for every activity line, an explanation, action
and owner for every exception, a line for every posting no rule could place, the support requests
to raise and a note for the audit file. The natural use is month-end close: a script rolls every
balance-sheet account forward, files the note with the schedule and holds anything whose verdict
is not ties.
Before the first call, note this: the model never does the arithmetic.
The GL detail is read, classified into lines L2 to L8, footed and its gap worked by
rollkit.js, the same file the web page loads. The result is sent as
facts, a JSON string. The model's job is judgement over those facts. See
building the facts below.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api. A success carries
its payload in data. A failure has a non-2xx HTTP status and an error object:
HTTP 2xx { "data": { ... } }
HTTP 4xx { "error": { "code": "...", "message": "...", "details": { ... } } }
This is exactly how the vendored SDK reads it. It treats a response as failed when the HTTP status
is not 2xx, then takes error.code, error.message and
error.details. Otherwise it returns data. So branch on the status, not on a
body flag. Send your token as Authorization: Bearer YOUR_TOKEN and
Content-Type: application/json on every call. The token is minted for this app, so no
slug header is needed.
The input object IS the request body. There is no {"input": ...}
wrapper: the SDK posts JSON.stringify(input) as the body of /estimate,
/run and /run-stream. Always send a JSON object. This app declares its input fields, so /estimate
and /run return a warnings list naming a missing task or
facts, or an unknown field such as a stray input wrapper. A warning is
advisory, not a rejection: the run still happens and is still charged. The page guards itself
with Rollkit.mustBeObject, which throws unless the body is an object with string
task and facts. Put the same check in your client, and treat any
warning as a bug in your body.
Error codes
Only statuses and codes the platform has actually been seen to return are listed. Always log
error.code and error.message as given, and branch on the HTTP status.
| status | code | what to do |
|---|---|---|
| 400 | validation_error | The platform rejected the request body or a field in it. Read error.message, fix the body and resend with a new Idempotency-Key. |
| 401 | unauthorized | "Invalid or expired app session". The token is missing, expired or revoked, or it is an account API key rather than an app token. Get a fresh one from the token page. |
| 402 | (read error.code) | The balance is below min_credits from /estimate. Top up. A balance between min_credits and hold_credits is not refused; it runs truncated (see truncation). |
| 404 | not_found | An id in the path does not exist, for example a mistyped job_id on /jobs/{job_id}. |
| 429 | (read error.code) | Shared rate limit. Back off and retry; never tight-loop. Keep the same Idempotency-Key. |
| SSE | event: error | On /run-stream, a failure after the stream opened arrives as an error event whose data is {code, message, job_id}. |
1. Get a token
The easiest route is the token page. It shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. It reads the same storage the app uses, so you never need a developer tool.
There are two kinds of token. A guest token is enough for /me and
/estimate, and you can mint one yourself with POST /guest and
{"slug":"rollforward-desk"}, as below. Reviewing a roll-forward is metered, so
/run and /run-stream need a personal token. You get one by
signing in on the token page. It bills your own balance. Treat it like a password.
# Personal token (needed for runs): sign in at
# https://rollforward-desk.skillsafe.ai/tokens.html
# and press "Copy shell export", which gives you:
# export SKILLSAFE_TOKEN="..."
#
# Guest token (enough for /me and /estimate), minted from the command line:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"rollforward-desk"}' | jq -r '.data.token'
import json, urllib.request
# Personal token: sign in at https://rollforward-desk.skillsafe.ai/tokens.html and copy it.
# Guest token (enough for /me and /estimate):
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "rollforward-desk"}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as r:
guest = json.load(r)["data"]
print(guest["token"]) # the bearer token
print(guest["guest_id"]) # the guest wallet id
// Personal token: sign in at https://rollforward-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate):
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "rollforward-desk" }),
});
const { data } = await res.json();
console.log(data.token); // the bearer token
console.log(data.guest_id); // the guest wallet id
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
// Personal token: sign in at https://rollforward-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate):
func main() {
res, err := http.Post("https://api.skillsafe.ai/v1/app-api/guest", "application/json",
bytes.NewBufferString(`{"slug":"rollforward-desk"}`))
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
panic(err)
}
fmt.Println(env.Data.Token)
}
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// Personal token: sign in at https://rollforward-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate):
public class GuestToken {
public static void main(String[] args) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"rollforward-desk\"}"))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
String token = new ObjectMapper().readTree(res.body()).path("data").path("token").asText();
System.out.println(token);
}
}
require "json"
require "net/http"
require "uri"
# Personal token: sign in at https://rollforward-desk.skillsafe.ai/tokens.html and copy it.
# Guest token (enough for /me and /estimate):
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, { slug: "rollforward-desk" }.to_json, "Content-Type" => "application/json")
guest = JSON.parse(res.body)["data"]
puts guest["token"]
<?php
// Personal token: sign in at https://rollforward-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate):
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "rollforward-desk"]),
CURLOPT_RETURNTRANSFER => true,
]);
$guest = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
echo $guest["token"], PHP_EOL;
using System.Net.Http.Json;
using System.Text.Json;
// Personal token: sign in at https://rollforward-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate):
using var http = new HttpClient();
var res = await http.PostAsJsonAsync("https://api.skillsafe.ai/v1/app-api/guest",
new { slug = "rollforward-desk" });
var env = await res.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(env.GetProperty("data").GetProperty("token").GetString());
2. A tiny client
One helper that adds the two headers, sends an already-serialised JSON body, unwraps
data and raises on a non-2xx status with error.code. It takes the body as
a string so a run can hash the exact bytes it sends (step 5). The later steps assume this
helper is in scope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # from https://rollforward-desk.skillsafe.ai/tokens.html
# api METHOD PATH [BODY_FILE] [extra curl args...]
# Prints the response body; exits non-zero (curl --fail-with-body) on a 4xx/5xx.
api() {
local method="$1" path="$2" body="$3"; shift 3 2>/dev/null || shift $#
if [ -n "$body" ]; then
curl -sS --fail-with-body -X "$method" "$BASE$path" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--data-binary "@$body" "$@"
else
curl -sS --fail-with-body -X "$method" "$BASE$path" \
-H "Authorization: Bearer $TOKEN" "$@"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def call(method, path, body=None, headers=None):
"""body is an already-serialised JSON string (or None). Returns `data`."""
req = urllib.request.Request(BASE + path, method=method,
data=body.encode("utf-8") if body is not None else None)
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r).get("data")
except urllib.error.HTTPError as e:
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
raise ApiError(e.code, err.get("code"), err.get("message") or e.reason, err.get("details")) from None
// Node 18+ as an ES module (.mjs, for top-level await) or a modern browser.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const token = "YOUR_TOKEN"; // from https://rollforward-desk.skillsafe.ai/tokens.html
// body is an already-serialised JSON string (or undefined). Returns `data`.
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: { "Content-Type": "application/json", Authorization: "Bearer " + token, ...headers },
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // from /tokens.html
// The later snippets are functions in this same package; call them from your main().
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
// call sends one request (body is serialised JSON or nil) and returns the `data` member.
func call(method, path string, body []byte, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := env.Error
if e == nil {
e = &APIError{Message: res.Status}
}
e.Status = res.StatusCode
return nil, e
}
return env.Data, nil
}
// Java 17+, Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
// The later snippets are static members of this class; plain statements go in main().
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class Rollforward {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
/** body is serialised JSON or null. Returns the `data` member. */
static JsonNode call(String method, String path, String body, Map<String, String> extra) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + TOKEN)
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (res.statusCode() < 200 || res.statusCode() > 299) {
JsonNode err = env.path("error");
throw new ApiException(res.statusCode(), err.path("code").asText(null),
err.path("message").asText("HTTP " + res.statusCode()));
}
return env.path("data");
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
# body is an already-serialised JSON string (or nil). Returns `data`.
def call(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = (method == "GET" ? Net::HTTP::Get : Net::HTTP::Post).new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
headers.each { |k, v| req[k] = v }
req.body = body if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
class ApiError extends RuntimeException {
public function __construct(public int $status, public ?string $errCode, string $message) {
parent::__construct("$status $errCode: $message");
}
}
/** $body is an already-serialised JSON string (or null). Returns `data` as an array. */
function call(string $method, string $path, ?string $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
$h = ["Content-Type: application/json", "Authorization: Bearer " . TOKEN];
foreach ($headers as $k => $v) $h[] = "$k: $v";
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $h,
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw, true) ?: [];
if ($status < 200 || $status > 299) {
$err = $env["error"] ?? [];
throw new ApiError($status, $err["code"] ?? null, $err["message"] ?? "HTTP $status");
}
return $env["data"] ?? null;
}
// .NET 8. Api.cs: keep it in its own file (C# wants types after top-level statements).
// The later snippets are top-level statements and local functions in Program.cs.
global using System.Net.Http.Headers;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Nodes;
global using System.Text.RegularExpressions;
public class ApiException(int status, string? code, string? message)
: Exception($"{status} {code}: {message}")
{
public int Status => status;
public string? Code => code;
}
public static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // from /tokens.html
public static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
// body is serialised JSON or null. Returns the `data` member.
public static async Task<JsonNode?> Call(HttpMethod method, string path, string? body = null,
IDictionary<string, string>? extra = null)
{
using var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body is not null) req.Content = new StringContent(body, Encoding.UTF8, "application/json");
if (extra is not null)
foreach (var (k, v) in extra) req.Headers.TryAddWithoutValidation(k, v);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode)
throw new ApiException((int)res.StatusCode, (string?)env?["error"]?["code"],
(string?)env?["error"]?["message"] ?? res.ReasonPhrase);
return env?["data"];
}
}
3. Check the session and the balance
GET /me is free. It returns the subject behind the token: subject_type
(user for a personal token), subject_id, credits (the balance)
and, for a signed-in user, profile fields such as username. Call it first. A
401 here means the token is stale, so nothing else will work either.
api GET /me | jq '.data | {subject_type, subject_id, credits}'
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
if me["subject_type"] != "user":
print("guest token: /me and /estimate work, a review needs a personal token")
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") console.log("guest token: /me and /estimate work, a review needs a personal token");
func showMe() error {
data, err := call("GET", "/me", nil, nil)
if err != nil {
return err
}
var me map[string]any
if err := json.Unmarshal(data, &me); err != nil {
return err
}
fmt.Println(me["subject_type"], me["credits"])
if me["subject_type"] != "user" {
fmt.Println("guest token: /me and /estimate work, a review needs a personal token")
}
return nil
}
JsonNode me = call("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("credits").asLong());
if (!"user".equals(me.path("subject_type").asText())) {
System.out.println("guest token: /me and /estimate work, a review needs a personal token");
}
me = call("GET", "/me")
puts "#{me['subject_type']} #{me['credits']}"
puts "guest token: /me and /estimate work, a review needs a personal token" unless me["subject_type"] == "user"
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", PHP_EOL;
if ($me["subject_type"] !== "user") {
echo "guest token: /me and /estimate work, a review needs a personal token", PHP_EOL;
}
var me = await Api.Call(HttpMethod.Get, "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["credits"]}");
if ((string?)me?["subject_type"] != "user")
Console.WriteLine("guest token: /me and /estimate work, a review needs a personal token");
4. Build the input and price it (free)
There is one lane, so task is always "review". An unknown or missing
task is still answered as a review. The body is one flat JSON object:
| field | type | what goes in it |
|---|---|---|
task | string, required | "review" |
facts | string, required | The JSON-encoded output of Rollkit.buildFacts. A string, not an object: the model is told to parse it first. |
account | string | The balance-sheet account, as you name it (2140 Accrued expenses). |
entity | string | The legal entity whose books these are. |
period | string | The period in words (June 2026). |
question | string | Optional, at most 600 characters (the page cuts it there). Answered in the headline or summary; it never overrides the rules. Send "" when you have none. |
retry_note | string | Only when resending after a reply that could not be parsed, or to ask for a shorter one. The model obeys it for shape and length and never mentions it. |
POST /estimate takes exactly the body you will run and costs nothing: no charge and no
job. Read hold_credits as the amount the platform reserves for a run
of this size. It is priced worst-case against the full output cap, so it is not the price. The
settled charge comes back later as charged_credits on the job, usually far below the
hold. min_credits is the floor below which a run is refused. The response also names
the model the app is bound to.
Building the facts
The page computes facts in your browser before any model runs. An API caller must
build it the same way. The simplest route is to load rollkit.js in Node:
Rollkit.analyze(glText, priorAccrualsText, settings) reads the GL detail and returns a
profile. Then Rollkit.buildInput({profile, account, entity, period, currency, question})
returns the whole body, with facts already stringified. The settings are
normal (credit or debit), sign,
date_order, period_start, period_end, bb (the
close-package beginning balance), prior_gl (the GL at prior-period end),
gl_end and tolerance, as the page's worked examples in
example.js show. The facts object carries:
account,entity,period,currency,period_start,period_end,normal_balance(creditordebit),toleranceandformula(L1 + ... + L8 = L9; L11 = L10 - L9, every line in the account's natural sign).lines: L1 to L11, each withid,key,name,amount,rows(how many postings) andties_to(where the figure comes from). Activity lines that have rows also carrydebitsandcredits. L2 to L8 are components A to F and U; L9 is the computed ending balance, L10 the GL ending balance and L11 the difference.gap:amount,status(ties,explained,unexplained,cannot_foot),causes,parts({code, amount, detail}per proven cause) andhints(candidates, not proof).flags:id(X1...),code,severity(block,warn,info),detail, and sometimesrowsandamount.prior_accruals:id(P1...),ref,memo,amount,reversed_by(a row id ornull).rows: every posting when the ledger has at most 80; otherwise a selection of 80: every unclassified row, every row a flag or gap part names, the three largest of each line, then the largest of the rest.counts.rows_sentagainstcounts.rows_readsays which. Each hasid(R1...),date,je,source,memo,amount(natural sign),line,component(AtoForU) andrule.counts:rows_read,rows_sent,rows_skipped,unclassified. Thenverdict_floor, the strictest verdict the arithmetic already forces.
The flag codes rollkit.js raises:
| severity | codes |
|---|---|
block | no_beginning, no_gl_end, bad_number (also raised as warn) |
warn | bb_mismatch, eb_paste_mismatch, out_of_period, duplicate, unclassified, direction, unreversed_accrual, unmatched_reversal, skipped_rows, truncated, clipped |
info | bb_from_paste, bad_dates, cutoff_not_checked, reversals_not_matched, same_period_reversal, zero_rows |
Worked example: the Velmarsk body
This is the exact body rollkit.js builds for the page's
vireo-explained example: accrued expenses for an invented company, June 2026. The gap
of (4,250.00) is explained exactly by a beginning-balance mismatch, one posting dated in July and
one duplicated payment. It was generated locally with:
node -e 'var R=require("./rollkit.js"),E=require("./example.js");var x=E.byId("vireo-explained");var P=R.analyze(x.gl,x.prior,x);console.log(JSON.stringify(R.buildInput({profile:P,account:x.account,entity:x.entity,period:x.period,currency:x.currency,question:x.question})))'
The output, unabridged: note that facts is one long string.
{"task":"review","facts":"{\"account\":\"2140 Accrued expenses\",\"entity\":\"Velmarsk Components GmbH\",\"period\":\"June 2026\",\"currency\":\"EUR\",\"period_start\":\"2026-06-01\",\"period_end\":\"2026-06-30\",\"normal_balance\":\"credit\",\"tolerance\":1,\"formula\":\"L1 + L2 + L3 + L4 + L5 + L6 + L7 + L8 = L9; L11 = L10 - L9 (every line in the account's natural sign, so reversals and payments are normally negative)\",\"lines\":[{\"id\":\"L1\",\"key\":\"BB\",\"name\":\"Beginning balance\",\"amount\":318900,\"rows\":0,\"ties_to\":\"prior-period close package (entered)\"},{\"id\":\"L2\",\"key\":\"A\",\"name\":\"Additions / new activity\",\"amount\":5600,\"rows\":1,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 1 row (R4) classified by source AP (1); debits 0.00, credits 5,600.00\",\"debits\":0,\"credits\":5600},{\"id\":\"L3\",\"key\":\"B\",\"name\":\"Accruals booked this period\",\"amount\":65250,\"rows\":5,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 5 rows (R8, R9, R10, R11, R13) classified by source ACCR (5); debits 0.00, credits 65,250.00\",\"debits\":0,\"credits\":65250},{\"id\":\"L4\",\"key\":\"C\",\"name\":\"Reversals of prior accruals\",\"amount\":-52250,\"rows\":3,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 3 rows (R1, R2, R3) classified by source REV (3); debits 52,250.00, credits 0.00\",\"debits\":52250,\"credits\":0},{\"id\":\"L5\",\"key\":\"D\",\"name\":\"Payments / settlements\",\"amount\":-20900,\"rows\":3,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 3 rows (R5, R6, R7) classified by source PAY (3); debits 20,900.00, credits 0.00\",\"debits\":20900,\"credits\":0},{\"id\":\"L6\",\"key\":\"E\",\"name\":\"Reclasses / adjustments\",\"amount\":0,\"rows\":0,\"ties_to\":\"no rows\"},{\"id\":\"L7\",\"key\":\"F\",\"name\":\"FX translation\",\"amount\":1240,\"rows\":1,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 1 row (R12) classified by memo 'FX' (1); debits 0.00, credits 1,240.00\",\"debits\":0,\"credits\":1240},{\"id\":\"L8\",\"key\":\"U\",\"name\":\"Unclassified activity\",\"amount\":0,\"rows\":0,\"ties_to\":\"no rows\"},{\"id\":\"L9\",\"key\":\"CEB\",\"name\":\"Computed ending balance\",\"amount\":317840,\"rows\":0,\"ties_to\":\"beginning balance plus every activity line above\"},{\"id\":\"L10\",\"key\":\"GL\",\"name\":\"Ending balance per GL\",\"amount\":313590,\"rows\":0,\"ties_to\":\"GL / trial balance at period end (entered)\"},{\"id\":\"L11\",\"key\":\"GAP\",\"name\":\"Unexplained difference\",\"amount\":-4250,\"rows\":0,\"ties_to\":\"ending balance per GL less computed ending balance\"}],\"gap\":{\"amount\":-4250,\"status\":\"explained\",\"causes\":[\"bb_mismatch\",\"out_of_period\",\"duplicate\"],\"parts\":[{\"code\":\"bb_mismatch\",\"amount\":-7500,\"detail\":\"GL at prior-period end less the close-package beginning balance: (7,500.00)\"},{\"code\":\"out_of_period\",\"amount\":-3000,\"detail\":\"rows dated outside the period (R13) net 3,000.00; removing them changes the computed ending balance by (3,000.00)\"},{\"code\":\"duplicate\",\"amount\":6250,\"detail\":\"R6 = R7: 1 extra copy of (6,250.00)\"}],\"hints\":[]},\"flags\":[{\"id\":\"X1\",\"code\":\"bb_mismatch\",\"severity\":\"warn\",\"detail\":\"The beginning balance per the close package, 318,900.00, differs from the GL at prior-period end, 311,400.00, by (7,500.00) (GL less package): a post-close entry, or a package that was never updated.\",\"amount\":-7500},{\"id\":\"X2\",\"code\":\"out_of_period\",\"severity\":\"warn\",\"detail\":\"1 row(s) are dated outside 2026-06-01 to 2026-06-30, netting 3,000.00: R13 (2026-07-02).\",\"rows\":[\"R13\"],\"amount\":3000},{\"id\":\"X3\",\"code\":\"duplicate\",\"severity\":\"warn\",\"detail\":\"1 set(s) of identical rows (same date, reference, source, memo and amount): R6 = R7 ((6,250.00)).\",\"rows\":[\"R6\",\"R7\"]},{\"id\":\"X4\",\"code\":\"unreversed_accrual\",\"severity\":\"warn\",\"detail\":\"1 of last period's 4 accrual(s) have no reversal of the same amount this period: P3 Audit fee Q2 accrual (April-May) 9,000.00.\"}],\"prior_accruals\":[{\"id\":\"P1\",\"ref\":\"JE-5090\",\"memo\":\"May accrual - freight\",\"amount\":14200,\"reversed_by\":\"R1\"},{\"id\":\"P2\",\"ref\":\"JE-5090\",\"memo\":\"May accrual - energy\",\"amount\":21600,\"reversed_by\":\"R2\"},{\"id\":\"P3\",\"ref\":\"JE-5090\",\"memo\":\"Audit fee Q2 accrual (April-May)\",\"amount\":9000,\"reversed_by\":null},{\"id\":\"P4\",\"ref\":\"JE-5090\",\"memo\":\"May accrual - temp labor\",\"amount\":16450,\"reversed_by\":\"R3\"}],\"rows\":[{\"id\":\"R1\",\"date\":\"2026-06-01\",\"je\":\"JE-5101\",\"source\":\"REV\",\"memo\":\"Reverse May accrual - freight\",\"amount\":-14200,\"line\":\"Reversals of prior accruals\",\"component\":\"C\",\"rule\":\"source REV\"},{\"id\":\"R2\",\"date\":\"2026-06-01\",\"je\":\"JE-5101\",\"source\":\"REV\",\"memo\":\"Reverse May accrual - energy\",\"amount\":-21600,\"line\":\"Reversals of prior accruals\",\"component\":\"C\",\"rule\":\"source REV\"},{\"id\":\"R3\",\"date\":\"2026-06-01\",\"je\":\"JE-5101\",\"source\":\"REV\",\"memo\":\"Reverse May accrual - temp labor\",\"amount\":-16450,\"line\":\"Reversals of prior accruals\",\"component\":\"C\",\"rule\":\"source REV\"},{\"id\":\"R4\",\"date\":\"2026-06-09\",\"je\":\"AP-8812\",\"source\":\"AP\",\"memo\":\"Addition - tooling warranty obligation, contract 4471\",\"amount\":5600,\"line\":\"Additions / new activity\",\"component\":\"A\",\"rule\":\"source AP\"},{\"id\":\"R5\",\"date\":\"2026-06-15\",\"je\":\"PY-2215\",\"source\":\"PAY\",\"memo\":\"Payment - Wendelbruck Logistik, April freight\",\"amount\":-8400,\"line\":\"Payments / settlements\",\"component\":\"D\",\"rule\":\"source PAY\"},{\"id\":\"R6\",\"date\":\"2026-06-22\",\"je\":\"PY-2222\",\"source\":\"PAY\",\"memo\":\"Payment - Stadtwerke energy settlement\",\"amount\":-6250,\"line\":\"Payments / settlements\",\"component\":\"D\",\"rule\":\"source PAY\"},{\"id\":\"R7\",\"date\":\"2026-06-22\",\"je\":\"PY-2222\",\"source\":\"PAY\",\"memo\":\"Payment - Stadtwerke energy settlement\",\"amount\":-6250,\"line\":\"Payments / settlements\",\"component\":\"D\",\"rule\":\"source PAY\"},{\"id\":\"R8\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - freight\",\"amount\":15050,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R9\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - energy\",\"amount\":20900,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R10\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - audit fee Q2\",\"amount\":9000,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R11\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - temp labor\",\"amount\":17300,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R12\",\"date\":\"2026-06-30\",\"je\":\"JE-5195\",\"source\":\"FXREV\",\"memo\":\"FX revaluation - USD-denominated accruals\",\"amount\":1240,\"line\":\"FX translation\",\"component\":\"F\",\"rule\":\"memo 'FX'\"},{\"id\":\"R13\",\"date\":\"2026-07-02\",\"je\":\"JE-5201\",\"source\":\"ACCR\",\"memo\":\"July accrual - freight (posted early)\",\"amount\":3000,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"}],\"counts\":{\"rows_read\":13,\"rows_sent\":13,\"rows_skipped\":0,\"unclassified\":0},\"verdict_floor\":\"does_not_tie\"}","account":"2140 Accrued expenses","entity":"Velmarsk Components GmbH","period":"June 2026","question":"Why does this not tie to the trial balance?"}
Save your own body as body.json, or just the facts object as facts.json
(the output of JSON.stringify(Rollkit.buildFacts(profile, ctx))). The samples below
read facts.json as text and send that text as the facts string. If you build
the facts in your own code, JSON-encode them first.
# Wrap facts.json into the body; tojson turns the facts object into the required STRING.
jq -c '{task: "review", facts: tojson,
account: "2140 Accrued expenses", entity: "Velmarsk Components GmbH",
period: "June 2026", question: "Why does this not tie to the trial balance?"}' \
facts.json > body.json
api POST /estimate body.json | jq '.data | {model, hold_credits, min_credits}'
def build_body(question=""):
with open("facts.json", encoding="utf-8") as f:
facts = f.read().strip() # already JSON text; if you hold a dict, use json.dumps(it)
body = {
"task": "review",
"facts": facts, # a STRING, not an object
"account": "2140 Accrued expenses",
"entity": "Velmarsk Components GmbH",
"period": "June 2026",
"question": question[:600],
}
return json.dumps(body)
body = build_body("Why does this not tie to the trial balance?")
est = call("POST", "/estimate", body)
print("reserved (hold_credits):", est["hold_credits"], "floor (min_credits):", est["min_credits"])
// Node 18+, saved as review.mjs so top-level await works. Builds the body with the
// page's own rollkit.js, exactly as the page does.
import { createRequire } from "node:module";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const Rollkit = require("./rollkit.js");
const settings = {
normal: "credit", sign: "debit_positive", date_order: "dmy",
period_start: "2026-06-01", period_end: "2026-06-30",
bb: "318,900.00", prior_gl: "311,400.00", gl_end: "313,590.00", tolerance: "1.00",
account: "2140 Accrued expenses",
};
const profile = Rollkit.analyze(fs.readFileSync("gl.tsv", "utf8"), fs.readFileSync("prior.tsv", "utf8"), settings);
const input = Rollkit.mustBeObject(Rollkit.buildInput({
profile, account: settings.account, entity: "Velmarsk Components GmbH",
period: "June 2026", currency: "EUR", question: "Why does this not tie to the trial balance?",
}));
const body = JSON.stringify(input); // input.facts is already a string
const est = await call("POST", "/estimate", body);
console.log("reserved (hold_credits):", est.hold_credits, "floor (min_credits):", est.min_credits);
// add import: "strings"
type Input struct {
Task string `json:"task"`
Facts string `json:"facts"` // a STRING, not an object
Account string `json:"account"`
Entity string `json:"entity"`
Period string `json:"period"`
Question string `json:"question"`
RetryNote string `json:"retry_note,omitempty"`
}
func buildBody(question, retryNote string) ([]byte, error) {
facts, err := os.ReadFile("facts.json") // already JSON text
if err != nil {
return nil, err
}
if r := []rune(question); len(r) > 600 {
question = string(r[:600])
}
return json.Marshal(Input{
Task: "review", Facts: strings.TrimSpace(string(facts)),
Account: "2140 Accrued expenses", Entity: "Velmarsk Components GmbH",
Period: "June 2026", Question: question, RetryNote: retryNote,
})
}
func estimate(body []byte) error {
data, err := call("POST", "/estimate", body, nil)
if err != nil {
return err
}
var est map[string]any
if err := json.Unmarshal(data, &est); err != nil {
return err
}
fmt.Println("reserved (hold_credits):", est["hold_credits"], "floor (min_credits):", est["min_credits"])
return nil
}
// add imports: java.nio.file.Files, java.nio.file.Path,
// com.fasterxml.jackson.databind.node.ObjectNode
static String buildBody(String question, String retryNote) throws Exception {
String facts = Files.readString(Path.of("facts.json")).strip(); // already JSON text
ObjectNode body = JSON.createObjectNode();
body.put("task", "review");
body.put("facts", facts); // a STRING, not an object
body.put("account", "2140 Accrued expenses");
body.put("entity", "Velmarsk Components GmbH");
body.put("period", "June 2026");
body.put("question", question.length() > 600 ? question.substring(0, 600) : question);
if (retryNote != null) body.put("retry_note", retryNote);
return JSON.writeValueAsString(body);
}
String body = buildBody("Why does this not tie to the trial balance?", null);
JsonNode est = call("POST", "/estimate", body, null);
System.out.println("reserved (hold_credits): " + est.path("hold_credits").asLong()
+ ", floor (min_credits): " + est.path("min_credits").asLong());
def build_body(question = "", retry_note = nil)
body = {
task: "review",
facts: File.read("facts.json").strip, # already JSON text: a STRING, not a Hash
account: "2140 Accrued expenses",
entity: "Velmarsk Components GmbH",
period: "June 2026",
question: question[0, 600],
}
body[:retry_note] = retry_note if retry_note
body.to_json
end
body = build_body("Why does this not tie to the trial balance?")
est = call("POST", "/estimate", body)
puts "reserved (hold_credits): #{est['hold_credits']}, floor (min_credits): #{est['min_credits']}"
<?php
function build_body(string $question = "", ?string $retryNote = null): string {
$body = [
"task" => "review",
"facts" => trim(file_get_contents("facts.json")), // already JSON text: a STRING
"account" => "2140 Accrued expenses",
"entity" => "Velmarsk Components GmbH",
"period" => "June 2026",
"question" => mb_substr($question, 0, 600),
];
if ($retryNote !== null) $body["retry_note"] = $retryNote;
return json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
$body = build_body("Why does this not tie to the trial balance?");
$est = call("POST", "/estimate", $body);
echo "reserved (hold_credits): {$est['hold_credits']}, floor (min_credits): {$est['min_credits']}", PHP_EOL;
static string BuildBody(string question, string? retryNote = null)
{
var body = new JsonObject
{
["task"] = "review",
["facts"] = File.ReadAllText("facts.json").Trim(), // already JSON text: a STRING
["account"] = "2140 Accrued expenses",
["entity"] = "Velmarsk Components GmbH",
["period"] = "June 2026",
["question"] = question.Length > 600 ? question[..600] : question,
};
if (retryNote is not null) body["retry_note"] = retryNote;
return body.ToJsonString();
}
var body = BuildBody("Why does this not tie to the trial balance?");
var est = await Api.Call(HttpMethod.Post, "/estimate", body);
Console.WriteLine($"reserved (hold_credits): {est?["hold_credits"]}, floor (min_credits): {est?["min_credits"]}");
5. Run it, then poll the job
POST /run with the same body starts the review and returns {"job_id": "..."}
straight away. Then poll GET /jobs/{job_id} until status is
succeeded or failed. The SDK polls once a second and gives up after 180
seconds; do the same. A finished job carries the model's reply as text in
output.output, the settled charged_credits, truncated and,
on failure, error.
Send an Idempotency-Key header on every run. It is the header the SDK sets on
/run and /run-stream. Derive it from the body and an attempt number, for
example rollforward-desk:review:<first 16 hex of sha256(body)>:a1. Then a retry
after a timeout or a dropped connection reuses the key and is recognised as the same request, not
billed as a new one. Change the attempt suffix only when you mean a new run, for example
a resend with a retry_note.
KEY="rollforward-desk:review:$(shasum -a 256 body.json | cut -c1-16):a1"
JOB=$(api POST /run body.json -H "Idempotency-Key: $KEY" | jq -r '.data.job_id')
echo "job $JOB"
for i in $(seq 1 180); do
api GET "/jobs/$JOB" > job.json
STATUS=$(jq -r '.data.status' job.json)
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 1
done
jq '.data | {status, charged_credits, truncated, error}' job.json
jq -r '.data.output.output' job.json > reply.txt # the model's reply (one JSON object as text)
import hashlib, time, urllib.parse
def idem_key(body, attempt=1):
return f"rollforward-desk:review:{hashlib.sha256(body.encode()).hexdigest()[:16]}:a{attempt}"
def wait_job(job_id, interval=1.0, timeout=180.0):
deadline = time.monotonic() + timeout
while True:
job = call("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
return job
if time.monotonic() > deadline:
raise TimeoutError(f"job {job_id} timed out")
time.sleep(interval)
def reply_text(job):
out = job.get("output")
return out.get("output") if isinstance(out, dict) else out
def run(body, attempt=1):
started = call("POST", "/run", body, {"Idempotency-Key": idem_key(body, attempt)})
return wait_job(started["job_id"])
job = run(body)
print(job["status"], "charged:", job.get("charged_credits"), "truncated:", job.get("truncated"))
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = reply_text(job)
async function idemKey(body, attempt = 1) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
const hex = [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
return `rollforward-desk:review:${hex.slice(0, 16)}:a${attempt}`;
}
async function waitJob(jobId, { intervalMs = 1000, timeoutMs = 180000 } = {}) {
const start = Date.now();
for (;;) {
const job = await call("GET", "/jobs/" + encodeURIComponent(jobId));
if (job.status === "succeeded" || job.status === "failed") return job;
if (Date.now() - start > timeoutMs) throw new Error(`job ${jobId} timed out`);
await new Promise((r) => setTimeout(r, intervalMs));
}
}
const replyText = (job) => (job.output && typeof job.output === "object" ? job.output.output : job.output);
async function run(body, attempt = 1) {
const { job_id } = await call("POST", "/run", body, { "Idempotency-Key": await idemKey(body, attempt) });
return waitJob(job_id);
}
const job = await run(body);
console.log(job.status, "charged:", job.charged_credits, "truncated:", job.truncated === true);
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = replyText(job);
// add imports: "crypto/sha256", "encoding/hex", "net/url", "time"
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
ChargedCredits float64 `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output json.RawMessage `json:"output"`
Error json.RawMessage `json:"error"`
}
// ReplyText returns output.output (or output itself when it is a plain string).
func (j *Job) ReplyText() string {
var wrapped struct {
Output string `json:"output"`
}
if json.Unmarshal(j.Output, &wrapped) == nil && wrapped.Output != "" {
return wrapped.Output
}
var s string
_ = json.Unmarshal(j.Output, &s)
return s
}
func idemKey(body []byte, attempt int) string {
sum := sha256.Sum256(body)
return fmt.Sprintf("rollforward-desk:review:%s:a%d", hex.EncodeToString(sum[:])[:16], attempt)
}
func waitJob(jobID string) (*Job, error) {
deadline := time.Now().Add(180 * time.Second)
for {
data, err := call("GET", "/jobs/"+url.PathEscape(jobID), nil, nil)
if err != nil {
return nil, err
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
return nil, err
}
if job.Status == "succeeded" || job.Status == "failed" {
return &job, nil
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("job %s timed out", jobID)
}
time.Sleep(time.Second)
}
}
func run(body []byte, attempt int) (*Job, error) {
data, err := call("POST", "/run", body, map[string]string{"Idempotency-Key": idemKey(body, attempt)})
if err != nil {
return nil, err
}
var started struct {
JobID string `json:"job_id"`
}
if err := json.Unmarshal(data, &started); err != nil {
return nil, err
}
return waitJob(started.JobID)
}
// add imports: java.net.URLEncoder, java.nio.charset.StandardCharsets,
// java.security.MessageDigest, java.util.HexFormat
static String idemKey(String body, int attempt) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8));
return "rollforward-desk:review:" + HexFormat.of().formatHex(d).substring(0, 16) + ":a" + attempt;
}
static JsonNode waitJob(String jobId) throws Exception {
long deadline = System.currentTimeMillis() + 180_000;
while (true) {
JsonNode job = call("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String st = job.path("status").asText();
if (st.equals("succeeded") || st.equals("failed")) return job;
if (System.currentTimeMillis() > deadline) throw new Exception("job " + jobId + " timed out");
Thread.sleep(1000);
}
}
static String replyText(JsonNode job) {
JsonNode out = job.path("output");
return out.isObject() ? out.path("output").asText(null) : out.asText(null);
}
static JsonNode run(String body, int attempt) throws Exception {
JsonNode started = call("POST", "/run", body, Map.of("Idempotency-Key", idemKey(body, attempt)));
return waitJob(started.path("job_id").asText());
}
JsonNode job = run(body, 1);
System.out.println(job.path("status").asText() + " charged: " + job.path("charged_credits").asLong()
+ " truncated: " + job.path("truncated").asBoolean(false));
String text = replyText(job);
require "digest"
require "erb"
def idem_key(body, attempt = 1)
"rollforward-desk:review:#{Digest::SHA256.hexdigest(body)[0, 16]}:a#{attempt}"
end
def wait_job(job_id, interval: 1, timeout: 180)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
job = call("GET", "/jobs/#{ERB::Util.url_encode(job_id)}")
return job if %w[succeeded failed].include?(job["status"])
raise "job #{job_id} timed out" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep interval
end
end
def reply_text(job)
out = job["output"]
out.is_a?(Hash) ? out["output"] : out
end
def run(body, attempt = 1)
started = call("POST", "/run", body, "Idempotency-Key" => idem_key(body, attempt))
wait_job(started["job_id"])
end
job = run(body)
puts "#{job['status']} charged: #{job['charged_credits']} truncated: #{job['truncated'] == true}"
raise "review failed: #{job['error']}" if job["status"] == "failed"
text = reply_text(job)
<?php
function idem_key(string $body, int $attempt = 1): string {
return "rollforward-desk:review:" . substr(hash("sha256", $body), 0, 16) . ":a$attempt";
}
function wait_job(string $jobId, int $timeout = 180): array {
$deadline = time() + $timeout;
while (true) {
$job = call("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job["status"], ["succeeded", "failed"], true)) return $job;
if (time() > $deadline) throw new RuntimeException("job $jobId timed out");
sleep(1);
}
}
function reply_text(array $job): ?string {
$out = $job["output"] ?? null;
return is_array($out) ? ($out["output"] ?? null) : $out;
}
function run(string $body, int $attempt = 1): array {
$started = call("POST", "/run", $body, ["Idempotency-Key" => idem_key($body, $attempt)]);
return wait_job($started["job_id"]);
}
$job = run($body);
echo $job["status"], " charged: ", $job["charged_credits"] ?? "", " truncated: ",
!empty($job["truncated"]) ? "yes" : "no", PHP_EOL;
if ($job["status"] === "failed") throw new RuntimeException(json_encode($job["error"] ?? null));
$text = reply_text($job);
static string IdemKey(string body, int attempt = 1)
{
var hex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
return $"rollforward-desk:review:{hex[..16]}:a{attempt}";
}
static async Task<JsonNode> WaitJob(string jobId)
{
var deadline = DateTime.UtcNow.AddSeconds(180);
while (true)
{
var job = await Api.Call(HttpMethod.Get, "/jobs/" + Uri.EscapeDataString(jobId))
?? throw new InvalidOperationException("empty job");
if ((string?)job["status"] is "succeeded" or "failed") return job;
if (DateTime.UtcNow > deadline) throw new TimeoutException($"job {jobId} timed out");
await Task.Delay(1000);
}
}
static string? ReplyText(JsonNode job) =>
job["output"] is JsonObject o ? (string?)o["output"] : (string?)job["output"];
static async Task<JsonNode> Run(string body, int attempt = 1)
{
var started = await Api.Call(HttpMethod.Post, "/run", body,
new Dictionary<string, string> { ["Idempotency-Key"] = IdemKey(body, attempt) });
return await WaitJob((string)started!["job_id"]!);
}
var job = await Run(body);
Console.WriteLine($"{job["status"]} charged: {job["charged_credits"]} truncated: {job["truncated"]}");
var text = ReplyText(job);
6. Or stream it
POST /run-stream takes the same body and the same Idempotency-Key header,
and answers with Server-Sent Events. Frames are separated by a blank line, and each has an
event: line and a JSON data: line. The events the SDK handles:
delta:{"text": "..."}, the next piece of the reply.job: the job record, carryingjob_id.done: the final payload{job_id, status, charged_credits, output}.pending: the stream closed before the job finished. Keep itsjob_idand poll/jobs/{job_id}as in step 5.error:{code, message, job_id}. The run failed.
Ignore any other event name, such as heartbeats. A page in a browser has been seen to receive
heartbeats and a single done but no delta frames, so never rely on deltas
to assemble the reply: take it from done, or from the job. Also, when the response
Content-Type is not text/event-stream, it is a plain {data}
/ {error} envelope. That is what an idempotent replay of an already-run key returns.
KEY="rollforward-desk:review:$(shasum -a 256 body.json | cut -c1-16):a1"
# -N turns off buffering so frames print as they arrive; tee keeps a copy.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data-binary @body.json | tee stream.txt
# The final payload is the data line after "event: done" (or "event: pending": then poll the job).
awk '/^event: (done|pending)/ { getline; sub(/^data: ?/, ""); print }' stream.txt | jq '{job_id, status, charged_credits}'
def run_stream(body, attempt=1, on_text=lambda t: print(t, end="", flush=True)):
req = urllib.request.Request(BASE + "/run-stream", data=body.encode("utf-8"), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", idem_key(body, attempt))
try:
r = urllib.request.urlopen(req, timeout=300)
except urllib.error.HTTPError as e:
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
raise ApiError(e.code, err.get("code"), err.get("message") or e.reason) from None
result, last = None, None
with r:
if "text/event-stream" not in r.headers.get("Content-Type", ""):
return json.load(r).get("data") # idempotent replay: a plain envelope
event, data = "message", ""
for raw in r:
line = raw.decode("utf-8").rstrip("\r\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
elif line == "":
payload = None
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if payload is not None:
if event == "delta":
on_text(payload.get("text", ""))
elif event in ("done", "pending"):
result, last = payload, event
elif event == "error":
raise ApiError(None, payload.get("code"), payload.get("message"),
{"job_id": payload.get("job_id")})
event, data = "message", ""
if last == "pending":
return wait_job(result["job_id"])
return result
done = run_stream(body)
text = reply_text(done)
async function runStream(body, attempt = 1, onText = (t) => console.log(t)) {
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
"Idempotency-Key": await idemKey(body, attempt),
},
body,
});
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(json.error?.message || res.statusText), { status: res.status, code: json.error?.code });
return json.data; // idempotent replay: a plain envelope
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", result = null, last = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", data = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
let payload;
try { payload = JSON.parse(data); } catch { continue; }
if (event === "delta") onText(payload.text || "");
else if (event === "done" || event === "pending") { result = payload; last = event; }
else if (event === "error") throw Object.assign(new Error(payload.message || "job failed"), { code: payload.code, job_id: payload.job_id });
}
}
return last === "pending" ? waitJob(result.job_id) : result;
}
const done = await runStream(body);
const text = replyText(done);
// add import: "bufio"
// runStream returns the done payload (same shape as a Job), or the polled job after "pending".
func runStream(body []byte, attempt int, onText func(string)) (*Job, error) {
req, err := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(body, attempt))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var env struct {
Data *Job `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := env.Error
if e == nil {
e = &APIError{Message: res.Status}
}
e.Status = res.StatusCode
return nil, e
}
return env.Data, nil // idempotent replay: a plain envelope
}
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
event, data, last := "message", "", ""
var result Job
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "":
if data != "" {
switch event {
case "delta":
var d struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(data), &d) == nil {
onText(d.Text)
}
case "done", "pending":
if json.Unmarshal([]byte(data), &result) == nil {
last = event
}
case "error":
var e struct {
Code, Message string
JobID string `json:"job_id"`
}
_ = json.Unmarshal([]byte(data), &e)
return nil, fmt.Errorf("%s: %s (job %s)", e.Code, e.Message, e.JobID)
}
}
event, data = "message", ""
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if last == "pending" {
return waitJob(result.JobID)
}
return &result, nil
}
// add imports: java.util.Iterator, java.util.stream.Collectors
static JsonNode runStream(String body, int attempt) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + TOKEN)
.header("Idempotency-Key", idemKey(body, attempt))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<java.util.stream.Stream<String>> res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String ctype = res.headers().firstValue("content-type").orElse("");
if (!ctype.contains("text/event-stream")) { // idempotent replay or an error: a plain envelope
JsonNode env = JSON.readTree(res.body().collect(Collectors.joining("\n")));
if (res.statusCode() < 200 || res.statusCode() > 299) {
throw new ApiException(res.statusCode(), env.path("error").path("code").asText(null),
env.path("error").path("message").asText("HTTP " + res.statusCode()));
}
return env.path("data");
}
String event = "message", last = null;
StringBuilder data = new StringBuilder();
JsonNode result = null;
Iterator<String> lines = res.body().iterator();
while (lines.hasNext()) {
String line = lines.next();
if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
else if (line.isEmpty()) {
if (data.length() > 0) {
JsonNode p = JSON.readTree(data.toString());
switch (event) {
case "delta" -> System.out.print(p.path("text").asText(""));
case "done", "pending" -> { result = p; last = event; }
case "error" -> throw new Exception(p.path("code").asText() + ": " + p.path("message").asText()
+ " (job " + p.path("job_id").asText() + ")");
default -> { }
}
}
event = "message";
data.setLength(0);
}
}
if ("pending".equals(last)) return waitJob(result.path("job_id").asText());
return result;
}
JsonNode done = runStream(body, 1);
String text = replyText(done);
def run_stream(body, attempt = 1)
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = idem_key(body, attempt)
req.body = body
result = last = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["Content-Type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.read_body) rescue {})
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message) unless res.is_a?(Net::HTTPSuccess)
return env["data"] # idempotent replay: a plain envelope
end
buffer = +""
res.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
frame = buffer.slice!(0, idx + 2)
event = "message"
data = +""
frame.each_line(chomp: true) do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue next)
case event
when "delta" then print payload["text"].to_s
when "done", "pending" then result, last = payload, event
when "error" then raise "#{payload['code']}: #{payload['message']} (job #{payload['job_id']})"
end
end
end
end
end
last == "pending" ? wait_job(result["job_id"]) : result
end
done = run_stream(body)
text = reply_text(done)
<?php
function run_stream(string $body, int $attempt = 1): ?array {
$buffer = ""; $raw = ""; $ctype = ""; $result = null; $last = null; $failure = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . TOKEN,
"Idempotency-Key: " . idem_key($body, $attempt),
],
CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$ctype) {
if (stripos($h, "content-type:") === 0) $ctype = trim(substr($h, 13));
return strlen($h);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buffer, &$raw, &$ctype, &$result, &$last, &$failure) {
if (stripos($ctype, "text/event-stream") === false) { $raw .= $chunk; return strlen($chunk); }
$buffer .= $chunk;
while (($idx = strpos($buffer, "\n\n")) !== false) {
$frame = substr($buffer, 0, $idx);
$buffer = substr($buffer, $idx + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
elseif (str_starts_with($line, "data:")) $data .= trim(substr($line, 5));
}
$p = $data === "" ? null : json_decode($data, true);
if ($p === null) continue;
if ($event === "delta") echo $p["text"] ?? "";
elseif ($event === "done" || $event === "pending") { $result = $p; $last = $event; }
elseif ($event === "error") $failure = $p;
}
return strlen($chunk);
},
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw !== "") { // idempotent replay or an error: a plain envelope
$env = json_decode($raw, true) ?: [];
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env["error"]["code"] ?? null, $env["error"]["message"] ?? "HTTP $status");
}
return $env["data"] ?? null;
}
if ($failure) throw new RuntimeException(($failure["code"] ?? "") . ": " . ($failure["message"] ?? "job failed"));
return $last === "pending" ? wait_job($result["job_id"]) : $result;
}
$done = run_stream($body);
$text = reply_text($done);
static async Task<JsonNode?> RunStream(string body, int attempt = 1)
{
using var req = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Api.Token);
req.Headers.TryAddWithoutValidation("Idempotency-Key", IdemKey(body, attempt));
using var res = await Api.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
var env = JsonNode.Parse(await res.Content.ReadAsStringAsync()); // replay or error envelope
if (!res.IsSuccessStatusCode)
throw new ApiException((int)res.StatusCode, (string?)env?["error"]?["code"], (string?)env?["error"]?["message"]);
return env?["data"];
}
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string evt = "message", data = "";
string? last = null;
JsonNode? result = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:")) data += line[5..].Trim();
else if (line.Length == 0)
{
if (data.Length > 0)
{
var p = JsonNode.Parse(data);
switch (evt)
{
case "delta": Console.Write((string?)p?["text"]); break;
case "done" or "pending": result = p; last = evt; break;
case "error": throw new Exception($"{p?["code"]}: {p?["message"]} (job {p?["job_id"]})");
}
}
evt = "message"; data = "";
}
}
return last == "pending" ? await WaitJob((string)result!["job_id"]!) : result;
}
var done = await RunStream(body);
var text = ReplyText(done!);
7. Parse and check the reply
The reply text is meant to be exactly one JSON object. Parse it the way recon.js
does in parseResult: trim it, drop a stray code fence, take everything from the first
{ to the last } and JSON.parse that. Then hold it to the facts
you sent. The page never trusts the model's reading of the numbers; reconcile()
checks each of the points below and shows every disagreement.
Invariants worth asserting
- Verdict floor.
verdictis never looser thanfacts.verdict_floor, in the orderties<ties_with_exceptions<does_not_tie. - Verdict and gap.
verdictisdoes_not_tieexactly whenfacts.gap.statusis notties. It istiesonly when no exception is blocking. - Gap.
gap.statusequalsfacts.gap.status. When the status isexplained,gap.causeslists exactlyfacts.gap.causes; otherwise it is empty. - Exceptions. Every flag whose severity is
warnorblockis answered exactly once, and no exception names a flag that does not exist. Ablockflag is alwaysblocking: trueand aninfoflag never is. The warn codesduplicate,out_of_period,unreversed_accrual,unmatched_reversal,direction,skipped_rows,truncatedandclippedmust block, and so must any flag whose code is a gap cause.unclassifiedmust not block. - Unclassified rows. Every row in
facts.rowswith componentUgets exactly onereclass_suggestionsentry. A suggestion for any other row is allowed only for a row named by adirectionflag. An A or B suggestion needs a positive amount; a C or D suggestion needs a negative one. - Line notes. Each line from L2 to L8 whose
rowsis above 0 gets exactly one note. A line with no rows gets none. - Amounts only from the facts. Every amount written in the prose appears somewhere in
facts, within 0.01.recon.jsusesnumbersIn()andpools()to find amounts, and it skips dates, ids and journal references. EveryL,R,XorPid cited must exist in what was sent. - Never plug. Neither
gap.actionnor any exceptionactionproposes a plug, balancing entry or suspense entry. Userecon.js'splugs()check, which ignores a refusal such as "do not plug".
In JavaScript, load recon.js itself and call Recon.reconcile. The
other languages below port the structural checks; port numbersIn and
plugs too if you gate on them.
# reply.txt from step 5; facts.json is what you sent.
# First "{" to last "}", as recon.js does:
perl -0777 -ne 'print $1 if /(\{.*\})/s' reply.txt > review.json
jq -e . review.json > /dev/null || { echo "reply is not JSON"; exit 1; }
# Prints a list of problems; [] means every structural invariant holds.
jq -n --slurpfile r review.json --slurpfile f facts.json '
($r[0]) as $r | ($f[0]) as $f |
{"ties": 0, "ties_with_exceptions": 1, "does_not_tie": 2} as $rank |
[
(if ($rank[$r.verdict // ""] // -1) < $rank[$f.verdict_floor] then "verdict looser than the floor" else empty end),
(if $r.gap.status != $f.gap.status then "gap.status differs from the foot check" else empty end),
(if (($r.gap.causes // []) | sort) != (if $f.gap.status == "explained" then ($f.gap.causes | sort) else [] end)
then "gap.causes differ from the proven causes" else empty end),
(if ($r.verdict == "does_not_tie") != ($f.gap.status != "ties") then "verdict disagrees with the foot check" else empty end),
($f.flags[] | select(.severity == "warn" or .severity == "block") | .id as $id
| select([($r.exceptions // [])[] | select(.flag == $id)] | length != 1) | "flag \($id) not answered exactly once"),
($f.rows[] | select(.component == "U") | .id as $id
| select([($r.reclass_suggestions // [])[] | select(.row == $id)] | length != 1) | "row \($id) not placed exactly once"),
($f.lines[] | select((.id | test("^L[2-8]$")) and .rows > 0) | .id as $id
| select([($r.lines // [])[] | select(.line == $id)] | length != 1) | "line \($id) not noted exactly once")
]'
import re
RANK = {"ties": 0, "ties_with_exceptions": 1, "does_not_tie": 2}
MUST_BLOCK = {"duplicate", "out_of_period", "unreversed_accrual", "unmatched_reversal",
"direction", "skipped_rows", "truncated", "clipped"}
def parse_result(text):
t = (text or "").strip()
a, b = t.find("{"), t.rfind("}")
if a == -1 or b < a:
raise ValueError("no JSON object in the reply")
return json.loads(t[a:b + 1])
def check(reply, facts):
p = []
def once(label, want, got):
for i in want:
if got.count(i) != 1:
p.append(f"{label} {i} answered {got.count(i)} times, expected once")
verdict, floor = reply.get("verdict"), facts["verdict_floor"]
if RANK.get(verdict, -1) < RANK[floor]:
p.append(f"verdict {verdict!r} is looser than the floor {floor!r}")
fg, rg = facts["gap"], reply.get("gap") or {}
if rg.get("status") != fg["status"]:
p.append(f"gap.status {rg.get('status')!r}, the foot check says {fg['status']!r}")
want = sorted(fg["causes"]) if fg["status"] == "explained" else []
if sorted(rg.get("causes") or []) != want:
p.append(f"gap.causes {rg.get('causes')}, expected {want}")
if (verdict == "does_not_tie") != (fg["status"] != "ties"):
p.append("verdict disagrees with the foot check")
flags = {f["id"]: f for f in facts["flags"]}
exc = reply.get("exceptions") or []
once("flag", [i for i, f in flags.items() if f["severity"] in ("warn", "block")], [x.get("flag") for x in exc])
for x in exc:
f = flags.get(x.get("flag"))
if f is None:
p.append(f"exception names unknown flag {x.get('flag')}")
continue
if f["severity"] == "block":
must = True
elif f["severity"] == "info":
must = False
elif f["code"] in MUST_BLOCK or f["code"] in fg["causes"]:
must = True
elif f["code"] == "unclassified":
must = False
else:
must = None # a judgement call
if must is not None and (x.get("blocking") is True) != must:
p.append(f"{f['id']} ({f['code']}) blocking should be {must}")
if verdict == "ties" and any(x.get("blocking") is True for x in exc):
p.append("verdict ties with a blocking exception")
once("row", [r["id"] for r in facts["rows"] if r["component"] == "U"],
[s.get("row") for s in reply.get("reclass_suggestions") or []])
once("line", [l["id"] for l in facts["lines"] if re.fullmatch(r"L[2-8]", l["id"]) and l["rows"] > 0],
[n.get("line") for n in reply.get("lines") or []])
return p
facts = json.loads(json.loads(body)["facts"])
review = parse_result(text)
print(review["verdict"], "-", review["headline"])
for msg in check(review, facts):
print(" FAIL", msg)
// The page's own checker. `require` is the createRequire from step 4.
const Recon = require("./recon.js");
const facts = JSON.parse(input.facts);
const review = Recon.normalize(Recon.parseResult(text));
const report = Recon.reconcile(review, facts);
console.log(review.verdict, "-", review.headline);
for (const c of report.checks) console.log(c.ok ? " ok " : " FAIL", c.scope + ":", c.detail);
for (const e of report.exceptions) for (const c of e.checks) if (!c.ok) console.log(" FAIL", e.exc.flag, c.detail);
for (const s of report.suggestions) for (const c of s.checks) if (!c.ok) console.log(" FAIL", s.sug.row, c.detail);
if (report.disagreements) throw new Error(`${report.disagreements} disagreement(s) with the facts`);
// add imports: "errors", "regexp", "sort"
type Facts struct {
VerdictFloor string `json:"verdict_floor"`
Gap struct {
Status string `json:"status"`
Causes []string `json:"causes"`
} `json:"gap"`
Lines []struct {
ID string `json:"id"`
Rows int `json:"rows"`
} `json:"lines"`
Flags []struct{ ID, Code, Severity string } `json:"flags"`
Rows []struct{ ID, Component string } `json:"rows"`
}
type Review struct {
Lane, Verdict, Headline string
Lines []struct{ Line, Note string }
Gap struct {
Status string
Causes []string
Explanation, Action string
}
Exceptions []struct {
Flag, Explanation, Action, Owner string
Blocking bool
}
ReclassSuggestions []struct{ Row, Component, Reason string } `json:"reclass_suggestions"`
SupportRequests []string `json:"support_requests"`
FileNote string `json:"file_note"`
Summary string
}
var rank = map[string]int{"ties": 0, "ties_with_exceptions": 1, "does_not_tie": 2}
var mustBlock = map[string]bool{"duplicate": true, "out_of_period": true, "unreversed_accrual": true,
"unmatched_reversal": true, "direction": true, "skipped_rows": true, "truncated": true, "clipped": true}
var activityLine = regexp.MustCompile(`^L[2-8]$`)
func parseResult(text string) (*Review, error) {
t := strings.TrimSpace(text)
a, b := strings.Index(t, "{"), strings.LastIndex(t, "}")
if a < 0 || b < a {
return nil, errors.New("no JSON object in the reply")
}
var r Review
if err := json.Unmarshal([]byte(t[a:b+1]), &r); err != nil {
return nil, err
}
return &r, nil
}
func count(ids []string, id string) (n int) {
for _, x := range ids {
if x == id {
n++
}
}
return
}
func check(r *Review, f *Facts) []string {
var p []string
if v, ok := rank[r.Verdict]; !ok || v < rank[f.VerdictFloor] {
p = append(p, fmt.Sprintf("verdict %q is looser than the floor %q", r.Verdict, f.VerdictFloor))
}
if r.Gap.Status != f.Gap.Status {
p = append(p, fmt.Sprintf("gap.status %q, the foot check says %q", r.Gap.Status, f.Gap.Status))
}
want := []string{}
if f.Gap.Status == "explained" {
want = append(want, f.Gap.Causes...)
}
got := append([]string{}, r.Gap.Causes...)
sort.Strings(want)
sort.Strings(got)
if strings.Join(got, ",") != strings.Join(want, ",") {
p = append(p, fmt.Sprintf("gap.causes %v, expected %v", got, want))
}
if (r.Verdict == "does_not_tie") != (f.Gap.Status != "ties") {
p = append(p, "verdict disagrees with the foot check")
}
causes := map[string]bool{}
for _, c := range f.Gap.Causes {
causes[c] = true
}
var answered []string
anyBlocking := false
for _, x := range r.Exceptions {
answered = append(answered, x.Flag)
anyBlocking = anyBlocking || x.Blocking
}
for _, fl := range f.Flags {
if n := count(answered, fl.ID); (fl.Severity == "warn" || fl.Severity == "block") && n != 1 {
p = append(p, fmt.Sprintf("flag %s answered %d times, expected once", fl.ID, n))
}
for _, x := range r.Exceptions {
if x.Flag != fl.ID {
continue
}
wrong := false
switch {
case fl.Severity == "block":
wrong = !x.Blocking
case fl.Severity == "info":
wrong = x.Blocking
case mustBlock[fl.Code] || causes[fl.Code]:
wrong = !x.Blocking
case fl.Code == "unclassified":
wrong = x.Blocking
}
if wrong {
p = append(p, fmt.Sprintf("%s (%s) has the wrong blocking value", fl.ID, fl.Code))
}
}
}
if r.Verdict == "ties" && anyBlocking {
p = append(p, "verdict ties with a blocking exception")
}
var placed, noted []string
for _, s := range r.ReclassSuggestions {
placed = append(placed, s.Row)
}
for _, row := range f.Rows {
if n := count(placed, row.ID); row.Component == "U" && n != 1 {
p = append(p, fmt.Sprintf("row %s placed %d times, expected once", row.ID, n))
}
}
for _, l := range r.Lines {
noted = append(noted, l.Line)
}
for _, l := range f.Lines {
if n := count(noted, l.ID); activityLine.MatchString(l.ID) && l.Rows > 0 && n != 1 {
p = append(p, fmt.Sprintf("line %s noted %d times, expected once", l.ID, n))
}
}
return p
}
// add imports: java.util.*
static final Map<String, Integer> RANK = Map.of("ties", 0, "ties_with_exceptions", 1, "does_not_tie", 2);
static final Set<String> MUST_BLOCK = Set.of("duplicate", "out_of_period", "unreversed_accrual",
"unmatched_reversal", "direction", "skipped_rows", "truncated", "clipped");
static JsonNode parseResult(String text) throws Exception {
String t = text == null ? "" : text.strip();
int a = t.indexOf('{'), b = t.lastIndexOf('}');
if (a < 0 || b < a) throw new Exception("no JSON object in the reply");
return JSON.readTree(t.substring(a, b + 1));
}
static List<String> texts(JsonNode arr, String field) {
List<String> out = new ArrayList<>();
arr.forEach(n -> out.add(field == null ? n.asText() : n.path(field).asText()));
return out;
}
static List<String> check(JsonNode r, JsonNode f) {
List<String> p = new ArrayList<>();
String verdict = r.path("verdict").asText(), floor = f.path("verdict_floor").asText();
if (RANK.getOrDefault(verdict, -1) < RANK.get(floor)) p.add("verdict " + verdict + " is looser than the floor " + floor);
JsonNode fg = f.path("gap"), rg = r.path("gap");
String status = fg.path("status").asText();
if (!rg.path("status").asText().equals(status)) p.add("gap.status differs from the foot check");
List<String> want = status.equals("explained") ? texts(fg.path("causes"), null) : new ArrayList<>();
List<String> got = texts(rg.path("causes"), null);
Collections.sort(want);
Collections.sort(got);
if (!want.equals(got)) p.add("gap.causes " + got + ", expected " + want);
if (verdict.equals("does_not_tie") != !status.equals("ties")) p.add("verdict disagrees with the foot check");
Set<String> causes = new HashSet<>(texts(fg.path("causes"), null));
List<String> answered = texts(r.path("exceptions"), "flag");
for (JsonNode fl : f.path("flags")) {
String id = fl.path("id").asText(), sev = fl.path("severity").asText(), code = fl.path("code").asText();
int n = Collections.frequency(answered, id);
if ((sev.equals("warn") || sev.equals("block")) && n != 1) p.add("flag " + id + " answered " + n + " times");
for (JsonNode x : r.path("exceptions")) {
if (!x.path("flag").asText().equals(id)) continue;
boolean blocking = x.path("blocking").asBoolean(false);
Boolean must = sev.equals("block") ? Boolean.TRUE
: sev.equals("info") ? Boolean.FALSE
: (MUST_BLOCK.contains(code) || causes.contains(code)) ? Boolean.TRUE
: code.equals("unclassified") ? Boolean.FALSE : null;
if (must != null && must.booleanValue() != blocking) p.add(id + " (" + code + ") blocking should be " + must);
}
}
boolean anyBlocking = false;
for (JsonNode x : r.path("exceptions")) anyBlocking |= x.path("blocking").asBoolean(false);
if (verdict.equals("ties") && anyBlocking) p.add("verdict ties with a blocking exception");
List<String> placed = texts(r.path("reclass_suggestions"), "row");
for (JsonNode row : f.path("rows")) {
String id = row.path("id").asText();
if (row.path("component").asText().equals("U") && Collections.frequency(placed, id) != 1)
p.add("row " + id + " not placed exactly once");
}
List<String> noted = texts(r.path("lines"), "line");
for (JsonNode l : f.path("lines")) {
String id = l.path("id").asText();
if (id.matches("L[2-8]") && l.path("rows").asInt() > 0 && Collections.frequency(noted, id) != 1)
p.add("line " + id + " not noted exactly once");
}
return p;
}
JsonNode facts = JSON.readTree(JSON.readTree(body).path("facts").asText());
JsonNode review = parseResult(text);
System.out.println(review.path("verdict").asText() + " - " + review.path("headline").asText());
check(review, facts).forEach(msg -> System.out.println(" FAIL " + msg));
RANK = { "ties" => 0, "ties_with_exceptions" => 1, "does_not_tie" => 2 }.freeze
MUST_BLOCK = %w[duplicate out_of_period unreversed_accrual unmatched_reversal
direction skipped_rows truncated clipped].freeze
def parse_result(text)
t = text.to_s.strip
a = t.index("{")
b = t.rindex("}")
raise "no JSON object in the reply" if a.nil? || b.nil? || b < a
JSON.parse(t[a..b])
end
def check(r, f)
problems = []
once = lambda do |label, want, got|
want.each do |id|
n = got.count(id)
problems << "#{label} #{id} answered #{n} times, expected once" unless n == 1
end
end
verdict = r["verdict"]
floor = f["verdict_floor"]
problems << "verdict #{verdict} is looser than the floor #{floor}" if RANK.fetch(verdict, -1) < RANK[floor]
fg = f["gap"]
rg = r["gap"] || {}
problems << "gap.status #{rg['status']}, the foot check says #{fg['status']}" if rg["status"] != fg["status"]
want = fg["status"] == "explained" ? fg["causes"].sort : []
problems << "gap.causes #{rg['causes']}, expected #{want}" if (rg["causes"] || []).sort != want
problems << "verdict disagrees with the foot check" if (verdict == "does_not_tie") != (fg["status"] != "ties")
exc = r["exceptions"] || []
once.call("flag", f["flags"].select { |x| %w[warn block].include?(x["severity"]) }.map { |x| x["id"] },
exc.map { |x| x["flag"] })
flags = f["flags"].to_h { |x| [x["id"], x] }
exc.each do |x|
fl = flags[x["flag"]]
if fl.nil?
problems << "exception names unknown flag #{x['flag']}"
next
end
must = if fl["severity"] == "block" then true
elsif fl["severity"] == "info" then false
elsif MUST_BLOCK.include?(fl["code"]) || fg["causes"].include?(fl["code"]) then true
elsif fl["code"] == "unclassified" then false
end
problems << "#{fl['id']} (#{fl['code']}) blocking should be #{must}" if !must.nil? && (x["blocking"] == true) != must
end
problems << "verdict ties with a blocking exception" if verdict == "ties" && exc.any? { |x| x["blocking"] == true }
once.call("row", f["rows"].select { |x| x["component"] == "U" }.map { |x| x["id"] },
(r["reclass_suggestions"] || []).map { |s| s["row"] })
once.call("line", f["lines"].select { |l| l["id"].match?(/\AL[2-8]\z/) && l["rows"] > 0 }.map { |l| l["id"] },
(r["lines"] || []).map { |n| n["line"] })
problems
end
facts = JSON.parse(JSON.parse(body)["facts"])
review = parse_result(text)
puts "#{review['verdict']} - #{review['headline']}"
check(review, facts).each { |msg| puts " FAIL #{msg}" }
<?php
const RANK = ["ties" => 0, "ties_with_exceptions" => 1, "does_not_tie" => 2];
const MUST_BLOCK = ["duplicate", "out_of_period", "unreversed_accrual", "unmatched_reversal",
"direction", "skipped_rows", "truncated", "clipped"];
function parse_result(string $text): array {
$t = trim($text);
$a = strpos($t, "{");
$b = strrpos($t, "}");
if ($a === false || $b === false || $b < $a) throw new RuntimeException("no JSON object in the reply");
return json_decode(substr($t, $a, $b - $a + 1), true, 512, JSON_THROW_ON_ERROR);
}
function check(array $r, array $f): array {
$p = [];
$once = function (string $label, array $want, array $got) use (&$p) {
$counts = array_count_values(array_filter($got, "is_string"));
foreach ($want as $id) {
$n = $counts[$id] ?? 0;
if ($n !== 1) $p[] = "$label $id answered $n times, expected once";
}
};
$verdict = $r["verdict"] ?? "";
$floor = $f["verdict_floor"];
if ((RANK[$verdict] ?? -1) < RANK[$floor]) $p[] = "verdict $verdict is looser than the floor $floor";
$fg = $f["gap"];
$rg = $r["gap"] ?? [];
if (($rg["status"] ?? null) !== $fg["status"]) $p[] = "gap.status differs from the foot check";
$want = $fg["status"] === "explained" ? $fg["causes"] : [];
$got = $rg["causes"] ?? [];
sort($want);
sort($got);
if ($want !== $got) $p[] = "gap.causes differ from the proven causes";
if (($verdict === "does_not_tie") !== ($fg["status"] !== "ties")) $p[] = "verdict disagrees with the foot check";
$exc = $r["exceptions"] ?? [];
$need = array_column(array_filter($f["flags"], fn($x) => in_array($x["severity"], ["warn", "block"], true)), "id");
$once("flag", $need, array_column($exc, "flag"));
$flags = array_column($f["flags"], null, "id");
foreach ($exc as $x) {
$fl = $flags[$x["flag"] ?? ""] ?? null;
if ($fl === null) { $p[] = "exception names unknown flag " . ($x["flag"] ?? "?"); continue; }
$must = match (true) {
$fl["severity"] === "block" => true,
$fl["severity"] === "info" => false,
in_array($fl["code"], MUST_BLOCK, true) || in_array($fl["code"], $fg["causes"], true) => true,
$fl["code"] === "unclassified" => false,
default => null,
};
if ($must !== null && (($x["blocking"] ?? false) === true) !== $must) {
$p[] = "{$fl['id']} ({$fl['code']}) has the wrong blocking value";
}
}
if ($verdict === "ties" && in_array(true, array_column($exc, "blocking"), true)) $p[] = "verdict ties with a blocking exception";
$u = array_column(array_filter($f["rows"], fn($x) => $x["component"] === "U"), "id");
$once("row", $u, array_column($r["reclass_suggestions"] ?? [], "row"));
$l = array_column(array_filter($f["lines"], fn($x) => preg_match('/^L[2-8]$/', $x["id"]) && $x["rows"] > 0), "id");
$once("line", $l, array_column($r["lines"] ?? [], "line"));
return $p;
}
$facts = json_decode(json_decode($body, true)["facts"], true);
$review = parse_result($text);
echo $review["verdict"], " - ", $review["headline"], PHP_EOL;
foreach (check($review, $facts) as $msg) echo " FAIL $msg", PHP_EOL;
static JsonNode ParseResult(string text)
{
var t = (text ?? "").Trim();
int a = t.IndexOf('{'), b = t.LastIndexOf('}');
if (a < 0 || b < a) throw new FormatException("no JSON object in the reply");
return JsonNode.Parse(t[a..(b + 1)])!;
}
static List<string> Check(JsonNode r, JsonNode f)
{
var rank = new Dictionary<string, int> { ["ties"] = 0, ["ties_with_exceptions"] = 1, ["does_not_tie"] = 2 };
var mustBlock = new HashSet<string> { "duplicate", "out_of_period", "unreversed_accrual",
"unmatched_reversal", "direction", "skipped_rows", "truncated", "clipped" };
var p = new List<string>();
static List<string> Ids(JsonNode? arr, string field) =>
arr?.AsArray().Select(n => (string?)n?[field] ?? "").ToList() ?? new List<string>();
void Once(string label, IEnumerable<string> want, List<string> got)
{
foreach (var id in want)
{
var n = got.Count(g => g == id);
if (n != 1) p.Add($"{label} {id} answered {n} times, expected once");
}
}
static bool IsTrue(JsonNode? v) => v is not null && v.GetValueKind() == JsonValueKind.True;
var verdict = (string?)r["verdict"] ?? "";
var floor = (string)f["verdict_floor"]!;
if (rank.GetValueOrDefault(verdict, -1) < rank[floor]) p.Add($"verdict {verdict} is looser than the floor {floor}");
var fg = f["gap"]!;
var rg = r["gap"];
var status = (string)fg["status"]!;
if ((string?)rg?["status"] != status) p.Add("gap.status differs from the foot check");
var fCauses = fg["causes"]!.AsArray().Select(c => (string)c!).ToList();
var want = status == "explained" ? fCauses.OrderBy(c => c).ToList() : new List<string>();
var got = rg?["causes"]?.AsArray().Select(c => (string)c!).OrderBy(c => c).ToList() ?? new List<string>();
if (!want.SequenceEqual(got)) p.Add("gap.causes differ from the proven causes");
if ((verdict == "does_not_tie") != (status != "ties")) p.Add("verdict disagrees with the foot check");
var flags = f["flags"]!.AsArray().ToDictionary(x => (string)x!["id"]!, x => x!);
var exc = r["exceptions"]?.AsArray() ?? new JsonArray();
var needX = flags.Values.Where(x => (string?)x["severity"] is "warn" or "block").Select(x => (string)x["id"]!);
Once("flag", needX, Ids(exc, "flag"));
foreach (var x in exc)
{
var fid = (string?)x?["flag"] ?? "";
if (!flags.TryGetValue(fid, out var fl)) { p.Add("exception names unknown flag " + fid); continue; }
string sev = (string)fl["severity"]!, code = (string)fl["code"]!;
bool? must = sev == "block" ? true
: sev == "info" ? false
: mustBlock.Contains(code) || fCauses.Contains(code) ? true
: code == "unclassified" ? false : null;
if (must is bool m && m != IsTrue(x?["blocking"])) p.Add(fid + " (" + code + ") blocking should be " + m);
}
if (verdict == "ties" && exc.Any(x => IsTrue(x?["blocking"]))) p.Add("verdict ties with a blocking exception");
var needU = f["rows"]!.AsArray().Where(x => (string?)x!["component"] == "U").Select(x => (string)x!["id"]!);
Once("row", needU, Ids(r["reclass_suggestions"], "row"));
var needL = f["lines"]!.AsArray()
.Where(l => Regex.IsMatch((string)l!["id"]!, "^L[2-8]$") && (int)l!["rows"]! > 0)
.Select(l => (string)l!["id"]!);
Once("line", needL, Ids(r["lines"], "line"));
return p;
}
var facts = JsonNode.Parse((string)JsonNode.Parse(body)!["facts"]!)!;
var review = ParseResult(text!);
Console.WriteLine("" + review["verdict"] + " - " + review["headline"]);
foreach (var msg in Check(review, facts)) Console.WriteLine(" FAIL " + msg);
The output contract
The reply is one JSON object and nothing else: no prose around it, no code fences and no Markdown
inside strings. Arrays with nothing in them are empty arrays, never omitted and never
null. This is its shape, taken from the app's system prompt. It is an
illustration of the fields, not real model output:
{
"lane": "review",
"verdict": "ties | ties_with_exceptions | does_not_tie",
"headline": "one sentence: can this schedule go into the close package, and why",
"lines": [ { "line": "L3", "note": "..." } ],
"gap": { "status": "ties | explained | unexplained | cannot_foot", "causes": [], "explanation": "...", "action": "" },
"exceptions": [ { "flag": "X2", "explanation": "...", "action": "...", "owner": "gl_accounting", "blocking": true } ],
"reclass_suggestions": [ { "row": "R3", "component": "U", "reason": "..." } ],
"support_requests": [ "..." ],
"file_note": "3-6 sentences for the close package or the audit file",
"summary": "2-3 sentences for the reviewer who reads nothing else"
}
| field | values and rules |
|---|---|
lane | Always "review". |
verdict | ties, ties_with_exceptions, does_not_tie. does_not_tie whenever the gap does not tie; ties only when the gap ties and nothing blocks; never looser than verdict_floor. |
headline | One sentence. Answers question when one was sent (or summary does). |
lines[] | line (L2 to L8, only lines with rows) and note (at most 45 words, citing the line's ties_to). |
gap.status | ties, explained, unexplained, cannot_foot. Must equal facts.gap.status. |
gap.causes | A subset of bb_mismatch, out_of_period, duplicate: exactly facts.gap.causes when explained, otherwise empty. |
gap.explanation / gap.action | At most 70 and 40 words. The action is an empty string when the status is ties. Never a plug. |
exceptions[] | flag (an X id), explanation (at most 70 words), action (at most 40), owner, blocking (boolean). |
owner | preparer (how the schedule was built: the paste, a missing balance, an unclassified row), gl_accounting (journals, accruals, reversals, reclasses, cut-off), ap_ar (sub-ledger invoices, billings, credit memos, payments), treasury (cash settlements and FX), controller (sign-off, estimates, policy). |
reclass_suggestions[] | row (an R id that was sent), component, reason (at most 40 words). |
component | A additions, B accruals booked, C reversals of prior accruals, D payments / settlements, E reclasses / adjustments, F FX translation, U unclassified. A and B take a positive amount, C and D a negative one, E and F either sign. U means the memo and source do not show where the row belongs. |
support_requests[] | One string per open point, at most 45 words, naming the ids and the figure. May be empty when the verdict is ties. |
file_note / summary | 3 to 6 sentences for the audit file; 2 to 3 sentences for the reviewer. |
Every amount in the prose is copied from facts, written with thousands separators and
two decimals (7,500.00). A negative is written with a minus sign, in parentheses, or
as a plain size when the sentence gives the direction.
Truncation and partial results
/estimate gives two numbers that matter here. A run whose balance is below
min_credits is refused (402). A run whose balance is between min_credits
and hold_credits is not refused. It runs with the output cap scaled down to
what the balance affords. The job still reports status: "succeeded", but with
truncated: true. The reply you then hold is a prefix. The line notes and exceptions
may be complete while file_note and summary are missing, or the JSON may
stop mid-string.
Always check truncated before you treat a reply as complete. To show what did arrive,
do what the page does: Recon.closeJson(text) closes an open string and any open
brackets, then normalize reads whichever sections are present. Do not file a
truncated review. Top up, then resend with a new attempt suffix on the
Idempotency-Key. If the reply was cut short or could not be parsed, you can add a
retry_note asking for a shorter reply.