Drive Budget Flow from your own code
Everything the page does is available over HTTP. A natural use is a budget you keep in a file or a spreadsheet
export - this month's income lines, categories and debts - where a script recomputes the flow whenever the file
changes, asks for a planning reading, and asks again with replan once you have applied the moves you
accepted.
One thing to be clear about first: the browser owns the arithmetic. The caller computes every
total - income, spending, month-end, group totals, ratios, buffer months, the 12-month projection, the debt payoff
date - and sends them as facts: the input figures object comes from the page, never from the model.
The model reads the flow and returns ranked moves, risks and a ratio read; the app's browser then reconciles every
figure in the reply against its own numbers (each move's from, each risk's figure, each
ratio's pct) and only lets moves that pass be applied. The page's own arithmetic and reconciler ship as
plain scripts you can load in node — see the end of the examples.
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":"budget-flow"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model. The model is gpt-terra; a run is metered, /estimate is free.
Errors:
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 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: readings are metered and need 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 comes first
One system prompt serves two lanes. task picks the contract; a missing or unknown value is read as plan and the reply says so in notes.
| task | When | What the reply does |
|---|---|---|
plan | The first reading of a budget, as it stands now (any what-if levers already moved are part of each amount). | 1 to 5 ranked moves, the risks, one first question and a ratio read. |
replan | After accepted moves were applied: the budget and figures now reflect the budget after the moves. | A headline quoting the before and after figures, 0 to 4 further moves, every risk re-checked against the new figures, and a note for each applied cut of more than 30%. |
Every run body is one JSON object whose values are all strings: the declared input schema is scalar-only. task, budget and figures are required (/estimate warns when one is missing); the rest are optional. task and retry_note are plain strings; every other field is an object or array JSON-encoded as a string — build the object, then stringify each of those fields before sending (the page does this with FlowMath.toWire). Left as raw objects, they produce an input_schema type warning on every run. The shapes below are the decoded values. Money is in major units of budget.currency, per month:
| Field | Type on the wire | What it holds (decoded) |
|---|---|---|
task | string, required | "plan" or "replan". |
budget | JSON string, required | {title, currency, period: "month", start_month: "YYYY-MM", context, incomes, categories, group_totals, debts}. context is what the person said (who it is for, what must not change, what they save for); the page cuts its middle past 1,200 characters. |
budget.incomes | inside budget | [{id, name, amount, base_amount, lever_pct, irregular}]: base_amount is what was typed, amount is after the what-if lever. |
budget.categories | inside budget | [{id, name, group: "needs"|"wants"|"savings"|"debt", amount, base_amount, lever_pct, share_of_income_pct, line_count, largest_lines: [{name, amount}]}], largest first, at most 24 categories and the 3 largest lines of each. |
budget.group_totals | inside budget | {needs, wants, savings, debt}. |
budget.debts | inside budget | [{name, balance, apr}]. |
figures | JSON string, required | Your computed numbers: income, spending, month_end, essentials (needs + debt payments), savings_now, savings_in_12_months, buffer_months, buffer_months_in_12, debt_payoff ("YYYY-MM", "none" or "never"), debt_payoff_months, debt_interest_total, ratios_pct_of_income {needs, wants, savings, debt}, irregular_income_pct. |
clipped | JSON string, optional | What the page cut before sending: {lines_total, lines_sent, lines_cut, categories_cut, context_chars_cut}. |
applied_moves | JSON string, optional | replan only. [{category_id, from, to}]: the moves that were applied. |
figures_before_moves | JSON string, optional | replan only. The same shape as figures, computed before the moves. |
previous | JSON string, optional | replan only. The earlier reading: {headline, moves: [{rank, category_id, from, to}]}. |
retry_note | string, optional | Set by the page only on its one automatic retry after a reply that was not a single valid JSON object: what was wrong with that reply. |
The reply arrives as a JSON string in the finished job's output.output; parsed, it is one object (the same envelope for both lanes, every array present even when empty):
{
"task": "plan | replan",
"headline": "one sentence: the single most important thing about this flow",
"moves": [{ "rank": 1, "category_id": "c7", "category": "Eating out", "from": 515, "to": 360, "why", "tradeoff" }],
"risks": [{ "level": "high|medium|low", "kind": "shortfall|no_buffer|irregular_income|ratio|debt|concentration|other",
"text", "figure": 1 or null, "figure_key": "buffer_months" or null }],
"first_question": "one question ending in a question mark?",
"ratio_read": [{ "group": "needs|wants|savings|debt", "pct": 64.3, "verdict": "ok|high|low", "note" }], // exactly 4, in that order
"notes": ["anything about the input itself"]
}
Each move's category_id is an id from budget.categories and its from is that category's current amount; to is the proposed monthly amount, 0 to 1.5 × base_amount. figure_key is one of month_end, buffer_months, buffer_months_in_12, irregular_income_pct, ratio_needs, ratio_wants, ratio_savings, ratio_debt, debt_payoff_months, savings_now or a category id, and figure must equal your value for it. Totals are never recomputed from the reply: check it against your own figures, as the page does.
Worked examples
The page's bundled family budget: the run input the page builds for each lane, shown in object form for reading, and a saved reply to it. On the wire each object field is a JSON string, so the plan body starts {"task":"plan","budget":"{\"title\":\"Our family budget\",...}","figures":"{\"income\":6100,...}","clipped":"{...}"}.
plan: a family budget with a surplus, a thin buffer and card debt
Input:
{
"task": "plan",
"budget": {
"title": "Our family budget",
"currency": "USD",
"period": "month",
"start_month": "2026-10",
"context": "Two adults, two kids (6 and 9). Alex is salaried; Sam works part-time hours that are steady. The mortgage is fixed until 2029. The after-school club is non-negotiable while we both work. We want a proper emergency fund and to stop paying interest on the credit card.",
"incomes": [
{
"id": "i1",
"name": "Alex salary (take-home)",
"amount": 4200,
"base_amount": 4200,
"lever_pct": 0,
"irregular": false
},
{
"id": "i2",
"name": "Sam part-time (take-home)",
"amount": 1900,
"base_amount": 1900,
"lever_pct": 0,
"irregular": false
}
],
"categories": [
{
"id": "c1",
"name": "Housing",
"group": "needs",
"amount": 1735,
"base_amount": 1735,
"lever_pct": 0,
"share_of_income_pct": 28.4,
"line_count": 2,
"largest_lines": [
{
"name": "Mortgage",
"amount": 1650
},
{
"name": "Home insurance",
"amount": 85
}
]
},
{
"id": "c3",
"name": "Groceries",
"group": "needs",
"amount": 960,
"base_amount": 960,
"lever_pct": 0,
"share_of_income_pct": 15.7,
"line_count": 2,
"largest_lines": [
{
"name": "Supermarket",
"amount": 820
},
{
"name": "Bulk store run",
"amount": 140
}
]
},
{
"id": "c10",
"name": "Debt repayments",
"group": "debt",
"amount": 530,
"base_amount": 530,
"lever_pct": 0,
"share_of_income_pct": 8.7,
"line_count": 2,
"largest_lines": [
{
"name": "Car loan",
"amount": 380
},
{
"name": "Credit card payment",
"amount": 150
}
]
},
{
"id": "c7",
"name": "Eating out",
"group": "wants",
"amount": 515,
"base_amount": 515,
"lever_pct": 0,
"share_of_income_pct": 8.4,
"line_count": 3,
"largest_lines": [
{
"name": "Takeaway",
"amount": 260
},
{
"name": "Restaurants",
"amount": 180
},
{
"name": "Coffee shops",
"amount": 75
}
]
},
{
"id": "c4",
"name": "Childcare",
"group": "needs",
"amount": 480,
"base_amount": 480,
"lever_pct": 0,
"share_of_income_pct": 7.9,
"line_count": 1,
"largest_lines": [
{
"name": "After-school club",
"amount": 480
}
]
},
{
"id": "c5",
"name": "Transport",
"group": "needs",
"amount": 390,
"base_amount": 390,
"lever_pct": 0,
"share_of_income_pct": 6.4,
"line_count": 3,
"largest_lines": [
{
"name": "Fuel",
"amount": 210
},
{
"name": "Car insurance",
"amount": 120
},
{
"name": "Transit passes",
"amount": 60
}
]
},
{
"id": "c8",
"name": "Shopping",
"group": "wants",
"amount": 360,
"base_amount": 360,
"lever_pct": 0,
"share_of_income_pct": 5.9,
"line_count": 2,
"largest_lines": [
{
"name": "Online orders",
"amount": 210
},
{
"name": "Kids clothes",
"amount": 150
}
]
},
{
"id": "c2",
"name": "Utilities and phones",
"group": "needs",
"amount": 355,
"base_amount": 355,
"lever_pct": 0,
"share_of_income_pct": 5.8,
"line_count": 4,
"largest_lines": [
{
"name": "Electricity and gas",
"amount": 140
},
{
"name": "Two phone plans",
"amount": 90
},
{
"name": "Internet",
"amount": 70
}
]
},
{
"id": "c9",
"name": "Saving",
"group": "savings",
"amount": 250,
"base_amount": 250,
"lever_pct": 0,
"share_of_income_pct": 4.1,
"line_count": 2,
"largest_lines": [
{
"name": "Emergency fund transfer",
"amount": 150
},
{
"name": "College fund",
"amount": 100
}
]
},
{
"id": "c6",
"name": "Subscriptions",
"group": "wants",
"amount": 144,
"base_amount": 144,
"lever_pct": 0,
"share_of_income_pct": 2.4,
"line_count": 4,
"largest_lines": [
{
"name": "Two gym memberships",
"amount": 90
},
{
"name": "Netflix",
"amount": 23
},
{
"name": "Spotify family",
"amount": 17
}
]
}
],
"group_totals": {
"needs": 3920,
"wants": 1019,
"savings": 250,
"debt": 530
},
"debts": [
{
"name": "Car loan",
"balance": 11400,
"apr": 6.9
},
{
"name": "Credit card",
"balance": 4800,
"apr": 22.9
}
]
},
"figures": {
"income": 6100,
"spending": 5719,
"month_end": 381,
"essentials": 4450,
"savings_now": 4500,
"savings_in_12_months": 12072,
"buffer_months": 1,
"buffer_months_in_12": 2.7,
"debt_payoff": "2029-08",
"debt_payoff_months": 35,
"debt_interest_total": 2100.6,
"ratios_pct_of_income": {
"needs": 64.3,
"wants": 16.7,
"savings": 4.1,
"debt": 8.7
},
"irregular_income_pct": 0
},
"clipped": {
"lines_total": 25,
"lines_sent": 23,
"lines_cut": 2,
"categories_cut": 0,
"context_chars_cut": 0
}
}
Reply (the parsed output.output):
{
"task": "plan",
"headline": "The month ends 381 ahead, but that surplus is not working: savings of 4500 cover only 1 month of essentials while a 4800 credit card balance at 22.9% APR gets just 150 a month.",
"moves": [
{
"rank": 1,
"category_id": "c9",
"category": "Saving",
"from": 250,
"to": 375,
"why": "Puts 125 more a month into the emergency fund transfer, so the buffer grows faster than the 2.7 months it would reach in 12 months at today's rate; this is the first call on the 381 month-end surplus.",
"tradeoff": "375 is the top of the slider range, so the buffer still takes well over a year to reach 3 months of essentials; keep the 100 college fund line as it is rather than raiding it."
},
{
"rank": 2,
"category_id": "c10",
"category": "Debt repayments",
"from": 530,
"to": 795,
"why": "Sends 265 more a month at the debts; aim all of it at the 22.9% credit card rather than the 6.9% car loan, which roughly triples the 150 card payment and stops most of the interest you said you want to stop paying.",
"tradeoff": "The slider scales both lines in this category, so the extra on the car loan line is notional; in practice keep the car loan at 380 and pay the card the difference, and this uses up most of the remaining surplus."
},
{
"rank": 3,
"category_id": "c7",
"category": "Eating out",
"from": 515,
"to": 360,
"why": "Frees 155 a month, mostly from the 260 takeaway line, and that money is what funds the card and buffer increases above without leaving the month too thin.",
"tradeoff": "Roughly two or three fewer takeaway nights a month for a busy family of four with both adults working; restaurants and coffee can stay close to where they are."
},
{
"rank": 4,
"category_id": "c8",
"category": "Shopping",
"from": 360,
"to": 290,
"why": "Trims 70 a month from the 210 online orders line and adds it to the month-end margin, which can go to the card until it is cleared.",
"tradeoff": "Kids clothes at 150 are close to a need with a 6 and a 9 year old, so the cut has to come from online orders; growth spurts will sometimes push this back up."
}
],
"risks": [
{
"level": "high",
"kind": "no_buffer",
"text": "Savings of 4500 cover 1 month of the 4450 monthly essentials, so one lost paycheck or a large repair goes straight onto the 22.9% card.",
"figure": 1,
"figure_key": "buffer_months"
},
{
"level": "high",
"kind": "debt",
"text": "The credit card carries 4800 at 22.9% APR but receives only 150 of the 530 debt payments; at that pace it is the most expensive money in this budget and keeps the all-debt payoff out at 35 months.",
"figure": 35,
"figure_key": "debt_payoff_months"
},
{
"level": "medium",
"kind": "ratio",
"text": "Needs take 64.3% of take-home pay, driven by the fixed 1650 mortgage and the after-school club you have ruled out changing, so this is structural until 2029 rather than something to cut now.",
"figure": 64.3,
"figure_key": "ratio_needs"
},
{
"level": "medium",
"kind": "ratio",
"text": "Savings run at 4.1% of income and, with debt payments at 8.7%, the two together are well under the usual 20%, even though 381 is left over each month.",
"figure": 4.1,
"figure_key": "ratio_savings"
}
],
"first_question": "Is the 381 left over at month end really there each month, or does it already get spent on things this budget does not show?",
"ratio_read": [
{
"group": "needs",
"pct": 64.3,
"verdict": "high",
"note": "Fixed mortgage and the after-school club; structural, not a cutting target."
},
{
"group": "wants",
"pct": 16.7,
"verdict": "ok",
"note": "Within range; eating out at 515 is the main lever."
},
{
"group": "savings",
"pct": 4.1,
"verdict": "low",
"note": "250 a month against a 1 month buffer."
},
{
"group": "debt",
"pct": 8.7,
"verdict": "ok",
"note": "In range, but only 150 of it reaches the 22.9% card."
}
],
"notes": [
"2 of 25 lines were not sent: Utilities and phones and Subscriptions each show 3 of their 4 lines, so those categories were read from their totals.",
"Subscriptions (144) include two gym memberships at 90; worth checking both are used, but it was left out of the moves because the sums involved are small next to the card."
]
}
replan: the same budget after the four moves were applied
Input. The budget is the same one after the moves (Saving 250 → 375, Debt repayments 530 → 795, Eating out 515 → 360, Shopping 360 → 290); categories is cut here to the four the moves touched — the real body carries all 10 — and every other field is exactly as sent:
{
"task": "replan",
"budget": {
"title": "Our family budget",
"currency": "USD",
"period": "month",
"start_month": "2026-10",
"context": "Two adults, two kids (6 and 9). Alex is salaried; Sam works part-time hours that are steady. The mortgage is fixed until 2029. The after-school club is non-negotiable while we both work. We want a proper emergency fund and to stop paying interest on the credit card.",
"incomes": [
{
"id": "i1",
"name": "Alex salary (take-home)",
"amount": 4200,
"base_amount": 4200,
"lever_pct": 0,
"irregular": false
},
{
"id": "i2",
"name": "Sam part-time (take-home)",
"amount": 1900,
"base_amount": 1900,
"lever_pct": 0,
"irregular": false
}
],
"categories": [
{
"id": "c10",
"name": "Debt repayments",
"group": "debt",
"amount": 795,
"base_amount": 530,
"lever_pct": 50,
"share_of_income_pct": 13,
"line_count": 2,
"largest_lines": [
{
"name": "Car loan",
"amount": 570
},
{
"name": "Credit card payment",
"amount": 225
}
]
},
{
"id": "c9",
"name": "Saving",
"group": "savings",
"amount": 375,
"base_amount": 250,
"lever_pct": 50,
"share_of_income_pct": 6.1,
"line_count": 2,
"largest_lines": [
{
"name": "Emergency fund transfer",
"amount": 225
},
{
"name": "College fund",
"amount": 150
}
]
},
{
"id": "c7",
"name": "Eating out",
"group": "wants",
"amount": 360,
"base_amount": 515,
"lever_pct": -30.1,
"share_of_income_pct": 5.9,
"line_count": 3,
"largest_lines": [
{
"name": "Takeaway",
"amount": 181.74
},
{
"name": "Restaurants",
"amount": 125.83
},
{
"name": "Coffee shops",
"amount": 52.43
}
]
},
{
"id": "c8",
"name": "Shopping",
"group": "wants",
"amount": 290,
"base_amount": 360,
"lever_pct": -19.44,
"share_of_income_pct": 4.8,
"line_count": 2,
"largest_lines": [
{
"name": "Online orders",
"amount": 169.17
},
{
"name": "Kids clothes",
"amount": 120.83
}
]
}
],
"group_totals": {
"needs": 3920,
"wants": 794,
"savings": 375,
"debt": 795
},
"debts": [
{
"name": "Car loan",
"balance": 11400,
"apr": 6.9
},
{
"name": "Credit card",
"balance": 4800,
"apr": 22.9
}
]
},
"figures": {
"income": 6100,
"spending": 5884,
"month_end": 216,
"essentials": 4715,
"savings_now": 4500,
"savings_in_12_months": 11592,
"buffer_months": 1,
"buffer_months_in_12": 2.5,
"debt_payoff": "2028-08",
"debt_payoff_months": 23,
"debt_interest_total": 1353.75,
"ratios_pct_of_income": {
"needs": 64.3,
"wants": 13,
"savings": 6.1,
"debt": 13
},
"irregular_income_pct": 0
},
"clipped": {
"lines_total": 25,
"lines_sent": 23,
"lines_cut": 2,
"categories_cut": 0,
"context_chars_cut": 0
},
"applied_moves": [
{
"category_id": "c9",
"from": 250,
"to": 375
},
{
"category_id": "c10",
"from": 530,
"to": 795
},
{
"category_id": "c7",
"from": 515,
"to": 360
},
{
"category_id": "c8",
"from": 360,
"to": 290
}
],
"figures_before_moves": {
"income": 6100,
"spending": 5719,
"month_end": 381,
"essentials": 4450,
"savings_now": 4500,
"savings_in_12_months": 12072,
"buffer_months": 1,
"buffer_months_in_12": 2.7,
"debt_payoff": "2029-08",
"debt_payoff_months": 35,
"debt_interest_total": 2100.6,
"ratios_pct_of_income": {
"needs": 64.3,
"wants": 16.7,
"savings": 4.1,
"debt": 8.7
},
"irregular_income_pct": 0
},
"previous": {
"headline": "The month ends 381 ahead, but that surplus is not working: savings of 4500 cover only 1 month of essentials while a 4800 credit card balance at 22.9% APR gets just 150 a month.",
"moves": [
{
"rank": 1,
"category_id": "c9",
"from": 250,
"to": 375
},
{
"rank": 2,
"category_id": "c10",
"from": 530,
"to": 795
},
{
"rank": 3,
"category_id": "c7",
"from": 515,
"to": 360
},
{
"rank": 4,
"category_id": "c8",
"from": 360,
"to": 290
}
]
}
}
Reply:
{
"task": "replan",
"headline": "The four moves took month_end from 381 to 216 and pulled the debt payoff in from 35 to 23 months, but buffer_months is still 1 and the 216 left over has nowhere assigned to go.",
"moves": [
{
"rank": 1,
"category_id": "c6",
"category": "Subscriptions",
"from": 144,
"to": 100,
"why": "Dropping one of the two gym memberships in the 90 line frees 44 a month; with Saving and Debt repayments already at the top of their slider range, transfer it by hand to the credit card each month.",
"tradeoff": "One adult loses a gym membership; Netflix and Spotify family stay as they are."
},
{
"rank": 2,
"category_id": "c3",
"category": "Groceries",
"from": 960,
"to": 900,
"why": "A 60 trim, about 6%, mostly from the 820 supermarket line, adds to the month-end margin that should be swept to the emergency fund on payday.",
"tradeoff": "Tighter meal planning for four people, and harder to hold now that eating out is down to 360 and more meals happen at home."
}
],
"risks": [
{
"level": "high",
"kind": "no_buffer",
"text": "The buffer is still 1 month of essentials, and because the higher debt payments count as essentials, 12 months of saving now reaches only 2.5 months instead of 2.7.",
"figure": 2.5,
"figure_key": "buffer_months_in_12"
},
{
"level": "medium",
"kind": "debt",
"text": "Debt payoff is now 23 months away instead of 35, but only if the extra 265 actually goes to the 22.9% card rather than being spread across the car loan as the slider does.",
"figure": 23,
"figure_key": "debt_payoff_months"
},
{
"level": "medium",
"kind": "ratio",
"text": "Needs still take 64.3% of income; the moves did not touch the fixed mortgage or the after-school club, and should not.",
"figure": 64.3,
"figure_key": "ratio_needs"
},
{
"level": "low",
"kind": "other",
"text": "216 is left over each month with no category holding it; unless it is moved to the card or the emergency fund on payday it will drift back into spending.",
"figure": 216,
"figure_key": "month_end"
}
],
"first_question": "Can you set up a standing transfer on payday that sends the 216 left over to the credit card until it is cleared, then to the emergency fund?",
"ratio_read": [
{
"group": "needs",
"pct": 64.3,
"verdict": "high",
"note": "Unchanged; mortgage and after-school club are fixed."
},
{
"group": "wants",
"pct": 13,
"verdict": "ok",
"note": "Down from 16.7 after the eating out and shopping cuts."
},
{
"group": "savings",
"pct": 6.1,
"verdict": "low",
"note": "Up from 4.1; with debt still just under 20% combined."
},
{
"group": "debt",
"pct": 13,
"verdict": "ok",
"note": "Up from 8.7; the card is now being paid down hard."
}
],
"notes": [
"Eating out went from 515 to 360, a cut of just over 30%. It looks sustainable if most of it comes from the 260 takeaway line, but it is the move most likely to slip in busy weeks with both adults working; check it after the first month.",
"Saving and Debt repayments are now at the top of their slider range (1.5 x what you typed), so any further money for the buffer or the card has to come from the month-end surplus by hand."
]
}
The page does the arithmetic and re-checks the reply; the API does not
Over the API you get the reading as JSON. The numbers in budget and figures are yours to compute, and the page's own code is plain scripts you can load in node after global.window = {}: flowmath.js (FlowMath.compute(budget) for the totals, ratios, buffer, projection and payoff; FlowMath.buildInput(comp, {task, applied, before, previous}) for this exact run body) and verdict.js (Verdict.parseResult(text) and Verdict.normalize(obj, comp) for the reply; Verdict.reconcile(result, comp, opts) for the checks the page prints against its own figures and the moves that can be applied; Verdict.appliedList(recon, ids) for the next replan's applied_moves).
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://budget-flow.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://budget-flow.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"]
// budget-flow.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://budget-flow.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://budget-flow.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
}
// BudgetFlow.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 BudgetFlow {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://budget-flow.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://budget-flow.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://budget-flow.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://budget-flow.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":"budget-flow"}' | 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": "budget-flow"})["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: "budget-flow" })).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": "budget-flow"}, 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", "budget-flow"), 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: "budget-flow" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "budget-flow"])["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 = "budget-flow" }))!["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 (not wrapped in {"input": ...}), in wire form: build the object, then JSON-encode budget, figures, clipped, applied_moves, figures_before_moves and previous as strings before sending (the page's FlowMath.toWire). Left as objects, they draw an input_schema type warning on every call. hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra and model the concrete model it resolves to. warnings names a missing required field, an unknown one, or a field of the wrong type.
# input.json: the run input in object form, as in the worked examples
# wire.json: the same input with the object fields JSON-encoded as strings (the input_schema is scalar-only)
jq -c 'with_entries(if (.key | IN("budget","figures","clipped","applied_moves","figures_before_moves","previous"))
and (.value | type | IN("object","array")) then .value |= tojson else . end)' input.json > wire.json
api POST /estimate "$(cat wire.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
WIRE = ("budget", "figures", "clipped", "applied_moves", "figures_before_moves", "previous")
def to_wire(inp): # the input_schema is scalar-only: object fields travel as JSON strings
return {k: json.dumps(v) if k in WIRE and isinstance(v, (dict, list)) else v for k, v in inp.items()}
inp = json.load(open("input.json")) # object form: {"task": "plan", "budget": {...}, "figures": {...}, "clipped": {...}}
wire = to_wire(inp) # {"task": "plan", "budget": "{\"title\": ...}", "figures": "{...}", "clipped": "{...}"}
est = api("POST", "/estimate", wire)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const WIRE = ["budget", "figures", "clipped", "applied_moves", "figures_before_moves", "previous"];
// the input_schema is scalar-only: object fields travel as JSON strings (the page's FlowMath.toWire)
const toWire = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) =>
[k, WIRE.includes(k) && v !== null && typeof v === "object" ? JSON.stringify(v) : v]));
const input = JSON.parse(readFileSync("input.json", "utf8")); // object form: {task, budget, figures, clipped}
const wire = toWire(input);
const est = await api("POST", "/estimate", wire);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // object form; add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
wire := map[string]any{} // the input_schema is scalar-only: object fields travel as JSON strings
for k, v := range input {
switch k {
case "budget", "figures", "clipped", "applied_moves", "figures_before_moves", "previous":
if v != nil { b, _ := json.Marshal(v); v = string(b) }
}
wire[k] = v
}
est, _ := api("POST", "/estimate", wire, 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); // object form
var WIRE = Set.of("budget", "figures", "clipped", "applied_moves", "figures_before_moves", "previous");
Map<String, Object> wire = new LinkedHashMap<>(); // the input_schema is scalar-only: object fields travel as JSON strings
for (var e : input.entrySet())
wire.put(e.getKey(), WIRE.contains(e.getKey()) && (e.getValue() instanceof Map || e.getValue() instanceof List)
? M.writeValueAsString(e.getValue()) : e.getValue());
JsonNode est = api("POST", "/estimate", wire, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
WIRE = %w[budget figures clipped applied_moves figures_before_moves previous]
input = JSON.parse(File.read("input.json")) # object form
# the input_schema is scalar-only: object fields travel as JSON strings
wire = input.to_h { |k, v| [k, WIRE.include?(k) && (v.is_a?(Hash) || v.is_a?(Array)) ? JSON.generate(v) : v] }
est = api("POST", "/estimate", wire)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
const WIRE = ["budget", "figures", "clipped", "applied_moves", "figures_before_moves", "previous"];
$input = json_decode(file_get_contents("input.json"), true); // object form
$wire = []; // the input_schema is scalar-only: object fields travel as JSON strings
foreach ($input as $k => $v) $wire[$k] = in_array($k, WIRE, true) && is_array($v) ? json_encode($v) : $v;
$est = api("POST", "/estimate", $wire);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!.AsObject(); // object form
string[] WIRE = { "budget", "figures", "clipped", "applied_moves", "figures_before_moves", "previous" };
var wire = new JsonObject(); // the input_schema is scalar-only: object fields travel as JSON strings
foreach (var (k, v) in input)
wire[k] = WIRE.Contains(k) && v is JsonObject or JsonArray ? JsonValue.Create(v!.ToJsonString()) : v?.DeepClone();
var est = await Api.Call("POST", "/estimate", wire);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
The same wire-form body. Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses exactly budget-flow:<task>:<hash>:a<attempt>, where <hash> is FlowMath.hashInput(input) over the object form; the samples hash with a SHA-256 prefix instead — any stable hash works as long as a retry reuses it. The job's output.output is the reply as a JSON string — parse it once to get the reply object; truncated: true means the balance cut it short and the string may not parse.
# the key hashes the object form (input.json); the body is the wire form (wire.json)
KEY="budget-flow: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 @wire.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 # a JSON string: one object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "budget-flow:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", wire, {"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"]) # output.output is a JSON string
print(j["charged_credits"], j.get("truncated"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `budget-flow:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", wire, { "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); // output.output is a JSON string
console.log(j.charged_credits, j.truncated, reply.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "budget-flow:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", wire, 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"]) // a JSON string
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "budget-flow:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", wire, 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()); // output.output is a JSON string
System.out.println(j.get("charged_credits") + " " + reply.get("headline"));
require "digest"
key = "budget-flow:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", wire, { "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"]) # output.output is a JSON string
puts [j["charged_credits"], j["truncated"], reply["headline"]].inspect
$key = "budget-flow:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $wire, ["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); // output.output is a JSON string
echo $j["charged_credits"], " ", $reply["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"budget-flow:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", wire, 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>()); // output.output is a JSON string
Console.WriteLine($"{j["charged_credits"]} {reply!["headline"]}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same wire-form body and key. 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: budget-flow:plan:$(shasum -a 256 input.json | cut -c1-16):a1" --data @wire.json
# inp and wire from step 4: the key hashes the object form, the body is the wire form
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(wire).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "budget-flow:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).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])
// input and wire from step 4: the key hashes the object form, the body is the wire form
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(wire), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `budget-flow:${input.task}:${createHash("sha256").update(JSON.stringify(input)).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));
body, _ := json.Marshal(wire) // add "bufio", "strings" to the imports; raw and wire from step 4
rs := sha256.Sum256(raw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "budget-flow:"+input["task"].(string)+":"+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:]) } }
// input and wire from step 4: the key hashes the object form, the body is the wire form
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "budget-flow:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(M.writeValueAsString(wire))).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
# input and wire from step 4: the key hashes the object form, the body is the wire form
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "budget-flow:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1")
req.body = JSON.generate(wire)
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
// $input and $wire from step 4: the key hashes the object form, the body is the wire form
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($wire), CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: budget-flow:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 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);
// input and wire from step 4: the key hashes the object form, the body is the wire form
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(wire.ToJsonString(), System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"budget-flow:{input["task"]}:{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);