Drive Seven Envelopes from your own code
The one metered task, plan, is available over HTTP. It takes a household's cash lines for a card-down week (a week when cards, phone wallets and banking apps stop working) together with the page's own figures - the seven day envelopes filled from the tin, the day it runs dry, the savings layers in months - and returns a verdict (exposed, thin or ready), the lines that could shrink for a week, what each layer is for, the next moves in time order and what to watch out for. A natural use is a household budgeting sheet that re-checks the tin each season, or a community preparedness page that hands people a printable cash list.
The division of labour first: the page owns the arithmetic; the model owns the judgement and the words; the page re-checks the reply. Reading the lines, landing each on its days, filling the envelopes, the shortfall, the months of essentials and the rule-of-thumb range come from envkit.js, which runs in node as well as in the browser. The model never adds anything up: EnvKit.buildInput hands it every figure. EnvKit.check holds each reply to them: the verdict by the rule, four layers in order, a this-week top-up of the tin first when the tin is short, every trim a real line, and no number, capitalised name, he/she, field name or bank name that the input does not hold.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"seven-envelopes"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, lines, figures) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: writing the plan is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the verdict rule
Every run sends task: plan (the only task; a missing or unknown one is treated as plan). The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:
- exposed - the tin fully covers fewer than 3 of the 7 days, or the tin plus instant-access savings hold less than one month of essentials.
- ready - the tin covers all 7 days and all four layers together reach the low end of the rule-of-thumb range (3-6 months for two or more steady incomes, 6-9 for one steady income or variable pay, 6-12 for self-employed or retired households; estimates, not advice).
- thin - everything else.
The declared input schema carries scalars only, so the array- and object-valued fields (lines, days, figures) travel JSON-encoded as strings (EnvKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | plan. Required. |
currency | string | $, £, € or "". |
household | string | People at home, 1-12. |
income, income_label | string | steady, one_income, variable, self_employed or retired, and its plain label. |
lines | string (JSON) | [{id, item, amount, when, days, week_total}] - ids L1.., up to 30 lines; amount per time; days are day names Monday-Sunday. Required. |
days | string (JSON) | Seven {day, need, covered, lines} - each envelope's need and what the tin covers, filled in order from Monday. |
figures | string (JSON) | {week_need, cash_now, days_covered, dry_day, shortfall, spare_cash, monthly, instant, notice, longer, total, months_covered, quick, quick_months, target_low, target_high, target_low_amount, target_high_amount, gap_to_low}, every value a string; months are rounded down to one decimal. Required. |
notes | string | Free text from the household, up to 400 characters. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, week ({note, trims: [{ref, how}]}), tiers (four {tier, role, advice} in the order tin, instant, notice, longer), moves (2-5 {step, when, tier}, when one of this-week, this-month, next-3-months) and watch_outs (0-3 strings). Parse with EnvKit.parseReply, then normalize(reply), then check(reply, built) where built = EnvKit.buildInput(form); md(reply, built, form) writes the Markdown and csv(built) the printable cash list.
Worked examples
Replies produced against the app's prompt for the three sample households (made up; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict.
a card-only family of four: the tin runs dry on Monday - exposed
Input:
{
"task": "plan",
"currency": "$",
"household": "4",
"income": "steady",
"income_label": "Two or more steady incomes",
"lines": "[{\"id\":\"L1\",\"item\":\"Groceries top-up\",\"amount\":\"35\",\"when\":\"3x (Mon, Wed, Sat)\",\"days\":[\"Monday\",\"Wednesday\",\"Saturday\"],\"week_total\":\"105\"},{\"id\":\"L2\",\"item\":\"School lunch money\",\"amount\":\"8\",\"when\":\"Mon to Fri\",\"days\":[\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\"],\"week_total\":\"40\"},{\"id\":\"L3\",\"item\":\"Bus fares\",\"amount\":\"6.50\",\"when\":\"Mon to Fri\",\"days\":[\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\"],\"w ... (741 characters)",
"days": "[{\"day\":\"Monday\",\"need\":\"99.50\",\"covered\":\"40\",\"lines\":[\"L1\",\"L2\",\"L3\",\"L4\"]},{\"day\":\"Tuesday\",\"need\":\"14.50\",\"covered\":\"0\",\"lines\":[\"L2\",\"L3\"]},{\"day\":\"Wednesday\",\"need\":\"49.50\",\"covered\":\"0\",\"lines\":[\"L1\",\"L2\",\"L3\"]},{\"day\":\"Thursday\",\"need\":\"32.50\",\"covered\":\"0\",\"lines\":[\"L2\",\"L3\",\"L5\"]},{\"day\":\"Friday\",\"need\":\"14.50\",\"covered\":\"0\",\"lines\":[\"L2\",\"L3\"]},{\"day\":\"Saturday\",\"need\":\"47\",\"covered\":\"0\",\"lines\":[\"L1\",\"L6\" ... (476 characters)",
"figures": "{\"week_need\":\"257.50\",\"cash_now\":\"40\",\"days_covered\":\"0\",\"dry_day\":\"Monday\",\"shortfall\":\"217.50\",\"spare_cash\":\"0\",\"monthly\":\"3200\",\"instant\":\"1500\",\"notice\":\"0\",\"longer\":\"2000\",\"total\":\"3540\",\"months_covered\":\"1.1\",\"quick\":\"1540\",\"quick_months\":\"0.4\",\"target_low\":\"3\",\"target_high\":\"6\",\"target_low_amount\":\"9600\",\"target_high_amount\":\"19200\",\"gap_to_low\":\"6060\"}",
"notes": "Two kids at primary school. We pay for almost everything by phone. The pharmacy near us takes cash."
}
Reply:
{
"task": "plan",
"verdict": "exposed",
"title": "The tin runs dry on Monday",
"summary": "The tin runs dry on Monday, leaving a $217.50 shortfall for the week. Savings cover 1.1 months against the 3 to 6 month range for two or more steady incomes, with a $6,060 gap to the low end.",
"week": {
"note": "Groceries top-up is the week's biggest cost at $105, falling on Monday, Wednesday and Saturday.",
"trims": [
{
"ref": "L1",
"how": "Buy a smaller top-up and use what's already in the cupboard."
},
{
"ref": "L4",
"how": "Combine trips or walk shorter journeys to cut fuel use this week."
},
{
"ref": "L6",
"how": "Delay the laundromat trip or wash by hand for the week."
}
]
},
"tiers": [
{
"tier": "tin",
"role": "Cash held at home for a week when cards and phone payments stop working.",
"advice": "Top up with $217.50 in small notes to cover the week's shortfall."
},
{
"tier": "instant",
"role": "Instant-access savings for the first month if a bigger emergency hits beyond the week.",
"advice": "Hold $1,500, but it's not yet a full month's essentials against the $3,200 needed."
},
{
"tier": "notice",
"role": "Notice savings for the months after the first, once instant-access runs out.",
"advice": "Currently $0 - nothing is held here yet."
},
{
"tier": "longer",
"role": "Longer-term savings for whatever is needed beyond the target range.",
"advice": "Hold $2,000 here; the gap to the target range sits in the tin and instant layers first."
}
],
"moves": [
{
"step": "Add $217.50 in small notes to the tin.",
"when": "this-week",
"tier": "tin"
},
{
"step": "Add to instant-access savings toward closing the $6,060 gap to the target range.",
"when": "this-month",
"tier": "instant"
},
{
"step": "Keep building savings so the total moves toward the 3 to 6 month range.",
"when": "next-3-months",
"tier": "notice"
}
],
"watch_outs": [
"Fuel and groceries both fall on Monday, the day the tin runs dry.",
"Most spending is paid by phone, which stops working in a card-down week.",
"The pharmacy accepts cash, so keep enough for the Thursday prescription."
]
}
a freelancer with savings but little cash: dry on Saturday - thin
Input:
{
"task": "plan",
"currency": "£",
"household": "1",
"income": "self_employed",
"income_label": "Self-employed or freelance",
"lines": "[{\"id\":\"L1\",\"item\":\"Food shop\",\"amount\":\"30\",\"when\":\"2x (Mon, Fri)\",\"days\":[\"Monday\",\"Friday\"],\"week_total\":\"60\"},{\"id\":\"L2\",\"item\":\"Coffee with clients\",\"amount\":\"4.50\",\"when\":\"Mon to Fri\",\"days\":[\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\"],\"week_total\":\"22.50\"},{\"id\":\"L3\",\"item\":\"Train to the studio\",\"amount\":\"9.80\",\"when\":\"Tue, Thu\",\"days\":[\"Tuesday\",\"Thursday\"],\"week_total\":\"19.60\"},{\"id\":\"L4\",\"item\":\"Cat ... (591 characters)",
"days": "[{\"day\":\"Monday\",\"need\":\"42.50\",\"covered\":\"42.50\",\"lines\":[\"L1\",\"L2\",\"L4\"]},{\"day\":\"Tuesday\",\"need\":\"14.30\",\"covered\":\"14.30\",\"lines\":[\"L2\",\"L3\"]},{\"day\":\"Wednesday\",\"need\":\"4.50\",\"covered\":\"4.50\",\"lines\":[\"L2\"]},{\"day\":\"Thursday\",\"need\":\"14.30\",\"covered\":\"14.30\",\"lines\":[\"L2\",\"L3\"]},{\"day\":\"Friday\",\"need\":\"34.50\",\"covered\":\"34.50\",\"lines\":[\"L1\",\"L2\"]},{\"day\":\"Saturday\",\"need\":\"15\",\"covered\":\"9.90\",\"lines\":[\"L5\"]},{\" ... (471 characters)",
"figures": "{\"week_need\":\"125.10\",\"cash_now\":\"120\",\"days_covered\":\"5\",\"dry_day\":\"Saturday\",\"shortfall\":\"5.10\",\"spare_cash\":\"0\",\"monthly\":\"1800\",\"instant\":\"6000\",\"notice\":\"5000\",\"longer\":\"0\",\"total\":\"11120\",\"months_covered\":\"6.1\",\"quick\":\"6120\",\"quick_months\":\"3.4\",\"target_low\":\"6\",\"target_high\":\"12\",\"target_low_amount\":\"10800\",\"target_high_amount\":\"21600\",\"gap_to_low\":\"0\"}",
"notes": "Invoices get paid by bank transfer, usually at the end of the month. I live alone and don't drive."
}
Reply:
{
"task": "plan",
"verdict": "thin",
"title": "Covered most of the week, short on Saturday",
"summary": "The tin runs short on Saturday, leaving a shortfall of £5.10. The savings layers cover 6.1 months of essentials, within the 6 to 12 month range for self-employed or freelance income.",
"week": {
"note": "Food shop is the week's biggest cost, at £60.",
"trims": [
{
"ref": "L2",
"how": "Skip the coffee with clients for the week."
},
{
"ref": "L5",
"how": "Skip the takeaway, or make it at home instead."
}
]
},
"tiers": [
{
"tier": "tin",
"role": "What the tin covers in a card-down week when cards and banking stop working.",
"advice": "Add £5.10 to close the shortfall, then keep the tin topped up at this level."
},
{
"tier": "instant",
"role": "Instant-access savings for the first month of a real emergency after the card-down week.",
"advice": "Holds £6,000, which together with the tin covers 3.4 months; keep it topped up rather than drawn down."
},
{
"tier": "notice",
"role": "A notice account for the months after the first month of a real emergency.",
"advice": "Holds £5,000 in the notice account, taking the total to 6.1 months of essentials, within the 6 to 12 month range; hold it steady."
},
{
"tier": "longer",
"role": "Longer-term savings for anything beyond the 6 to 12 month range once it's fully covered.",
"advice": "At £0, nothing needs to move here yet; essentials are already covered by the other layers."
}
],
"moves": [
{
"step": "Add £5.10 in small notes to the tin.",
"when": "this-week",
"tier": "tin"
},
{
"step": "Check the instant-access savings stay at £6,000 so a month of essentials stays covered.",
"when": "this-month",
"tier": "instant"
},
{
"step": "Keep the notice account at £5,000, keeping the total within the 6 to 12 month range.",
"when": "next-3-months",
"tier": "notice"
}
],
"watch_outs": [
"Saturday's takeaway is the day the tin runs short, so plan to cover it another way.",
"Invoices get paid by bank transfer, which won't work in a card-down week."
]
}
a retired couple covered all week, layers in range - ready
Input:
{
"task": "plan",
"currency": "€",
"household": "2",
"income": "retired",
"income_label": "Retired, on a pension",
"lines": "[{\"id\":\"L1\",\"item\":\"Market vegetables\",\"amount\":\"20\",\"when\":\"Wed, Sat\",\"days\":[\"Wednesday\",\"Saturday\"],\"week_total\":\"40\"},{\"id\":\"L2\",\"item\":\"Bakery\",\"amount\":\"4\",\"when\":\"every day\",\"days\":[\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\",\"Saturday\",\"Sunday\"],\"week_total\":\"28\"},{\"id\":\"L3\",\"item\":\"Bus pass top-up\",\"amount\":\"15\",\"when\":\"once (Mon)\",\"days\":[\"Monday\"],\"week_total\":\"15\"},{\"id\":\"L4\",\"item\":\"Pharmacy\",\"amo ... (584 characters)",
"days": "[{\"day\":\"Monday\",\"need\":\"19\",\"covered\":\"19\",\"lines\":[\"L2\",\"L3\"]},{\"day\":\"Tuesday\",\"need\":\"26\",\"covered\":\"26\",\"lines\":[\"L2\",\"L4\"]},{\"day\":\"Wednesday\",\"need\":\"24\",\"covered\":\"24\",\"lines\":[\"L1\",\"L2\"]},{\"day\":\"Thursday\",\"need\":\"4\",\"covered\":\"4\",\"lines\":[\"L2\"]},{\"day\":\"Friday\",\"need\":\"34\",\"covered\":\"34\",\"lines\":[\"L2\",\"L5\"]},{\"day\":\"Saturday\",\"need\":\"24\",\"covered\":\"24\",\"lines\":[\"L1\",\"L2\"]},{\"day\":\"Sunday\",\"need\":\"4\",\"covere ... (443 characters)",
"figures": "{\"week_need\":\"135\",\"cash_now\":\"400\",\"days_covered\":\"7\",\"dry_day\":\"\",\"shortfall\":\"0\",\"spare_cash\":\"265\",\"monthly\":\"2100\",\"instant\":\"9000\",\"notice\":\"6000\",\"longer\":\"12000\",\"total\":\"27400\",\"months_covered\":\"13\",\"quick\":\"9400\",\"quick_months\":\"4.4\",\"target_low\":\"6\",\"target_high\":\"12\",\"target_low_amount\":\"12600\",\"target_high_amount\":\"25200\",\"gap_to_low\":\"0\"}",
"notes": "Both pensions arrive on the 1st. One of us does not drive."
}
Reply:
{
"task": "plan",
"verdict": "ready",
"title": "Covered all week, with room to spare",
"summary": "The tin covers every day of the card-down week, leaving €265 spare. The savings layers total 13 months of essential spending, above the 6 to 12 month range for a retired household on a pension.",
"week": {
"note": "Market vegetables is the week's biggest cash line at €40, and the tin covers it in full.",
"trims": []
},
"tiers": [
{
"tier": "tin",
"role": "The cash in the tin for a card-down week.",
"advice": "At €400, it already covers the whole week with €265 spare; keep topping it back up."
},
{
"tier": "instant",
"role": "Instant-access savings for the first month if a bigger emergency hits.",
"advice": "At €9,000, it holds steady; together with the tin it covers about 4.4 months of essentials."
},
{
"tier": "notice",
"role": "Notice savings for the months after the first one, if the emergency drags on.",
"advice": "At €6,000, it's doing its job; no need to add more right now."
},
{
"tier": "longer",
"role": "Longer-term savings for whatever is left beyond the usual range.",
"advice": "At €12,000, it's fine to leave as is for now."
}
],
"moves": [
{
"step": "Keep about €400 in the tin, in small notes, ready for a card-down week.",
"when": "this-week",
"tier": "tin"
},
{
"step": "Leave the instant-access savings where they are; they're already doing their job.",
"when": "this-month",
"tier": "instant"
},
{
"step": "Check in again in a few months to make sure the balance still feels right.",
"when": "next-3-months",
"tier": "longer"
}
],
"watch_outs": [
"One of you doesn't drive, so check the bus pass top-up is affordable if cash is tight.",
"Both pensions arrive on the same day, so keep that timing in mind for the tin."
]
}
Build the input yourself
Load the script in node (global.window = global; require("./envkit.js")), then:
const K = EnvKit;
const form = K.form({ currency: "$", household: "2", income: "steady", cash: "60", monthly: "2400",
instant: "3000", week: "Groceries | 30 | 2x\nBus fares | 5 | weekdays" });
const r = K.read(form); // r.fill.env: seven envelopes; r.fig: every figure and the verdict
const built = K.buildInput(form);
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.csv(built) // a printable cash list, one row per day per line
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://seven-envelopes.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://seven-envelopes.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// seven-envelopes.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://seven-envelopes.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://seven-envelopes.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { 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 map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// SevenEnvelopes.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class SevenEnvelopes {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://seven-envelopes.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://seven-envelopes.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://seven-envelopes.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://seven-envelopes.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"seven-envelopes"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "seven-envelopes"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "seven-envelopes" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "seven-envelopes"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "seven-envelopes"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "seven-envelopes" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "seven-envelopes"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "seven-envelopes" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "plan", "currency": "$", "household": "4", "income": "steady", "lines": "[{\"id\":\"L1\",\"item\":\"Groceries top-up\",\"amount\":\"35\",\"when\":\"3x (Mon, Wed, Sat)\",\"days\":[\"Monday\",\"Wednesday\",\"Saturday\"],\"week_total\":\"105\"},{\"id\":\"L2\",\"item\":\"School lunch money\",\"amount\":\"8\",\"when\":\"Mon to Fri\",\"days\":[\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\"],\"week_total\":\"40\"}, ...]", "days": "[...]", "figures": "{...}", "notes": "Two kids at primary school. We pay for almost everything by phone. The pharmacy near us takes cash."}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "plan", "currency": "$", "household": "4", "income": "steady", "lines": "[{\"id\":\"L1\",\"item\":\"Groceries top-up\",\"amount\":\"35\",\"when\":\"3x (Mon, Wed, Sat)\",\"days\":[\"Monday\",\"Wednesday\",\"Saturday\"],\"week_total\":\"105\"},{\"id\":\"L2\",\"item\":\"School lunch money\",\"amount\":\"8\",\"when\":\"Mon to Fri\",\"days\":[\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\"],\"week_total\":\"40\"}, ...]", "days": "[...]", "figures": "{...}", "notes": "Two kids at primary school. We pay for almost everything by phone. The pharmacy near us takes cash."}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "plan", currency, household, income, lines, days, figures, notes, ... (see the field table)}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses seven-envelopes:<task>:<inputHash>:a<attempt> (the one task is plan), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="seven-envelopes:plan:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "seven-envelopes:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `seven-envelopes:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "seven-envelopes:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "seven-envelopes:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "seven-envelopes:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "seven-envelopes:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"seven-envelopes:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: seven-envelopes:plan:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "plan", "currency": "$", "household": "4", "income": "steady", "lines": "[{\"id\":\"L1\",\"item\":\"Groceries top-up\",\"amount\":\"35\",\"when\":\"3x (Mon, Wed, Sat)\",\"days\":[\"Monday\",\"Wednesday\",\"Saturday\"],\"week_total\":\"105\"},{\"id\":\"L2\",\"item\":\"School lunch money\",\"amount\":\"8\",\"when\":\"Mon to Fri\",\"days\":[\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\"],\"week_total\":\"40\"}, ...]", "days": "[...]", "figures": "{...}", "notes": "Two kids at primary school. We pay for almost everything by phone. The pharmacy near us takes cash."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "seven-envelopes:plan:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `seven-envelopes:plan:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "seven-envelopes:plan:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "seven-envelopes:plan:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "seven-envelopes:plan:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: seven-envelopes:plan:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"seven-envelopes:plan:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);