Drive Tax Prep Desk from your own code
Prep material for an accountant, not tax advice. Nothing is ever filed. US federal
only; tax years 2025 and 2026 only. The app never sends the whole
ledger: only the totals, the flagged rows and the payees at or near the 1099 threshold travel in
pack. Never put an SSN, a tax id, or a bank or card number in question -
it is sent as you type it.
Everything the web page does is available over HTTP. Send a small business's ledger facts, as the
browser computes them, and get the same packet back: a books check that says whether the ledger is
fit to compute a tax number from (ready, proceed_with_assumptions,
close_first) with the open items and the gate statement; a 1099 prep list with one
candidate per payee at or near the threshold, the suggested form, W-9 requests and duplicate-payee
reviews; or the quarterly estimated tax summary with the next payment, its due date and a catch-up
line for every missed quarter. The natural use is a month-end job: export the ledger, run the books
check, then file the 1099 list or the estimate summary in the folder your accountant reads.
The model never does the arithmetic. Payee totals against the year's threshold,
W-9 gaps, likely duplicates, the uncategorized swing, the annualised net, self-employment tax, the
federal figure at the assumed rate, missed quarters and the next installment are all worked out by
taxkit.js, the same file the web page loads, and sent as pack, a JSON
string. See building the body.
Three lanes: the task field
Every request names its lane in task, and one system prompt routes on it.
| task | what it does | what it needs in pack |
|---|---|---|
ready | The books check (tax-prep): verdict, headline, summary, one open_items entry per high or medium flag (ref, issue, effect, fix), gate_statement, basis_statement, mode and mode_reason, plus the shared keys. | readiness (the browser's verdict_hint and counts) and rows (the problem rows only, at most 40). |
nec | The 1099 prep list (tax-season-organizer, path 2): one candidates entry per payee at or near the threshold (form, total, w9, note), excluded, w9_requests, duplicate_reviews, corporate_reviews, processor_note, deadline_note, plus the shared keys. | nec (threshold, near floor, due date, id lists), payees (at or near the threshold, then the largest exempt ones, at most 60) and duplicates. |
quarterly | The estimated tax summary (tax-season-organizer, path 1): quarter, payment_due, due_date, one catch_up line per missed quarter, se_text, federal_text, safe_harbor_text, basis_statement, plus the shared keys. | estimate (every step of the calculation, or blocked with the reason). |
Shared keys in every reply: lane, headline (names the tax year),
summary (answers question when there is one), assumptions,
accountant_checklist and one prescan_responses entry per prescan flag.
The lanes chain: the ready check's mode says which packet comes next
(quarterly, nec or both), and its verdict, gate statement and
open items can travel into nec or quarterly as the optional
readiness field. See handing the books check on. A missing or
unknown task is answered as the closest lane and the reply's lane says
which. Always send task.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "...", "status": 402, "details": {}}}
The token is minted for this app (the guest endpoint takes {"slug":"tax-prep-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….
The input object IS the request body. There is no {"input": …}
wrapper. A wrapped body returns a 200 with an unknown field 'input' warning, and the
model never sees your ledger facts.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A field is the wrong type. Every field is a string: tax_year is "2026", not 2026, and pack and readiness must be JSON-encoded strings, not objects. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
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. A guest token can call /me and
/estimate; all three lanes are metered, so a run needs a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://tax-prep-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; all three lanes need a personal token
# from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"tax-prep-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://tax-prep-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "tax-prep-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://tax-prep-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "tax-prep-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://tax-prep-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"tax-prep-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://tax-prep-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"tax-prep-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://tax-prep-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "tax-prep-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://tax-prep-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "tax-prep-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://tax-prep-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"tax-prep-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
2. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="tax-prep-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://tax-prep-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "tax-prep-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://tax-prep-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "tax-prep-desk";
const TOKEN = "YOUR_TOKEN"; // from https://tax-prep-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "tax-prep-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://tax-prep-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class TaxPrepDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "tax-prep-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "tax-prep-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://tax-prep-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "tax-prep-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class TaxPrepDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "tax-prep-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await TaxPrepDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. It also does not validate the body, so check the shape yourself: an object
whose every value is a string, task one of the three lanes, and pack a
JSON string. Re-estimate per lane - the lanes send different amounts of data and reserve different
amounts.
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js below. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("ready","nec","quarterly") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("business","tax_year","pack")) and isinstance(json.loads(b["pack"]),dict)'
INPUT=$(cat body.json)
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is usually far lower.
INPUT = json.load(open("body.json")) # built by make-body.js below
assert isinstance(INPUT, dict) and INPUT.get("task") in ("ready", "nec", "quarterly")
assert all(isinstance(v, str) for v in INPUT.values()) # tax_year too: "2026"
assert all(INPUT.get(k, "").strip() for k in ("business", "tax_year", "pack"))
assert isinstance(json.loads(INPUT["pack"]), dict) # pack is a JSON STRING
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
import { readFileSync } from "node:fs";
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js below
if (!INPUT || typeof INPUT !== "object" || !["ready", "nec", "quarterly"].includes(INPUT.task)) throw new Error("bad task");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["business", "tax_year", "pack"]) if (!INPUT[k]) throw new Error(k + " is required");
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js below
var input map[string]string // every field is a string, pack and tax_year included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
lane := input["task"] // "ready", "nec" or "quarterly"
if lane != "ready" && lane != "nec" && lane != "quarterly" {
panic("task must be ready, nec or quarterly")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js below
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(ready|nec|quarterly)\".*", "$1");
if (!lane.matches("ready|nec|quarterly")) throw new IllegalStateException("task must be ready, nec or quarterly");
String est = call("estimate", input);
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js below
raise "bad task" unless %w[ready nec quarterly].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[business tax_year pack].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
est = call("estimate", INPUT)
puts est["model"], est["model_alias"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js below
if (!is_array($input) || !in_array($input["task"] ?? "", ["ready", "nec", "quarterly"], true)) { throw new Exception("bad task"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["business", "tax_year", "pack"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
$est = call("estimate", $input);
echo $est["model"], " ", $est["model_alias"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js below
using var doc = JsonDocument.Parse(input);
var lane = doc.RootElement.GetProperty("task").GetString();
if (lane is not ("ready" or "nec" or "quarterly")) throw new Exception("task must be ready, nec or quarterly");
var est = await TaxPrepDesk.Call("estimate", doc.RootElement);
Console.WriteLine(est); // model, model_alias, markup_bps, hold_credits, min_credits
Building the body
Do not hand-assemble pack. Load taxkit.js (it runs unchanged in Node) and
let it read your ledger export exactly as the page does. It removes SSN-shaped values, classifies
every row, totals the payees, works the estimate and raises the prescan flags, then
buildInput picks what each lane may see:
// make-body.js - build the run body with the SAME engine the web page uses.
// Save taxkit.js from https://tax-prep-desk.skillsafe.ai/taxkit.js next to this file.
// Usage: node make-body.js ready|nec|quarterly [readiness.json]
const fs = require("fs");
const K = require("./taxkit.js");
const A = K.analyze({
business: "Rivera Design Studio - graphic design, sole proprietor, Portland",
year: "2026", // 2025 or 2026 only
entity: "sole_prop", // sole_prop, smllc, partnership, scorp, ccorp
rate: "22", // assumed federal rate: 10, 12, 22, 24, 32, 35 or 37
through: "2026-08-31", // books closed through (blank: end of the latest month with activity)
prepared: "2026-09-20", // today, for the owner
paid1: "4,000", paid2: "4,000", paid3: "", paid4: "", // estimated payments made per quarter
prior_tax: "", // last year's total tax, for the safe-harbour floor (optional)
agi_over: false, // last year's AGI over USD 150,000 (the 110% rule)
full_year: "", // the owner's own full-year net, instead of annualising (optional)
ledger: fs.readFileSync("ledger.csv", "utf8") // date, payee, account, amount (+ method, W-9)
});
const lane = process.argv[2] || "ready";
const readiness = lane !== "ready" && process.argv[3] ? fs.readFileSync(process.argv[3], "utf8") : null;
const body = K.buildInput(lane, A, {
question: "I forgot to pay in September. What do I owe now, and who needs a 1099 this year?",
readiness // nec and quarterly only; ignored for ready
});
if (!body) throw new Error("no ledger rows read, or an unknown lane");
fs.writeFileSync("body.json", JSON.stringify(K.mustBeObject(body)));
console.log(lane, A.rows.length, "rows,", K.laneFlags(A, lane).length, "flags for this lane");
Run on the Rivera example ledger from the page, this reproduces the worked inputs below byte for
byte. buildInput caps what travels: at most 40 problem rows (ready) and 60 payees (nec),
with rows_not_sent / payees_not_sent counting the rest, and
question cut at 1,500 characters.
The input fields, every one a string:
| field | required | what it holds |
|---|---|---|
task | yes | ready, nec or quarterly. Send it first; it picks the lane. |
business | yes | The business in the owner's words (up to 160 characters); "the business" when blank. |
tax_year | yes | "2025" or "2026" - a string, not a number. |
pack | yes | A JSON string (the output of JSON.stringify), never an object. Its keys are listed in the next table. |
question | no | The owner's own question, answered inside summary. Omitted when empty. No SSNs or account numbers. |
readiness | no (nec and quarterly only) | A JSON string of the ready check's handoff: {"verdict","gate_statement","open_items","mode"}. When its verdict is not ready, the gate statement becomes a stated assumption. See below. |
retry_note | no | Only on a reformat retry, after a reply that could not be parsed: say what was wrong. Never on a first run. |
The keys inside pack:
| key | lanes | what it holds |
|---|---|---|
tax_year, prepared_on, books_through, books_through_inferred | all | The year (a number here), today, the close date, and whether the close was inferred. |
entity, entity_label, assumed_rate_pct, mode_hint | all | Business structure, the assumed federal rate (22 by default), and which packet the date suggests (quarterly, nec or both). |
figures | all | The year's se_wage_base, nec_threshold, near_threshold_floor, nec_due, return_due, quarter_due and the source of those figures. |
books | all | rows_read, rows_in_year, income, expenses, net_profit, uncategorized_rows, uncategorized_total, uncategorized_swing, marginal_rate_pct, owner_draws_excluded, transfers_excluded. |
readiness, rows, rows_not_sent | ready | verdict_hint, empty_months, duplicate_postings, rows_outside_year, rows_after_close, expense_rows_without_payee, unreadable_rows, contractors_paid, payees_over_threshold, quarterly_ready; the problem rows R# (uncategorized, duplicated, no payee, after the close) with id, date, payee, account, amount, kind, uncategorized. |
nec, payees, payees_not_sent, duplicates | nec | threshold, near_floor, due, past_deadline, year_incomplete, counts, and the id lists w9_missing, corporate_looking, card_or_processor; payees V# with status (file, near, exempt), form, total, basis, the split into services, rent, goods, wages, unclassified, card_or_processor, processor_only, plus w9, ledger_1099_tag, looks_corporate, llc, attorney, accounts, methods, payments, first, last; likely-duplicate pairs D# with combined and crosses_threshold. |
estimate | quarterly | months_of_books, ytd_net, annual_net, annual_basis, se_base, se_tax, se_note, se_half, adjusted_net, federal_tax, total_liability, required_installment, paid_by_quarter, paid_total, missed (q, due, due_long, amount), quarters_ahead, remaining, next_quarter, payment_due, due_date, due_with_return, single_filer_bracket_pct, safe_harbor, wage_base_hit. Or only blocked, a reason, when no estimate can be computed. |
prescan_flags | all | The flags for this lane only: id (F#), severity (high, medium, info), category, ref, message. |
Worked inputs
The Rivera Design Studio example from the page: a sole proprietor, tax year 2026, books closed
through August 31, a Q3 payment missed. Each body below is valid JSON exactly as sent, with
pack as a JSON string; to keep them readable, some rows, payees and flags are left out
of each pack (noted under each one), and the decoded pack follows.
ready (3 of 5 problem rows shown):
{
"task": "ready",
"business": "Rivera Design Studio - graphic design, sole proprietor, Portland",
"tax_year": "2026",
"pack": "{\"tax_year\":2026,\"prepared_on\":\"2026-09-20\",\"books_through\":\"2026-08-31\",\"books_through_inferred\":false,\"entity\":\"sole_prop\",\"entity_label\":\"Sole proprietor\",\"assumed_rate_pct\":22,\"mode_hint\":\"quarterly\",\"figures\":{\"source\":\"2026 figures as published by the IRS and SSA for 2026; the USD 2,000 1099-NEC / 1099-MISC threshold applies to payments made after 2025 under P.L. 119-21. Confirm each figure at irs.gov before relying on it.\",\"se_wage_base\":184500,\"nec_threshold\":2000,\"near_threshold_floor\":1333,\"nec_due\":\"2027-02-01\",\"return_due\":\"2027-04-15\",\"quarter_due\":[\"Q1 Jan 1 - Mar 31 due 2026-04-15\",\"Q2 Apr 1 - May 31 due 2026-06-15\",\"Q3 Jun 1 - Aug 31 due 2026-09-15\",\"Q4 Sep 1 - Dec 31 due 2027-01-15\"]},\"books\":{\"rows_read\":52,\"rows_in_year\":52,\"income\":96000,\"expenses\":33999.92,\"net_profit\":62000.08,\"uncategorized_rows\":3,\"uncategorized_total\":2270,\"uncategorized_swing\":785,\"marginal_rate_pct\":34.58,\"owner_draws_excluded\":12000,\"transfers_excluded\":0},\"readiness\":{\"verdict_hint\":\"proceed_with_assumptions\",\"empty_months\":[],\"duplicate_postings\":[\"R43 and R44\"],\"rows_outside_year\":0,\"rows_after_close\":0,\"expense_rows_without_payee\":0,\"unreadable_rows\":0,\"contractors_paid\":12,\"payees_over_threshold\":5,\"quarterly_ready\":true},\"rows\":[{\"id\":\"R26\",\"date\":\"2026-05-09\",\"payee\":\"Home Depot\",\"account\":\"Uncategorized Expense\",\"amount\":640,\"kind\":\"expense\",\"uncategorized\":true},{\"id\":\"R43\",\"date\":\"2026-07-22\",\"payee\":\"FedEx\",\"account\":\"Shipping and postage\",\"amount\":95,\"kind\":\"expense\",\"uncategorized\":false},{\"id\":\"R44\",\"date\":\"2026-07-22\",\"payee\":\"FedEx\",\"account\":\"Shipping and postage\",\"amount\":95,\"kind\":\"expense\",\"uncategorized\":false}],\"prescan_flags\":[{\"id\":\"F1\",\"severity\":\"medium\",\"category\":\"uncategorized\",\"ref\":\"R26,R38,R51\",\"message\":\"3 transactions are uncategorized, totalling USD 2,270. Left out of net profit; at the 34.58% combined marginal rate they could move the annual estimate by about USD 785 either way.\"},{\"id\":\"F2\",\"severity\":\"medium\",\"category\":\"duplicate_posting\",\"ref\":\"R43,R44\",\"message\":\"1 pair of rows share the same date, payee and amount - possible double postings, counted twice in every total.\"},{\"id\":\"F3\",\"severity\":\"info\",\"category\":\"figures\",\"ref\":\"\",\"message\":\"2026 figures as published by the IRS and SSA for 2026; the USD 2,000 1099-NEC / 1099-MISC threshold applies to payments made after 2025 under P.L. 119-21. Confirm each figure at irs.gov before relying on it.\"}]}",
"question": "I forgot to pay in September. What do I owe now, and who needs a 1099 this year?"
}
Its pack, decoded:
{
"tax_year": 2026,
"prepared_on": "2026-09-20",
"books_through": "2026-08-31",
"books_through_inferred": false,
"entity": "sole_prop",
"entity_label": "Sole proprietor",
"assumed_rate_pct": 22,
"mode_hint": "quarterly",
"figures": {
"source": "2026 figures as published by the IRS and SSA for 2026; the USD 2,000 1099-NEC / 1099-MISC threshold applies to payments made after 2025 under P.L. 119-21. Confirm each figure at irs.gov before relying on it.",
"se_wage_base": 184500,
"nec_threshold": 2000,
"near_threshold_floor": 1333,
"nec_due": "2027-02-01",
"return_due": "2027-04-15",
"quarter_due": [
"Q1 Jan 1 - Mar 31 due 2026-04-15",
"Q2 Apr 1 - May 31 due 2026-06-15",
"Q3 Jun 1 - Aug 31 due 2026-09-15",
"Q4 Sep 1 - Dec 31 due 2027-01-15"
]
},
"books": {
"rows_read": 52,
"rows_in_year": 52,
"income": 96000,
"expenses": 33999.92,
"net_profit": 62000.08,
"uncategorized_rows": 3,
"uncategorized_total": 2270,
"uncategorized_swing": 785,
"marginal_rate_pct": 34.58,
"owner_draws_excluded": 12000,
"transfers_excluded": 0
},
"readiness": {
"verdict_hint": "proceed_with_assumptions",
"empty_months": [],
"duplicate_postings": [
"R43 and R44"
],
"rows_outside_year": 0,
"rows_after_close": 0,
"expense_rows_without_payee": 0,
"unreadable_rows": 0,
"contractors_paid": 12,
"payees_over_threshold": 5,
"quarterly_ready": true
},
"rows": [
{
"id": "R26",
"date": "2026-05-09",
"payee": "Home Depot",
"account": "Uncategorized Expense",
"amount": 640,
"kind": "expense",
"uncategorized": true
},
{
"id": "R43",
"date": "2026-07-22",
"payee": "FedEx",
"account": "Shipping and postage",
"amount": 95,
"kind": "expense",
"uncategorized": false
},
{
"id": "R44",
"date": "2026-07-22",
"payee": "FedEx",
"account": "Shipping and postage",
"amount": 95,
"kind": "expense",
"uncategorized": false
}
],
"prescan_flags": [
{
"id": "F1",
"severity": "medium",
"category": "uncategorized",
"ref": "R26,R38,R51",
"message": "3 transactions are uncategorized, totalling USD 2,270. Left out of net profit; at the 34.58% combined marginal rate they could move the annual estimate by about USD 785 either way."
},
{
"id": "F2",
"severity": "medium",
"category": "duplicate_posting",
"ref": "R43,R44",
"message": "1 pair of rows share the same date, payee and amount - possible double postings, counted twice in every total."
},
{
"id": "F3",
"severity": "info",
"category": "figures",
"ref": "",
"message": "2026 figures as published by the IRS and SSA for 2026; the USD 2,000 1099-NEC / 1099-MISC threshold applies to payments made after 2025 under P.L. 119-21. Confirm each figure at irs.gov before relying on it."
}
]
}
nec (3 of 7 payees, 1 of 2 duplicate pairs and 5 of 10 flags shown; the shared keys are the same as above):
{
"task": "nec",
"business": "Rivera Design Studio - graphic design, sole proprietor, Portland",
"tax_year": "2026",
"pack": "{\"tax_year\":2026,\"prepared_on\":\"2026-09-20\",\"books_through\":\"2026-08-31\",\"books_through_inferred\":false,\"entity\":\"sole_prop\",\"entity_label\":\"Sole proprietor\",\"assumed_rate_pct\":22,\"mode_hint\":\"quarterly\",\"figures\":{\"source\":\"2026 figures as published by the IRS and SSA for 2026; the USD 2,000 1099-NEC / 1099-MISC threshold applies to payments made after 2025 under P.L. 119-21. Confirm each figure at irs.gov before relying on it.\",\"se_wage_base\":184500,\"nec_threshold\":2000,\"near_threshold_floor\":1333,\"nec_due\":\"2027-02-01\",\"return_due\":\"2027-04-15\",\"quarter_due\":[\"Q1 Jan 1 - Mar 31 due 2026-04-15\",\"Q2 Apr 1 - May 31 due 2026-06-15\",\"Q3 Jun 1 - Aug 31 due 2026-09-15\",\"Q4 Sep 1 - Dec 31 due 2027-01-15\"]},\"books\":{\"rows_read\":52,\"rows_in_year\":52,\"income\":96000,\"expenses\":33999.92,\"net_profit\":62000.08,\"uncategorized_rows\":3,\"uncategorized_total\":2270,\"uncategorized_swing\":785,\"marginal_rate_pct\":34.58,\"owner_draws_excluded\":12000,\"transfers_excluded\":0},\"nec\":{\"threshold\":2000,\"near_floor\":1333,\"due\":\"2027-02-01\",\"past_deadline\":false,\"year_incomplete\":true,\"payees_total\":15,\"contractors_paid\":12,\"file_count\":5,\"near_count\":2,\"w9_missing\":[\"V5\"],\"corporate_looking\":[\"V3\"],\"card_or_processor\":[\"V5\",\"V6\",\"V7\"]},\"payees\":[{\"id\":\"V1\",\"name\":\"Quorvale Studios LLC\",\"status\":\"file\",\"form\":\"1099-MISC\",\"total\":8800,\"basis\":8800,\"services\":0,\"rent\":8800,\"goods\":0,\"wages\":0,\"unclassified\":0,\"card_or_processor\":0,\"processor_only\":false,\"w9\":\"on_file\",\"ledger_1099_tag\":null,\"looks_corporate\":false,\"llc\":true,\"attorney\":false,\"accounts\":[\"Rent\"],\"methods\":[\"Check\"],\"payments\":8,\"first\":\"2026-01-01\",\"last\":\"2026-08-01\"},{\"id\":\"V5\",\"name\":\"Pellucid Owl Photo\",\"status\":\"file\",\"form\":\"1099-NEC\",\"total\":2400,\"basis\":2400,\"services\":2400,\"rent\":0,\"goods\":0,\"wages\":0,\"unclassified\":0,\"card_or_processor\":2400,\"processor_only\":true,\"w9\":\"missing\",\"ledger_1099_tag\":null,\"looks_corporate\":false,\"llc\":false,\"attorney\":false,\"accounts\":[\"Contract labor\"],\"methods\":[\"PayPal\"],\"payments\":2,\"first\":\"2026-05-14\",\"last\":\"2026-08-13\"},{\"id\":\"V6\",\"name\":\"Dana Reyes\",\"status\":\"near\",\"form\":\"1099-NEC\",\"total\":1500,\"basis\":1500,\"services\":1500,\"rent\":0,\"goods\":0,\"wages\":0,\"unclassified\":0,\"card_or_processor\":1500,\"processor_only\":true,\"w9\":\"missing\",\"ledger_1099_tag\":null,\"looks_corporate\":false,\"llc\":false,\"attorney\":false,\"accounts\":[\"Contract labor\"],\"methods\":[\"Venmo\"],\"payments\":1,\"first\":\"2026-06-19\",\"last\":\"2026-06-19\"}],\"duplicates\":[{\"id\":\"D2\",\"a\":\"V8\",\"b\":\"V11\",\"a_name\":\"Bob Nguyen\",\"b_name\":\"Robert Nguyen\",\"combined\":2150,\"crosses_threshold\":true,\"why\":\"\\\"bob\\\" and \\\"robert\\\" are the same first name\"}],\"prescan_flags\":[{\"id\":\"F2\",\"severity\":\"medium\",\"category\":\"duplicate_posting\",\"ref\":\"R43,R44\",\"message\":\"1 pair of rows share the same date, payee and amount - possible double postings, counted twice in every total.\"},{\"id\":\"F4\",\"severity\":\"high\",\"category\":\"w9\",\"ref\":\"V5\",\"message\":\"1 payee at or over the USD 2,000 threshold has no W-9 on file - collect before filing (due February 1, 2027).\"},{\"id\":\"F6\",\"severity\":\"high\",\"category\":\"duplicate_payee\",\"ref\":\"V8,V11\",\"message\":\"\\\"Bob Nguyen\\\" and \\\"Robert Nguyen\\\" may be the same payee (\\\"bob\\\" and \\\"robert\\\" are the same first name). Combined USD 2,150 - over the threshold together, under it apart. Confirm before filing; not merged.\"},{\"id\":\"F9\",\"severity\":\"medium\",\"category\":\"processor\",\"ref\":\"V5,V6,V7\",\"message\":\"3 payees were paid at least partly by card or a payment processor. The processor may issue its own 1099-K; the accountant decides whether a 1099-NEC is also needed.\"},{\"id\":\"F10\",\"severity\":\"medium\",\"category\":\"year_incomplete\",\"ref\":\"\",\"message\":\"The books run only through August 31, 2026, so these are year-to-date totals; a payee under the threshold now may cross it by December 31.\"}]}",
"question": "I forgot to pay in September. What do I owe now, and who needs a 1099 this year?"
}
The lane-specific part of its pack, decoded:
{
"nec": {
"threshold": 2000,
"near_floor": 1333,
"due": "2027-02-01",
"past_deadline": false,
"year_incomplete": true,
"payees_total": 15,
"contractors_paid": 12,
"file_count": 5,
"near_count": 2,
"w9_missing": [
"V5"
],
"corporate_looking": [
"V3"
],
"card_or_processor": [
"V5",
"V6",
"V7"
]
},
"payees": [
{
"id": "V1",
"name": "Quorvale Studios LLC",
"status": "file",
"form": "1099-MISC",
"total": 8800,
"basis": 8800,
"services": 0,
"rent": 8800,
"goods": 0,
"wages": 0,
"unclassified": 0,
"card_or_processor": 0,
"processor_only": false,
"w9": "on_file",
"ledger_1099_tag": null,
"looks_corporate": false,
"llc": true,
"attorney": false,
"accounts": [
"Rent"
],
"methods": [
"Check"
],
"payments": 8,
"first": "2026-01-01",
"last": "2026-08-01"
},
{
"id": "V5",
"name": "Pellucid Owl Photo",
"status": "file",
"form": "1099-NEC",
"total": 2400,
"basis": 2400,
"services": 2400,
"rent": 0,
"goods": 0,
"wages": 0,
"unclassified": 0,
"card_or_processor": 2400,
"processor_only": true,
"w9": "missing",
"ledger_1099_tag": null,
"looks_corporate": false,
"llc": false,
"attorney": false,
"accounts": [
"Contract labor"
],
"methods": [
"PayPal"
],
"payments": 2,
"first": "2026-05-14",
"last": "2026-08-13"
},
{
"id": "V6",
"name": "Dana Reyes",
"status": "near",
"form": "1099-NEC",
"total": 1500,
"basis": 1500,
"services": 1500,
"rent": 0,
"goods": 0,
"wages": 0,
"unclassified": 0,
"card_or_processor": 1500,
"processor_only": true,
"w9": "missing",
"ledger_1099_tag": null,
"looks_corporate": false,
"llc": false,
"attorney": false,
"accounts": [
"Contract labor"
],
"methods": [
"Venmo"
],
"payments": 1,
"first": "2026-06-19",
"last": "2026-06-19"
}
],
"duplicates": [
{
"id": "D2",
"a": "V8",
"b": "V11",
"a_name": "Bob Nguyen",
"b_name": "Robert Nguyen",
"combined": 2150,
"crosses_threshold": true,
"why": "\"bob\" and \"robert\" are the same first name"
}
],
"prescan_flags": [
{
"id": "F2",
"severity": "medium",
"category": "duplicate_posting",
"ref": "R43,R44",
"message": "1 pair of rows share the same date, payee and amount - possible double postings, counted twice in every total."
},
{
"id": "F4",
"severity": "high",
"category": "w9",
"ref": "V5",
"message": "1 payee at or over the USD 2,000 threshold has no W-9 on file - collect before filing (due February 1, 2027)."
},
{
"id": "F6",
"severity": "high",
"category": "duplicate_payee",
"ref": "V8,V11",
"message": "\"Bob Nguyen\" and \"Robert Nguyen\" may be the same payee (\"bob\" and \"robert\" are the same first name). Combined USD 2,150 - over the threshold together, under it apart. Confirm before filing; not merged."
},
{
"id": "F9",
"severity": "medium",
"category": "processor",
"ref": "V5,V6,V7",
"message": "3 payees were paid at least partly by card or a payment processor. The processor may issue its own 1099-K; the accountant decides whether a 1099-NEC is also needed."
},
{
"id": "F10",
"severity": "medium",
"category": "year_incomplete",
"ref": "",
"message": "The books run only through August 31, 2026, so these are year-to-date totals; a payee under the threshold now may cross it by December 31."
}
]
}
quarterly (3 of 5 flags shown):
{
"task": "quarterly",
"business": "Rivera Design Studio - graphic design, sole proprietor, Portland",
"tax_year": "2026",
"pack": "{\"tax_year\":2026,\"prepared_on\":\"2026-09-20\",\"books_through\":\"2026-08-31\",\"books_through_inferred\":false,\"entity\":\"sole_prop\",\"entity_label\":\"Sole proprietor\",\"assumed_rate_pct\":22,\"mode_hint\":\"quarterly\",\"figures\":{\"source\":\"2026 figures as published by the IRS and SSA for 2026; the USD 2,000 1099-NEC / 1099-MISC threshold applies to payments made after 2025 under P.L. 119-21. Confirm each figure at irs.gov before relying on it.\",\"se_wage_base\":184500,\"nec_threshold\":2000,\"near_threshold_floor\":1333,\"nec_due\":\"2027-02-01\",\"return_due\":\"2027-04-15\",\"quarter_due\":[\"Q1 Jan 1 - Mar 31 due 2026-04-15\",\"Q2 Apr 1 - May 31 due 2026-06-15\",\"Q3 Jun 1 - Aug 31 due 2026-09-15\",\"Q4 Sep 1 - Dec 31 due 2027-01-15\"]},\"books\":{\"rows_read\":52,\"rows_in_year\":52,\"income\":96000,\"expenses\":33999.92,\"net_profit\":62000.08,\"uncategorized_rows\":3,\"uncategorized_total\":2270,\"uncategorized_swing\":785,\"marginal_rate_pct\":34.58,\"owner_draws_excluded\":12000,\"transfers_excluded\":0},\"estimate\":{\"months_of_books\":8,\"ytd_net\":62000,\"annual_net\":93000,\"annual_basis\":\"projected: YTD USD 62,000 / 8 months x 12\",\"se_base\":85886,\"se_tax\":13141,\"se_note\":\"under the 2026 wage base of USD 184,500 - full 15.3% applies\",\"se_half\":6571,\"adjusted_net\":86429,\"federal_tax\":19014,\"total_liability\":32155,\"required_installment\":8039,\"paid_by_quarter\":[4000,4000,0,0],\"paid_total\":8000,\"missed\":[{\"q\":\"Q3\",\"due\":\"2026-09-15\",\"due_long\":\"September 15, 2026\",\"amount\":8039}],\"quarters_ahead\":1,\"remaining\":16116,\"next_quarter\":\"Q4\",\"payment_due\":16116,\"due_date\":\"2027-01-15\",\"due_with_return\":false,\"single_filer_bracket_pct\":22,\"safe_harbor\":null,\"wage_base_hit\":false},\"prescan_flags\":[{\"id\":\"F1\",\"severity\":\"medium\",\"category\":\"uncategorized\",\"ref\":\"R26,R38,R51\",\"message\":\"3 transactions are uncategorized, totalling USD 2,270. Left out of net profit; at the 34.58% combined marginal rate they could move the annual estimate by about USD 785 either way.\"},{\"id\":\"F12\",\"severity\":\"high\",\"category\":\"missed_quarter\",\"ref\":\"\",\"message\":\"Q3 was due September 15, 2026 and no payment is recorded against it. Shown as its own catch-up line; penalty and interest are for the accountant.\"},{\"id\":\"F13\",\"severity\":\"info\",\"category\":\"safe_harbor\",\"ref\":\"\",\"message\":\"No prior-year tax given, so the safe-harbour floor (100% of last year's tax, 110% above USD 150,000 AGI) cannot be computed.\"}]}",
"question": "I forgot to pay in September. What do I owe now, and who needs a 1099 this year?"
}
The lane-specific part of its pack, decoded:
{
"estimate": {
"months_of_books": 8,
"ytd_net": 62000,
"annual_net": 93000,
"annual_basis": "projected: YTD USD 62,000 / 8 months x 12",
"se_base": 85886,
"se_tax": 13141,
"se_note": "under the 2026 wage base of USD 184,500 - full 15.3% applies",
"se_half": 6571,
"adjusted_net": 86429,
"federal_tax": 19014,
"total_liability": 32155,
"required_installment": 8039,
"paid_by_quarter": [
4000,
4000,
0,
0
],
"paid_total": 8000,
"missed": [
{
"q": "Q3",
"due": "2026-09-15",
"due_long": "September 15, 2026",
"amount": 8039
}
],
"quarters_ahead": 1,
"remaining": 16116,
"next_quarter": "Q4",
"payment_due": 16116,
"due_date": "2027-01-15",
"due_with_return": false,
"single_filer_bracket_pct": 22,
"safe_harbor": null,
"wage_base_hit": false
},
"prescan_flags": [
{
"id": "F1",
"severity": "medium",
"category": "uncategorized",
"ref": "R26,R38,R51",
"message": "3 transactions are uncategorized, totalling USD 2,270. Left out of net profit; at the 34.58% combined marginal rate they could move the annual estimate by about USD 785 either way."
},
{
"id": "F12",
"severity": "high",
"category": "missed_quarter",
"ref": "",
"message": "Q3 was due September 15, 2026 and no payment is recorded against it. Shown as its own catch-up line; penalty and interest are for the accountant."
},
{
"id": "F13",
"severity": "info",
"category": "safe_harbor",
"ref": "",
"message": "No prior-year tax given, so the safe-harbour floor (100% of last year's tax, 110% above USD 150,000 AGI) cannot be computed."
}
]
}
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. Send an Idempotency-Key built from the lane and the input, such as
tax-prep-desk:quarterly:3f9a0c2e71b4d58a:a1, so a retried request returns the same job
instead of billing a second run.
# Always send an Idempotency-Key derived from the lane and the input. A retried
# request with the same key returns the SAME job instead of billing a second run.
KEY="tax-prep-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"quarterly\",\"headline\":\"Q4 2026 estimate: USD 16,116, due January 15, 2027 ...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
lane = INPUT["task"] # "ready", "nec" or "quarterly"
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"tax-prep-desk:{lane}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
reply = json.loads(job["output"]["output"])
print(reply["lane"], reply["headline"])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const lane = INPUT.task; // "ready", "nec" or "quarterly"
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `tax-prep-desk:${lane}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const reply = JSON.parse(job.output.output);
console.log(reply.lane, reply.headline, job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("tax-prep-desk:%s:%x:a1", lane, sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
// lane was read and checked in step 4: "ready", "nec" or "quarterly".
String key = "tax-prep-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
lane = INPUT["task"] # "ready", "nec" or "quarterly"
key = "tax-prep-desk:#{lane}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
reply = JSON.parse(job["output"]["output"])
puts reply["lane"], reply["headline"]
<?php
$key = "tax-prep-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
$reply = json_decode($job["output"]["output"], true);
echo $reply["lane"], " ", $reply["headline"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"tax-prep-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await TaxPrepDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!;
var reply = JsonSerializer.Deserialize<JsonElement>(output);
Console.WriteLine($"{reply.GetProperty("lane")} {reply.GetProperty("headline")}");
6. Or stream it
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"quarterly\",\"headline\":\"Q4 2026 estimate: USD 16,116"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
console.log(done, raw.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
The reply is one JSON object in data.output.output. Strip anything outside the outermost braces, then branch on lane.
# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("reply.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r.get("lane"), "-", r["headline"])
if r.get("lane") == "ready":
print("verdict:", r["verdict"], "mode:", r["mode"])
for o in r["open_items"]:
print(o["ref"], "|", o["issue"], "->", o["fix"])
print(r["gate_statement"])
elif r.get("lane") == "nec":
for c in r["candidates"]:
print(c["ref"], c["form"], c["total"], c["w9"], "|", c["note"])
for w in r["w9_requests"]:
print("W-9", w["ref"], "|", w["text"])
else:
print(r["quarter"], r["payment_due"], "due", r["due_date"])
for c in r["catch_up"]:
print("catch-up", c["quarter"], c["amount"], "was due", c["due_date"])
EOF
text = job["output"]["output"]
reply = json.loads(text[text.index("{"):text.rindex("}") + 1])
if reply.get("lane") == "ready":
print(reply["verdict"], reply["mode"], [o["ref"] for o in reply["open_items"]])
elif reply.get("lane") == "nec":
print([(c["ref"], c["form"], c["total"], c["w9"]) for c in reply["candidates"]])
else:
print(reply["quarter"], reply["payment_due"], reply["due_date"],
[(c["quarter"], c["amount"]) for c in reply["catch_up"]])
const text = job.output.output;
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
if (reply.lane === "ready") console.log(reply.verdict, reply.mode, reply.open_items.map((o) => o.ref));
else if (reply.lane === "nec") console.log(reply.candidates.map((c) => [c.ref, c.form, c.total, c.w9]));
else console.log(reply.quarter, reply.payment_due, reply.due_date, reply.catch_up.map((c) => [c.quarter, c.amount]));
start, end := strings.Index(jobOutput, "{"), strings.LastIndex(jobOutput, "}")
var reply map[string]any
if err := json.Unmarshal([]byte(jobOutput[start:end+1]), &reply); err != nil {
panic(err)
}
fmt.Println(reply["lane"], reply["headline"])
switch reply["lane"] {
case "ready":
fmt.Println(reply["verdict"], reply["mode"], reply["open_items"])
case "nec":
fmt.Println(reply["candidates"], reply["w9_requests"])
default:
fmt.Println(reply["quarter"], reply["payment_due"], reply["due_date"], reply["catch_up"])
}
// output is data.output.output from step 5: a string holding the reply JSON.
String json = output.substring(output.indexOf('{'), output.lastIndexOf('}') + 1);
// With Jackson: JsonNode r = new ObjectMapper().readTree(json);
// ready: r.get("verdict"), r.get("open_items"), r.get("gate_statement"), r.get("mode")
// nec: r.get("candidates") - each "total" is a number, "form" one of the four forms
// quarterly: r.get("payment_due") (a number or null), r.get("due_date"), r.get("catch_up")
System.out.println(json);
text = job["output"]["output"]
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
case reply["lane"]
when "ready" then puts reply["verdict"], reply["open_items"].map { |o| o["ref"] }
when "nec" then puts reply["candidates"].map { |c| "#{c['ref']} #{c['form']} #{c['total']} #{c['w9']}" }
else puts "#{reply['quarter']} #{reply['payment_due']} #{reply['due_date']}", reply["catch_up"].map { |c| "#{c['quarter']} #{c['amount']}" }
end
<?php
$text = $job["output"]["output"];
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1), true);
if (($reply["lane"] ?? "") === "ready") {
echo $reply["verdict"], " ", $reply["mode"], PHP_EOL;
foreach ($reply["open_items"] as $o) { echo $o["ref"], " ", $o["issue"], PHP_EOL; }
} elseif (($reply["lane"] ?? "") === "nec") {
foreach ($reply["candidates"] as $c) { echo $c["ref"], " ", $c["form"], " ", $c["total"], " ", $c["w9"], PHP_EOL; }
} else {
echo $reply["quarter"], " ", $reply["payment_due"], " ", $reply["due_date"], PHP_EOL;
foreach ($reply["catch_up"] as $c) { echo $c["quarter"], " ", $c["amount"], PHP_EOL; }
}
var text = output; // data.output.output from step 5
var json2 = text.Substring(text.IndexOf('{'), text.LastIndexOf('}') - text.IndexOf('{') + 1);
using var parsed = JsonDocument.Parse(json2);
var r = parsed.RootElement;
Console.WriteLine($"{r.GetProperty("lane")} {r.GetProperty("headline")}");
switch (r.GetProperty("lane").GetString())
{
case "ready":
foreach (var o in r.GetProperty("open_items").EnumerateArray()) Console.WriteLine($"{o.GetProperty("ref")} {o.GetProperty("issue")}");
break;
case "nec":
foreach (var c in r.GetProperty("candidates").EnumerateArray()) Console.WriteLine($"{c.GetProperty("ref")} {c.GetProperty("form")} {c.GetProperty("total")} {c.GetProperty("w9")}");
break;
default:
Console.WriteLine($"{r.GetProperty("quarter")} {r.GetProperty("payment_due")} {r.GetProperty("due_date")}");
break;
}
Invariants worth asserting
The web page holds every reply to the browser's arithmetic before it shows it. Do the same:
- ready:
verdictis never looser thanpack.readiness.verdict_hint(order:ready<proceed_with_assumptions<close_first; one step stricter is allowed); every high or medium prescan flag appears in someopen_items[].refand no info flag needs one; a non-emptygate_statementwhenever the verdict is notready; whenbooks.uncategorized_rowsis above zero,uncategorized_swingis stated in the gate statement or an open item'seffect;modeequalsmode_hintormode_reasonexplains why not. - nec: exactly one
candidatesentry per sent payee whosestatusisfileornear, and none for any other; each candidate'stotalequals that payee'sbasisand itsw9is copied from the pack; afilepayee never getsnonewithout a note, anearpayee never gets a 1099; onew9_requestsentry per id innec.w9_missing, oneduplicate_reviewsentry perD#, onecorporate_reviewsentry per id innec.corporate_looking, oneexcludedentry per sentexemptpayee; a non-emptyprocessor_notewhencard_or_processoris non-empty;deadline_notesays the deadline passed whenpast_deadline. - quarterly:
payment_due,due_dateandquarterequalestimate.payment_due,estimate.due_dateandestimate.next_quarter(andpayment_dueappears inheadline);catch_uphas one line perestimate.missedentry with the samequarter(q),amountanddue_date(due); whenestimate.blockedis present,payment_dueisnull,quarteranddue_dateare""andcatch_upis empty; the assumptions name the rate, the structure, the annualisation, state taxes, the SE half, QBI and the other deductions, and the year's figures. - every lane: every prescan flag has exactly one
prescan_responsesentry and no response names a flag that was not sent; every ref (F#,R#,V#,D#) exists in the input;headlinenames the tax year.
# assert-reply.py - the core checks, for body.json and reply.json from the steps above.
import json
body = json.load(open("body.json")); pack = json.loads(body["pack"])
t = open("reply.json").read(); r = json.loads(t[t.index("{"):t.rindex("}") + 1])
assert r["lane"] == body["task"] and body["tax_year"] in r["headline"]
ids = [p["id"] for p in r["prescan_responses"]]
assert sorted(ids) == sorted(f["id"] for f in pack["prescan_flags"]), "every flag answered once"
if r["lane"] == "ready":
order = ["ready", "proceed_with_assumptions", "close_first"]
assert order.index(r["verdict"]) >= order.index(pack["readiness"]["verdict_hint"])
elif r["lane"] == "nec":
want = {p["id"]: p for p in pack["payees"] if p["status"] in ("file", "near")}
assert sorted(c["ref"] for c in r["candidates"]) == sorted(want)
for c in r["candidates"]:
assert abs(c["total"] - want[c["ref"]]["basis"]) <= 1 and c["w9"] == want[c["ref"]]["w9"]
else:
e = pack["estimate"]
if "blocked" not in e:
assert abs(r["payment_due"] - e["payment_due"]) <= 1
assert r["due_date"] == e["due_date"] and r["quarter"] == e["next_quarter"]
assert sorted((c["quarter"], round(c["amount"]), c["due_date"]) for c in r["catch_up"]) == \
sorted((m["q"], round(m["amount"]), m["due"]) for m in e["missed"])
Handing the books check on
To build the 1099 list or the estimate on the books check, send the ready reply's handoff as
readiness in the nec or quarterly body - exactly what the page
carries forward after a ready run:
JSON.stringify({verdict, gate_statement, open_items: open_items.map(o => o.ref).join("; "), mode}).
When the verdict is proceed_with_assumptions or close_first, the reply puts
the gate statement into assumptions (the owner chose to proceed on books that are not
settled) and says so once in summary. The ready lane ignores it. For the Rivera books
check below, the field is:
{
"readiness": "{\"verdict\":\"proceed_with_assumptions\",\"gate_statement\":\"Three transactions are still uncategorized, totalling USD 2,270. Until those are coded, your estimate could be off by roughly USD 785 either way. Categorize them, or proceed and it becomes a stated assumption.\",\"open_items\":\"F1,R26,R38,R51; F2,R43,R44\",\"mode\":\"quarterly\"}"
}
which, decoded, is:
{
"verdict": "proceed_with_assumptions",
"gate_statement": "Three transactions are still uncategorized, totalling USD 2,270. Until those are coded, your estimate could be off by roughly USD 785 either way. Categorize them, or proceed and it becomes a stated assumption.",
"open_items": "F1,R26,R38,R51; F2,R43,R44",
"mode": "quarterly"
}
With the make-body.js above: save the decoded object as readiness.json and run node make-body.js quarterly readiness.json.
The output contract
Every key shown is always present. Arrays may be empty; strings are "" only where noted.
An enum is written "a|b|c": the reply carries exactly one of the values. Text fields are
plain prose - no Markdown, no bullets, money as USD 12,345.
ready
{"lane":"ready","verdict":"ready|proceed_with_assumptions|close_first","headline":"...","summary":"...",
"open_items":[{"ref":"F2,R43,R44","issue":"...","effect":"...","fix":"..."}],
"gate_statement":"...","basis_statement":"...","mode":"quarterly|nec|both","mode_reason":"...",
"assumptions":["..."],"accountant_checklist":["..."],
"prescan_responses":[{"id":"F1","status":"confirmed|dismissed","reason":"..."}]}
| key | type | notes |
|---|---|---|
lane | string | "ready". |
verdict | enum | ready, proceed_with_assumptions, close_first; the browser's verdict_hint or one step stricter. |
headline, summary | string | One sentence with the year and verdict; 2-3 sentences, answering question. |
open_items | array of objects | ref (flag id, plus row ids), issue, effect, fix - all strings. One per high or medium flag. |
gate_statement | string | The sentence the owner reads before any number; says nothing is open when ready. |
basis_statement | string | What the packet is built on; for close_first, which months to close. |
mode, mode_reason | enum, string | quarterly, nec or both, and why (which deadline comes first). |
assumptions, accountant_checklist | array of strings | 3-8 each. |
prescan_responses | array of objects | id, status (confirmed or dismissed), reason. One per flag. |
nec
{"lane":"nec","headline":"...","summary":"...",
"candidates":[{"ref":"V1","form":"1099-NEC|1099-MISC|none|accountant_decides","total":8800,"w9":"on_file|missing|unknown","note":"..."}],
"excluded":[{"ref":"V9","reason":"..."}],"w9_requests":[{"ref":"V5","text":"..."}],
"duplicate_reviews":[{"ref":"D1","text":"..."}],"corporate_reviews":[{"ref":"V3","text":"..."}],
"processor_note":"...","deadline_note":"...","assumptions":["..."],"accountant_checklist":["..."],
"prescan_responses":[{"id":"F4","status":"confirmed|dismissed","reason":"..."}]}
| key | type | notes |
|---|---|---|
lane | string | "nec". |
headline, summary | string | The year, how many 1099s look required, how many W-9s block filing; 2-3 sentences. |
candidates | array of objects | ref (string), form (enum 1099-NEC, 1099-MISC, none, accountant_decides), total (number: the payee's basis), w9 (enum on_file, missing, unknown, copied), note (string). In pack order. |
excluded | array of objects | ref, reason; one per sent exempt payee, [] if none. |
w9_requests, duplicate_reviews, corporate_reviews | arrays of objects | ref, text; one per id in w9_missing, per D#, per id in corporate_looking. |
processor_note | string | The 1099-K overlap note, naming the payees; "" when no payee was paid by card or processor. |
deadline_note | string | The filing deadline; whether it passed; whether the totals are year to date. |
assumptions, accountant_checklist, prescan_responses | as in ready |
quarterly
{"lane":"quarterly","headline":"...","summary":"...","quarter":"Q4","payment_due":16116,"due_date":"2027-01-15",
"catch_up":[{"quarter":"Q3","amount":8039,"due_date":"2026-09-15","text":"..."}],
"se_text":"...","federal_text":"...","safe_harbor_text":"...","basis_statement":"...",
"assumptions":["..."],"accountant_checklist":["..."],
"prescan_responses":[{"id":"F12","status":"confirmed|dismissed","reason":"..."}]}
| key | type | notes |
|---|---|---|
lane | string | "quarterly". |
headline, summary | string | "<quarter> <year> estimate: USD X, due <date>."; 2-3 sentences with the basis and the biggest caveat. |
quarter | string | estimate.next_quarter (Q1-Q4); "" when blocked. |
payment_due | number or null | Exactly estimate.payment_due; null when blocked. |
due_date | string | Exactly estimate.due_date (YYYY-MM-DD); "" when blocked. |
catch_up | array of objects | quarter (string), amount (number), due_date (string), text (late, accrues penalty and interest, the accountant computes them). One per missed quarter. |
se_text, federal_text, safe_harbor_text, basis_statement | string | The SE step, the federal step (flags a bracket that differs from the assumed rate), the safe-harbour rule with the pack's floor, and the books the estimate is built on. |
assumptions, accountant_checklist, prescan_responses | as in ready |
Worked replies
Real model replies for the Rivera inputs above (lists shortened to match the abridged inputs; every value is as the model wrote it).
ready:
{
"lane": "ready",
"verdict": "proceed_with_assumptions",
"headline": "Tax year 2026: Rivera Design Studio's books are close enough to build an estimate on, with two small bookkeeping items carried as stated assumptions.",
"summary": "Through August 31, 2026 the books show USD 96,000 of income, USD 34,000 of expenses and USD 62,000 of net profit, with 3 uncategorized purchases totalling USD 2,270 and one possible double-posted FedEx charge still open. The September payment you missed is the Q3 installment that was due September 15, 2026; it is now late, the quarterly packet built from these books gives the amount, and any penalty and interest are for your accountant to compute. On 1099s, 5 of the 12 contractors you paid are already at or over the USD 2,000 threshold, and the final list is settled at year end for the February 1, 2027 deadline.",
"open_items": [
{
"ref": "F1,R26,R38,R51",
"issue": "Three purchases sit in Uncategorized Expense: Home Depot USD 640 on 2026-05-09, B&H Photo USD 1,150 on 2026-06-30 and Quillstead Print Shop (paid through Square) USD 480 on 2026-08-21, USD 2,270 in all.",
"effect": "They are left out of net profit, so at the 34.58% combined marginal rate they could move the annual estimate by about USD 785 either way. The Quillstead Print Shop payment could also be a contractor payment that bears on the 1099 list.",
"fix": "Code each row to a real account from the receipt: B&H Photo is likely equipment or supplies (if equipment, flag it so your accountant can decide how to depreciate or expense it), Home Depot to supplies or repairs, and Quillstead Print Shop to printing or outside services, noting that it was paid by card through Square."
},
{
"ref": "F2,R43,R44",
"issue": "Two FedEx charges of USD 95 each are posted on 2026-07-22 to Shipping and postage, same date, payee and amount.",
"effect": "If one is a double posting, expenses are overstated by USD 95 and net profit is understated by USD 95, so the estimate would come out slightly low.",
"fix": "Match both rows against the July bank or card statement; if only one FedEx charge of USD 95 cleared that day, delete the duplicate row, otherwise keep both and attach the second receipt."
}
],
"gate_statement": "Three transactions are still uncategorized, totalling USD 2,270. Until those are coded, your estimate could be off by roughly USD 785 either way. Categorize them, or proceed and it becomes a stated assumption.",
"basis_statement": "Built on books closed through August 31, 2026, not the raw register.",
"mode": "quarterly",
"mode_reason": "The missed Q3 installment due September 15, 2026 is already late and Q4 is due January 15, 2027, so the quarterly packet comes first; the 1099 list is a year-end job for the February 1, 2027 deadline, and today's totals are only year to date.",
"assumptions": [
"The business is treated as a sole proprietorship, as entered; your accountant can change this if the structure is different.",
"Federal tax is figured at the 22% default rate; your accountant sets the right bracket.",
"The 3 uncategorized rows totalling USD 2,270 are left out of net profit until coded, a swing of about USD 785 on the annual estimate."
],
"accountant_checklist": [
"Confirm whether any 2026 estimated payments were made for Q1 (due 2026-04-15) or Q2 (due 2026-06-15), and compute penalty and interest on the missed Q3 installment due 2026-09-15.",
"Decide the bracket to use in place of the 22% assumed rate.",
"Decide how the B&H Photo purchase of USD 1,150 (R38) is treated once it is coded, if it turns out to be equipment."
],
"prescan_responses": [
{
"id": "F1",
"status": "confirmed",
"reason": "USD 2,270 of uncategorized spending moves the estimate by about USD 785 and is carried as an open item and an assumption."
},
{
"id": "F2",
"status": "confirmed",
"reason": "The R43 and R44 FedEx pair may double-count USD 95 of expense and needs a statement check."
},
{
"id": "F3",
"status": "confirmed",
"reason": "The packet relies on the 2026 figures, so the need to confirm them at irs.gov is stated once in the assumptions."
}
]
}
nec:
{
"lane": "nec",
"headline": "Tax year 2026: five payees are at or over the USD 2,000 threshold so far and look like 1099 candidates, and one missing W-9 (Pellucid Owl Photo) blocks filing.",
"summary": "Through August 31, 2026 you paid 12 contractors: 5 are at or over the USD 2,000 threshold (one rent form, four for services, two of which your accountant must rule on) and 2 more sit just under it, and Pellucid Owl Photo still owes you a W-9 before the February 1, 2027 filing deadline. The books stop at August 31, so these are year-to-date totals and more payees may cross the line by December 31. On your question: the Q3 estimated payment was due September 15, 2026, so it is now late; this packet is the 1099 list only, so the amount owed comes from the app's quarterly packet, and any penalty and interest are for your accountant to compute.",
"candidates": [
{
"ref": "V1",
"form": "1099-MISC",
"total": 8800,
"w9": "on_file",
"note": "USD 8,800 of studio rent paid by check to Quorvale Studios LLC over 8 payments is reported as rent on a 1099-MISC; as an LLC it is reportable unless its W-9 shows it is taxed as a corporation, which the accountant confirms from the W-9 on file."
},
{
"ref": "V5",
"form": "accountant_decides",
"total": 2400,
"w9": "missing",
"note": "USD 2,400 of contract labor would be a 1099-NEC, but both payments went through PayPal, which may issue its own 1099-K, so the accountant decides whether you file one too; the W-9 is missing either way (F4)."
},
{
"ref": "V6",
"form": "none",
"total": 1500,
"w9": "missing",
"note": "USD 1,500 of contract labor in one Venmo payment is under the USD 2,000 threshold, so no form for now; check that no payment to Dana Reyes was missed or paid another way, since more work before December 31 could cross the line."
}
],
"excluded": [],
"w9_requests": [
{
"ref": "V5",
"text": "Ask Pellucid Owl Photo for a completed W-9 before filing, showing its full legal name and tax classification: you paid USD 2,400 for photography work through PayPal between May 14 and August 13, 2026, and a 1099-NEC may be due by February 1, 2027."
}
],
"duplicate_reviews": [
{
"ref": "D2",
"text": "Bob Nguyen (V8) and Robert Nguyen (V11) may be the same person, since Bob is a common short form of Robert. Combined USD 2,150, which is over the USD 2,000 threshold together and under it apart. If they are one payee, a 1099-NEC and a W-9 are likely needed; if they are two people, neither needs a form on current totals. Confirm before filing; they are not merged."
}
],
"corporate_reviews": [
{
"ref": "V3",
"text": "Zentravo Web Works Inc. received USD 6,200 for professional services in 2 ACH payments. The name looks like a corporation, which is generally exempt from 1099-NEC reporting; the accountant checks the tax classification on the W-9 on file and decides whether to file."
}
],
"processor_note": "Pellucid Owl Photo (V5, PayPal), Dana Reyes (V6, Venmo) and Google Ads (V7, credit card) were paid entirely by card or a payment processor. The processor or card network may issue its own 1099-K for these payments; the accountant decides whether a 1099-NEC is also needed, which matters now only for Pellucid Owl Photo at USD 2,400.",
"deadline_note": "1099-NEC and 1099-MISC forms for 2026 are due February 1, 2027, and that date has not passed. The books run only through August 31, 2026, so every figure here is a year-to-date total; the list needs rechecking once December is closed.",
"assumptions": [
"The USD 2,000 1099-NEC and 1099-MISC threshold, the USD 1,333 near-threshold floor and the February 1, 2027 due date are 2026 figures that must be confirmed at irs.gov before relying on them.",
"Totals are year-to-date through August 31, 2026; payees under the threshold now may cross it by December 31.",
"Payees are kept separate exactly as named in the ledger; the two possible duplicates (D1, D2) are not merged."
],
"accountant_checklist": [
"Collect a W-9 from Pellucid Owl Photo (V5) before February 1, 2027.",
"Decide whether Zentravo Web Works Inc. (V3) is exempt as a corporation, using the classification on its W-9.",
"Decide whether a 1099-NEC is needed for Pellucid Owl Photo (V5) alongside any 1099-K from PayPal."
],
"prescan_responses": [
{
"id": "F2",
"status": "confirmed",
"reason": "Rows R43 and R44 may be one payment posted twice; if either is a contractor or rent payment, that payee's total is overstated until it is fixed."
},
{
"id": "F4",
"status": "confirmed",
"reason": "Pellucid Owl Photo (V5) is at USD 2,400 with no W-9, covered in w9_requests."
},
{
"id": "F6",
"status": "confirmed",
"reason": "Bob and Robert Nguyen (V8, V11) are covered in D2; together they cross the threshold, apart they do not."
},
{
"id": "F9",
"status": "confirmed",
"reason": "V5, V6 and V7 were paid by card or processor; covered in processor_note, and it decides the form for V5."
},
{
"id": "F10",
"status": "confirmed",
"reason": "The books end August 31, 2026, so these are year-to-date totals and the near payees V6 and V7 could still cross."
}
]
}
quarterly:
{
"lane": "quarterly",
"headline": "Q4 2026 estimate: USD 16,116, due January 15, 2027, plus the missed Q3 installment of USD 8,039 that was due September 15, 2026.",
"summary": "The missed September payment is its own catch-up line of USD 8,039, and the Q4 installment is USD 16,116, so the two together come to USD 24,155 before any penalty or interest, which your accountant computes. Both figures are built on a year-to-date net of USD 62,000 through August 31, 2026, projected over 12 months to USD 93,000, for a total 2026 liability of USD 32,155 against USD 8,000 paid so far. Who needs a 1099 is a separate year-end check in the 1099 lane, due February 1, 2027 (2027-02-01); the biggest caveat here is a possible double posting (R43, R44) and USD 2,270 of uncategorized items that could move the annual estimate by about USD 785 either way.",
"quarter": "Q4",
"payment_due": 16116,
"due_date": "2027-01-15",
"catch_up": [
{
"quarter": "Q3",
"amount": 8039,
"due_date": "2026-09-15",
"text": "The Q3 installment of USD 8,039 was due September 15, 2026 and no payment is recorded against it, so it is late. It accrues penalty and interest from its own due date until it is paid; your accountant computes those amounts, and paying it now, separately from the Q4 installment, stops further accrual on this line."
}
],
"se_text": "Self-employment tax is figured on an SE base of USD 85,886 from the projected USD 93,000 net, which is under the 2026 wage base of USD 184,500, so the full 15.3% applies and gives USD 13,141. Half of that, USD 6,571, is deducted before income tax.",
"federal_text": "After the deductible SE half, adjusted net is USD 86,429; at the assumed 22% federal rate that is USD 19,014 of income tax, for a total 2026 liability of USD 32,155 and a required installment of USD 8,039 per quarter. The 22% assumption matches the single-filer bracket the browser found for this income, but your accountant confirms the right bracket once filing status and deductions are known.",
"safe_harbor_text": "The safe-harbour rule protects you from the underpayment penalty if your 2026 payments reach 100% of last year's total tax (110% if last year's AGI was over USD 150,000) or 90% of this year's tax. No prior-year tax was given, so the floor cannot be computed; your accountant needs your 2025 return to check whether a lower payment would be safe.",
"basis_statement": "Built on books closed through August 31, 2026, not the raw register; 3 uncategorized transactions totalling USD 2,270 and USD 12,000 of owner draws are left out of net profit.",
"assumptions": [
"Federal income tax is figured at the assumed 22% rate; your accountant can change it once filing status, other household income and deductions are known.",
"The business is treated as a sole proprietorship reporting on Schedule C, as assumed from the owner's description, not confirmed.",
"Annual net is projected from 8 months of books: USD 62,000 year to date divided by 8 and multiplied by 12, giving USD 93,000; a slower or busier last four months changes every figure.",
"State and local income taxes are not included; this is the federal estimate only.",
"The deductible half of self-employment tax, USD 6,571, is applied before income tax.",
"No other deductions are applied: QBI, retirement contributions, home office, vehicle, depreciation and self-employed health insurance would all lower the estimate if your accountant confirms them.",
"2026 figures as published by the IRS and SSA for 2026 are used, including the USD 184,500 wage base; confirm each figure at irs.gov before relying on it.",
"The 3 uncategorized transactions (USD 2,270) are left out, and the rows R43 and R44 are counted as posted, both of which your accountant may adjust."
],
"accountant_checklist": [
"Compute the penalty and interest on the missed Q3 installment of USD 8,039 due September 15, 2026, and on the Q1 and Q2 payments of USD 4,000 each against a required USD 8,039.",
"Get the 2025 return to test the safe-harbour floor before the Q4 payment of USD 16,116 due January 15, 2027.",
"Confirm the 22% bracket and whether QBI, a SEP-IRA or other retirement contribution, home office, vehicle or self-employed health insurance deductions apply."
],
"prescan_responses": [
{
"id": "F1",
"status": "confirmed",
"reason": "Rows R26, R38 and R51 (USD 2,270) are left out of net profit and could move the annual estimate by about USD 785 either way; they are listed as an assumption and a checklist item."
},
{
"id": "F12",
"status": "confirmed",
"reason": "Q3 had no payment by September 15, 2026 and is shown as its own catch-up line of USD 8,039; penalty and interest are left to the accountant."
},
{
"id": "F13",
"status": "confirmed",
"reason": "Without last year's tax the safe-harbour floor cannot be computed, so the accountant needs the 2025 return to check it."
}
]
}
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused: it executes with a reduced output cap and returns truncated: true. What you
hold is a prefix of the reply. The web page closes the cut-off JSON (Recon.closeJson),
shows the sections that arrived and says how many it recovered - out of ten for a books check,
twelve for a 1099 list and thirteen for an estimate summary. A truncated 1099 list can be missing
candidates and a truncated estimate its catch-up lines, so check the flag before treating a reply as
complete, then resubmit with the attempt suffix on the Idempotency-Key incremented
(tax-prep-desk:nec:<hash>:a2).