Run Retire Runway from your own code
A planning model, not financial advice. Retire Runway projects one household's retirement plan and writes about it in plain language. It never names a fund, a product, a provider or a security, never promises an outcome, and frames every action as an option to discuss. The intake lane never invents a number - every figure on the sheet comes from the notes (or a month-to-year conversion), and anything unclear comes back as a question. The review lane never computes a new figure - every number it quotes is one the page printed in facts.
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page does the arithmetic for free - it reads the plan sheet and projects it year by year in today's dollars (pay with real raises, Social Security adjusted for the claiming age, pensions, a fixed mortgage, college costs, one-off events, withdrawals in the order cash, taxable, pre-tax, Roth, required minimum distributions), runs a seeded 1,000-path Monte Carlo for the probability of success against an 85% target, finds the sustainable retirement spending at 85%, and runs the stress test and the Social Security claiming table on the same paths. That is rrplan.js, and every finding carries its rule id, line and fix. The run either turns first-meeting notes into the plan sheet ("intake") or writes the plan review from the page's numbers ("review"); the page then holds the reply to its sources (rrreply.js): every number grounded, the verdict in the page's band, every scenario read, every error and warning answered.
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"retire-runway"}), 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 field the run needs (task, plan, facts) is missing or not a string. Send every field as a string; focus may be "". |
| 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: both lanes are metered and need a personal sign-in. |
| 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
Two lanes - "intake" (a first meeting's notes in plan, written in anyone's words, turned into the plan sheet the page projects: people, accounts, spending, Social Security, pensions, mortgage, education, events, assumptions, insurance and estate, plus the questions the household must answer and anything the sheet has no place for) and "review" (a plan sheet in plan plus the page's projection of it in facts, turned into the plan review: a verdict, the drivers, a reading of every stress test and the claiming table, prioritised options to discuss, education, protection, questions, findings and answers to the page's own findings). The RR- rules belong to the review. A missing or unknown task is answered as intake when plan has no People: section and as review otherwise, with a caveat saying so. Both lanes send the same fields.
The input
Every field is a string.
| Field | Required | What |
|---|---|---|
task | yes | "intake" or "review". The model answers that task and only that task. |
plan | yes | for "intake", the notes as written (free text); for "review", a plan sheet in the sheet format that the page can project (each person with an age, at least one account with a balance, the yearly spending). The page sends up to 12,000 characters - the numbers, not whole statements. |
focus | no | what the household wants weighed, such as "we want to stop at 60"; the page clips it to 600 characters. Send "" when there is none. |
facts | yes | the page's work on the same plan, built in the browser by rrplan.js (RrPlan.facts(), via buildInput). For "intake": what the page could read from the notes and the defaults it uses for missing values. For "review": the whole projection - see the facts format. |
retry_note | no | present only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise. |
The plan sheet format
plan is plain text. The sheet the page reads (and the one "intake" produces) has header lines and sections: Plan: and Year:, then People: (name, age, retire at, plan to, salary), Accounts: (name, balance, type - pre-tax for a 401(k), 403(b), 457, TSP or traditional IRA, roth, taxable or cash - and what is added a year), Spending: and Retirement spending:, Social Security: (the benefit at full retirement age, 67, and the claiming age), Pensions:, Mortgage:, Education:, Events:, Assumptions: (inflation, raises, stocks %, stock return, bond return, tax rate), Insurance: and Estate:. All money is in today's dollars.
Plan: The Shah household
Year: 2026
People:
- Priya: age 52, retire at 62, plan to 95, salary 96,000
- Tom: age 55, retire at 63, plan to 95, salary 72,000
Accounts:
- Priya 401(k): 410,000 pre-tax, adding 23,000 a year
- Joint brokerage: 140,000 taxable, adding 6,000 a year
- Savings account: 35,000 cash
Spending: 84,000 a year
Retirement spending: 80,000 a year
Social Security:
- Tom: 2,400 a month at 67, claim at 62
Mortgage: 2,100 a month until 2034
Education:
- Maya: age 14, 32,000 a year for 4 years from 18, 529 balance 48,000, adding 3,000 a year
Assumptions:
- Stocks: 80%
- Stock return: 9%
Estate:
- Wills: 2015
- Beneficiaries reviewed: 2019
A value the sheet leaves out takes the page's default: inflation 2.5%, raises 3%, stocks 60%, stock return 7%, bond return 4%, tax rate 15%, retire at 67, plan to 95, Social Security claimed at 67. Free notes ("Chidi is 47, Ngozi 45 ... 401k (Chidi) around 305k") are fine for "intake": the page finds no sheet sections in them and the model builds the sheet. Every household, name and number on this page is invented for the samples.
The facts format
facts is the page's own work, written as plain text. The page builds it in the browser with rrplan.js (RrPlan.buildInput(state, task) calls RrPlan.facts(scan, task)), and the model may quote only the numbers it prints: the review answers every error and warn rule id it finds there and the page then holds the reply to the same text. So an API client should send facts built the same way - from rrplan.js run under node (Build the input yourself; the Monte Carlo is seeded, so the same sheet gives the same text), or the text the page shows for the same plan - not an empty string and not numbers of your own.
For "intake" it is short: what the page could read, then the defaults line.
The notes are free text - the page found no plan sheet in them.
Defaults the page uses when a value is missing (do NOT copy them into the sheet): inflation 2.5%, raises 3%, stocks 60%, stock return 7%, bond return 4%, tax rate 15%, retire at 67, plan to 95, Social Security claimed at 67.
When the notes do hold part of a sheet, the first line reads The page read part of a plan sheet from the notes: followed by - person | <name> | age <n|->, - account | <name> | <type> | <$balance> and - spending | <$n> a year lines. For "review" the facts carry the whole projection, line by line:
Plan: <name>. Year <year>. All money in today's dollars (inflation <x>% taken out).
People: - <name> | age <n> | retires at <n> (<year>) | plan to age <n> | salary $<n>
Accounts (total $<n>): - <name> | <pretax|roth|taxable|cash> | $<n> | adding $<n> a year
Spending: ... ; Social Security: ... claiming at <age> = $<n> a month. ; Pension: ... ; Mortgage: ... ; Event: ...
Assumptions used: inflation, raises, stocks (return), bonds (return), cash return, tax rate; average real return, volatility.
Projection (base case, average returns): portfolio at retirement, first-year withdrawal rate, runs short or lasts, balances at 65/75/85/90
Monte Carlo (1,000 market paths): probability of success <x>% (target 85%); 10th percentile, median, 90th percentile at 90.
Sustainable retirement spending at 85% success: $<n> a year (the plan has $<n>).
Scenarios (name | success | median portfolio at 90 | average-return path runs short at age): base case + five stress tests
Social Security claiming age (everyone claims at the same age): - at 62 / 67 / 70 | monthly | success | median at 90
Education (cost in today's dollars): - <child> | $<total> | the 529 covers <x>% | about $<n> a month would fund it
Insurance: ... . Estate: ... .
Findings (rule | severity | line | where | text):
- <rule id> | <error|warn|info> | line <n> | <where|-> | <title>[ (note)]
Simplifications: one effective tax rate ...; withdrawals in the order cash, taxable, pre-tax, Roth; ...
A plan with no findings gets the single line - none. The scenarios are always the same five stress tests from the source skill: retire 2 years early, a 20% market drop in the first retirement year, spending +20%, living 5 years longer (to 100), and long-term care at $100,000 a year for 3 years from 85. A real one, shortened - the facts the page sends for its "couple at 52 and 55 with a child" sample (the Shah household, invented) on the "review" lane:
Plan: The Shah household. Year 2026. All money in today's dollars (inflation 2.5% taken out).
People:
- Priya | age 52 | retires at 62 (2036) | plan to age 95 | salary $96,000
- Tom | age 55 | retires at 63 (2034) | plan to age 95 | salary $72,000
Accounts (total $832,000):
- Priya 401(k) | pretax | $410,000 | adding $23,000 a year
...
Projection (base case, average returns):
- portfolio when everyone has retired (2036): $1,719,000
- first-year withdrawal rate: 3.3%
- the money lasts to the end of the plan; $5,328,000 left at the end
...
Monte Carlo (1,000 market paths): probability of success 88.3% (target 85%); portfolio when Priya is 90: 10th percentile $43,000, median $2,735,000, 90th percentile $10,335,000.
Sustainable retirement spending at 85% success: $83,000 a year (the plan has $80,000).
Scenarios (name | success | median portfolio when Priya is 90 | average-return path runs short at Priya's age):
- Base case | 88.3% | $2,735,000 | never
- Retire 2 years early | 75.9% | $1,467,000 | never
- 20% market drop in the first retirement year | 80.4% | $1,551,000 | never
- Spending +20% | 60.3% | $770,000 | never
- Living 5 years longer (to 100) | 86.3% | $2,735,000 | never
- Long-term care: $100,000 a year for 3 years from age 85 | 82.9% | $2,368,000 | never
Social Security claiming age (everyone claims at the same age):
- at 62 | Priya $2,030, Tom $1,680 a month | success 85.8% | median at 90 $2,746,000
- at 67 | Priya $2,900, Tom $2,400 a month | success 90.2% | median at 90 $2,811,000
- at 70 | Priya $3,596, Tom $2,976 a month | success 93.1% | median at 90 $2,809,000
Education (cost in today's dollars):
- Maya | $128,000 in total | the 529 covers 59% | about $930 a month would fund it (now $250)
...
Findings (rule | severity | line | where | text):
- RR-CASHFLOW | warn | line 15 | 2026 | Working years spend more than they earn (after-tax pay $138,000 vs spending, mortgage, education and savings $152,000)
- RR-EDU | warn | line 28 | Maya | Education goal underfunded (59% funded from the 529; about $930 a month would fund it)
- RR-RETURNS | warn | line 37 | Assumptions | Optimistic return assumption (stocks 9%, bonds 4%)
- RR-WILL | warn | line 45 | Estate | Will missing or not reviewed in five years (last reviewed 2015)
- RR-BENEF | warn | line 46 | Estate | Beneficiaries not reviewed in three years (last reviewed 2019)
- RR-SSEARLY | info | line 20 | Tom | Social Security claimed before full retirement age (claim at 62: 70% of the full benefit)
- RR-NOLTC | info | line 41 | Insurance | No long-term-care plan listed
Simplifications: one effective tax rate on pay, Social Security, pensions and pre-tax withdrawals; ...
Rule ids the page uses, with their severity and area:
| Rule | Severity | Area | Title |
|---|---|---|---|
RR-MISSING | error | household | The plan cannot be projected yet |
RR-DEPLETE | error | outcome | The money runs out in the base case |
RR-SUCCESS | warn | outcome | Probability of success under the 85% target |
RR-WITHDRAW | warn | outcome | High first-year withdrawal rate |
RR-SEQ | warn | outcome | Fragile to a market drop at retirement |
RR-RETURNS | warn | assumptions | Optimistic return assumption |
RR-INFLATION | warn | assumptions | Inflation assumption out of the usual range |
RR-DEFAULTS | info | assumptions | Defaults used for missing assumptions |
RR-CASHFLOW | warn | cashflow | Working years spend more than they earn |
RR-ALLOC | info | assumptions | Heavy in stocks close to retirement |
RR-SSEARLY | info | income | Social Security claimed before full retirement age |
RR-EDU | warn | goals | Education goal underfunded |
RR-NOLIFE | warn | protection | Dependants and no life cover listed |
RR-NOLTC | info | protection | No long-term-care plan listed |
RR-WILL | warn | estate | Will missing or not reviewed in five years |
RR-BENEF | warn | estate | Beneficiaries not reviewed in three years |
RR-NOYEAR | info | household | No plan year (shown on the page, left out of facts) |
A point the page cannot see (a spending figure that looks too low for the household, an assumption that contradicts the notes) comes back as a finding with the rule NEW.
The standard the model applies
The source skill - @anthropics/financial-plan (anthropics/financial-services, Apache-2.0) - is the only standard:
- A plan covers the client profile, cash flow, retirement (accumulation and distribution), goals (education, estate, risk management), scenarios, then prioritised recommendations: savings rate, asset allocation, tax, insurance gaps, estate documents, beneficiary review.
- Be conservative with return assumptions: overestimating returns gives false confidence.
- Always stress-test: a plan that only works in the base case is not a good plan. The target is a probability of success above 85%.
- Social Security timing is a major lever: compare claiming at 62, 67 and 70.
- Tax planning matters as much as returns; plans are living documents, reviewed every year and after major life events.
- Recommendations must fit the household: options to discuss, with their trade-offs, never a sale.
House rules
- The numbers belong to the page. In
"intake"every number on the sheet comes from the notes - only a month/year conversion is allowed, and no page default is copied in. In"review"every number quoted is one the facts print, written the way they print it ($1,719,000,88.3%,62): no differences, sums or new percentages. - Never name a fund, a product, a provider, a ticker or a security, and never promise an outcome. A projection is a probability, not a guarantee.
- Plain, neutral language for the household: options to discuss and weigh, never "you should" or "we recommend". The review says once, in
caveats, that it is not their financial adviser. - Every
errorandwarnfinding in the facts is answered by its rule id inprescan(callagreeordisagree, with a note);infoids need no entry. focusis weighed when given, and the summary says how it shaped the answer.- The reply is ONE JSON object and nothing else - no prose around it, no Markdown fences.
The replies
Both lanes carry task, summary, questions (strings) and caveats (strings). The rest differs by lane. The shapes below are what the page reads after RrReply.normalize(): a missing list comes back empty, an unknown area becomes other, an unknown severity becomes medium, and actions are sorted by priority.
intake
The notes written down as the plan sheet. The intake writes down the household; it does not judge the plan.
summary- one or two sentences: whose plan this is and what the notes leave open.sheet-plan,year(only if the notes give one),people(eachname,age,retire_at- an age or"retired"-plan_to,salary),accounts(eachname,owner,type-pretax,roth,taxableorcash-balance,addinga year),spendingandretirement_spendinga year,social_security(eachname,monthlyat full retirement age,claim_at),pensions(eachname,monthly,from,indexed- a boolean),mortgage(monthly,until),education(eachname,age,per_year,years,start_at,balance,adding),events(eachyear,what,amount- negative for a cost),assumptions(only rates the notes state:inflation,raises,stocks,stock_return,bond_return,tax_rate),insurance(strings) andestate(wills,beneficiaries,trust- a year or"none"). Numbers are plain figures without currency signs; a field the notes do not give is left empty.questions- one per thing the household must confirm or supply.left_out- anything the sheet has no place for, eachitemandwhy.caveats.
{
"task": "intake",
"summary": "",
"sheet": {"plan": "", "year": "", "people": [{"name": "", "age": "", "retire_at": "", "plan_to": "", "salary": ""}], "accounts": [{"name": "", "owner": "", "type": "pretax", "balance": "", "adding": ""}], "spending": "", "retirement_spending": "", "social_security": [{"name": "", "monthly": "", "claim_at": ""}], "pensions": [{"name": "", "monthly": "", "from": "", "indexed": false}], "mortgage": {"monthly": "", "until": ""}, "education": [{"name": "", "age": "", "per_year": "", "years": "", "start_at": "", "balance": "", "adding": ""}], "events": [{"year": "", "what": "", "amount": ""}], "assumptions": {"inflation": "", "raises": "", "stocks": "", "stock_return": "", "bond_return": "", "tax_rate": ""}, "insurance": [""], "estate": {"wills": "", "beneficiaries": "", "trust": ""}},
"questions": [""],
"left_out": [{"item": "", "why": ""}],
"caveats": [""]
}
review
The plan review, written from the facts.
verdict-on_track(probability of success 85% or more),borderline(70% to under 85%) oroff_track(under 70%), from the facts' probability.summary- three or four sentences: where the plan stands, the biggest risk, the strongest lever.drivers- the three to five things that move this plan most, eachfactorandeffect, citing the page's numbers.scenarios- every stress test in the facts (not the base case), eachnameandreading, then the Social Security claiming table as one more entry.actions- three to seven prioritised options to discuss, eachpriority(1, 2, 3 ...),area(savings,spending,retirement_age,social_security,allocation,tax,insurance,estate,beneficiaries,educationorcash_flow),action,whyandeffect(the scenario or table row that shows its size, or"not modelled by the page").educationandprotection- strings: each education goal; each insurance and estate gap.questions;findings(points the page cannot see:ruleNEW,severityhigh/medium/low,title,where,why,fix);prescan(one entry per distincterrororwarnrule id in the facts:rule,callagreeordisagree,note);caveats(the page's simplifications that matter here, and that this is a planning model, not financial advice).
{
"task": "review",
"verdict": "on_track",
"summary": "",
"drivers": [{"factor": "", "effect": ""}],
"scenarios": [{"name": "", "reading": ""}],
"actions": [{"priority": 1, "area": "savings", "action": "", "why": "", "effect": ""}],
"education": "",
"protection": "",
"questions": [""],
"findings": [{"rule": "NEW", "severity": "medium", "title": "", "where": "", "why": "", "fix": ""}],
"prescan": [{"rule": "RR-SUCCESS", "call": "agree", "note": ""}],
"caveats": [""]
}
The page holds either reply to its sources and shows each miss next to the reply as a note. For "intake" it wants every number on the sheet found in the notes (as written, or times or divided by 12), every person named in the notes, the sheet to read back through the page's parser with the same number of people and accounts, spending on the sheet or asked for, an age for each person or a question asking for it, and no advice. For "review" it wants every error and warning answered by rule id, no rule id the page did not report other than NEW, the verdict in the band of the page's probability, every number quoted found in the facts (within 3% for large figures), every stress test read, at least three actions, no product or provider named and no promise, a claiming-age driver or action when Social Security is claimed early, and non-empty education or protection when the page found an education, insurance or estate gap.
Reading the reply
The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in rrreply.js, and closes a stream that stopped mid-object). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'review' - no prose, no code fences." and a new attempt number in the key (see step 5). Step 5 prints the sheet's people and accounts, the questions and what was left out (intake), or the verdict, the stress-test readings and the prioritised actions (review). Have the household read the review with the plan in front of them; it is a planning model, not financial advice.
Intake, then review
The two lanes chain. An "intake" reply's sheet is written back as a plan sheet (RrPlan.sheet(reply.sheet): Plan:, Year:, then People:, Accounts:, Spending: and the other sections) and becomes the new plan. The page projects that sheet for free with its own defaults for anything left empty; once the household has answered the questions, the same sheet goes to "review" with fresh facts built from it.
Worked samples
Two of the page's own samples, with the real saved replies it shows for them. Every household, name and number is invented.
Sample 1 - intake: the Okafors' first meeting
Messy notes from a first meeting: two earners at 47 and 45, six accounts, a mortgage, two children heading for college, Social Security statements, no wills. The page finds no sheet in them, so facts is just the defaults line. The input is exactly what the page sends:
{
"task": "intake",
"plan": "First meeting with Chidi and Ngozi Okafor, 14 Oct.\nChidi is 47, Ngozi 45. Both want to stop at 60 if possible, Ngozi maybe later (she likes her job).\nChidi earns about 118k, Ngozi 64k. Raises have been roughly 3% a year.\n401k (Chidi) around 305k, he puts in 19,500/yr. Ngozi has a 403b of 88k and adds 6k.\nRoth IRAs: Chidi 34k, Ngozi 29k - not adding anything right now.\nBrokerage about 52k. Emergency fund 30k in a high-yield savings account.\nThey spend roughly $7,500 a month, mortgage included? (Ngozi thinks not - mortgage is 2,400/mo on top, paid off 2041.)\nTwo kids: Ada 11 and Emeka 8. Want 4 years of college for both, figure 28k a year each. 529s: Ada 21k, Emeka 12k, 250/mo each.\nSS statements: Chidi 3,100/mo at FRA, Ngozi 2,050/mo. No pensions.\nLife insurance through work only (2x salary). No wills yet - 'we keep meaning to'.\nNgozi's mother may move in at some point.",
"focus": "",
"facts": "The notes are free text - the page found no plan sheet in them.\nDefaults the page uses when a value is missing (do NOT copy them into the sheet): inflation 2.5%, raises 3%, stocks 60%, stock return 7%, bond return 4%, tax rate 15%, retire at 67, plan to 95, Social Security claimed at 67."
}
Reply - the saved reply, abbreviated: long strings are cut and end in ..., accounts keeps 3 of 6, and questions keeps 3 of 22 (the full reply also has 3 left_out items and 4 caveats):
{
"task": "intake",
"summary": "This is the plan for Chidi (47) and Ngozi (45) Okafor, who both hope to stop work at 60, with two children, Ada and Emeka, headed for college. The ...",
"sheet": {
"plan": "The Okafor household",
"year": "",
"people": [
{"name": "Chidi", "age": "47", "retire_at": "60", "plan_to": "", "salary": "118000"},
{"name": "Ngozi", "age": "45", "retire_at": "60", "plan_to": "", "salary": "64000"}
],
"accounts": [
{"name": "Chidi 401(k)", "owner": "Chidi", "type": "pretax", "balance": "305000", "adding": "19500"},
{"name": "Ngozi 403(b)", "owner": "Ngozi", "type": "pretax", "balance": "88000", "adding": "6000"},
{"name": "Chidi Roth IRA", "owner": "Chidi", "type": "roth", "balance": "34000", "adding": "0"}
],
"spending": "90000",
"retirement_spending": "",
"social_security": [
{"name": "Chidi", "monthly": "3100", "claim_at": ""},
{"name": "Ngozi", "monthly": "2050", "claim_at": ""}
],
"pensions": [],
"mortgage": {"monthly": "2400", "until": "2041"},
"education": [
{"name": "Ada", "age": "11", "per_year": "28000", "years": "4", "start_at": "", "balance": "21000", "adding": "3000"},
{"name": "Emeka", "age": "8", "per_year": "28000", "years": "4", "start_at": "", "balance": "12000", "adding": "3000"}
],
"events": [],
"assumptions": {
"inflation": "",
"raises": "3",
"stocks": "",
"stock_return": "",
"bond_return": "",
"tax_rate": ""
},
"insurance": ["Life insurance through work only (2x salary); no individual policy mentioned"],
"estate": {"wills": "none", "beneficiaries": "", "trust": ""}
},
"questions": [
"Does the roughly $7,500 a month of spending include the $2,400 a month mortgage, or is the mortgage on top of it (Ngozi thinks it is on top)?",
"Spending is described as roughly $7,500 a month - what is the closer figure?",
"Ngozi may work past 60 because she likes her job - at what age would she like to plan to stop?"
],
"left_out": [
{"item": "Ngozi's mother may move in at some point", "why": "No timing or cost is given, and the sheet has no place for a possible care or household change; it may become an event once a year and cost are known."}
],
"caveats": [
"Monthly figures were converted to yearly (spending 7,500 a month to 90000; 529 contributions 250 a month to 3000 each). No other ..."
]
}
"About 118k" arrives as 118000 and "roughly $7,500 a month" as 90000 a year (the one conversion allowed); the vagueness goes into questions. Nothing the notes do not say is filled in: plan_to, claim_at, start_at and most assumptions are empty, and the page's defaults are not copied in. The page's reply check finds nothing to note.
Sample 2 - review: the Shah household
A couple at 52 and 55 with a child of 14: $832,000 saved, retiring at 62 and 63, Tom claiming Social Security at 62, 80% in stocks at a 9% return, wills from 2015. The page puts the probability of success at 88.3% (on track) and reports five warnings. Input - the plan is exactly what the page sends; facts is shortened here (each ... stands for lines left out - send the whole text the page builds, about 4,000 characters):
{
"task": "review",
"plan": "Plan: The Shah household\nYear: 2026\n\nPeople:\n- Priya: age 52, retire at 62, plan to 95, salary 96,000\n- Tom: age 55, retire at 63, plan to 95, salary 72,000\n\nAccounts:\n- Priya 401(k): 410,000 pre-tax, adding 23,000 a year\n- Tom IRA: 185,000 pre-tax, adding 7,000 a year\n- Priya Roth IRA: 62,000 roth, adding 7,000 a year\n- Joint brokerage: 140,000 taxable, adding 6,000 a year\n- Savings account: 35,000 cash\n\nSpending: 84,000 a year\nRetirement spending: 80,000 a year\n\nSocial Security:\n- Priya: 2,900 a month at 67, claim at 67\n- Tom: 2,400 a month at 67, claim at 62\n\nPensions:\n- Tom: 900 a month from 63\n\nMortgage: 2,100 a month until 2034\n\nEducation:\n- Maya: age 14, 32,000 a year for 4 years from 18, 529 balance 48,000, adding 3,000 a year\n\nEvents:\n- 2031: new roof, -25,000\n\nAssumptions:\n- Inflation: 2.5%\n- Raises: 3%\n- Stocks: 80%\n- Stock return: 9%\n- Bond return: 4%\n- Tax rate: 18%\n\nInsurance:\n- Priya: term life 750,000 until 2040\n\nEstate:\n- Wills: 2015\n- Beneficiaries reviewed: 2019\n- Trust: none",
"focus": "",
"facts": "Plan: The Shah household. Year 2026. All money in today's dollars (inflation 2.5% taken out).\nPeople:\n- Priya | age 52 | retires at 62 (2036) | plan to age 95 | salary $96,000\n- Tom | age 55 | retires at 63 (2034) | plan to age 95 | salary $72,000\nAccounts (total $832,000):\n- Priya 401(k) | pretax | $410,000 | adding $23,000 a year\n...\nMonte Carlo (1,000 market paths): probability of success 88.3% (target 85%); portfolio when Priya is 90: 10th percentile $43,000, median $2,735,000, 90th percentile $10,335,000.\nSustainable retirement spending at 85% success: $83,000 a year (the plan has $80,000).\nScenarios (name | success | median portfolio when Priya is 90 | average-return path runs short at Priya's age):\n- Base case | 88.3% | $2,735,000 | never\n...\n- Spending +20% | 60.3% | $770,000 | never\n...\nFindings (rule | severity | line | where | text):\n- RR-CASHFLOW | warn | line 15 | 2026 | Working years spend more than they earn (after-tax pay $138,000 vs spending, mortgage, education and savings $152,000)\n...\n- RR-RETURNS | warn | line 37 | Assumptions | Optimistic return assumption (stocks 9%, bonds 4%)\n..."
}
Reply - the saved reply, abbreviated: long strings are cut and end in ..., and lists keep their first entries (the full reply has 5 drivers, 6 scenario readings, 7 actions, 7 questions, 4 findings, 5 prescan answers and 5 caveats):
{
"task": "review",
"verdict": "on_track",
"summary": "The Shah plan shows a probability of success of 88.3% against the 85% target, and on average returns the money lasts to the end of the plan, with $1,719,000 in the ...",
"drivers": [
{"factor": "Retirement spending level", "effect": "The plan spends $80,000 a year in retirement, close to the sustainable $83,000 at 85% success; the Spending ..."},
{"factor": "Retirement date", "effect": "Retiring 2 years early drops success to 75.9% and the median at Priya's 90 to $1,467,000, below the 85% ..."}
],
"scenarios": [
{"name": "Spending +20%", "reading": "Success falls to 60.3% and the median to $770,000, the largest drop in the table; retirement ..."},
{"name": "Social Security claiming age", "reading": "With everyone claiming at the same age, success is 85.8% at 62, 90.2% at 67 and 93.1% at 70; later ..."}
],
"actions": [
{"priority": 1, "area": "cash_flow", "action": "Map the working-year budget to see how the gap between pay and outgoings is being covered today.", "why": "After-tax pay is $138,000 against $152,000 of spending, mortgage, education and savings ...", "effect": "not modelled by the page"},
{"priority": 2, "area": "spending", "action": "Confirm the $80,000 retirement spending figure and discuss keeping it at or below the sustainable level.", "why": "The sustainable retirement spending at 85% success is $83,000 a year, and a spending rise ...", "effect": "Spending +20% scenario: success 60.3%."},
{"priority": 3, "area": "social_security", "action": "Weigh Tom claiming later than 62, alongside Priya's claim at 67, against the household's need for income in the early retirement years.", "why": "Tom's claim at 62 pays $1,680 a month against $2,400 at 67, a lower benefit for the rest ...", "effect": "Social Security claiming table: success 85.8% at 62, 90.2% at 67, 93.1% at 70."}
],
"education": "Maya's education is estimated at $128,000 in total in today's dollars; the 529 covers 59%, and about $930 a month would ...",
"protection": "The wills date from 2015 and the beneficiaries were last reviewed in 2019, both flagged by the page. Priya has term ...",
"questions": [
"How is the gap between after-tax pay of $138,000 and outgoings of $152,000 being covered today?",
"Is $80,000 a year a realistic retirement spending figure, and does it include costs such as travel, health care and car replacement?"
],
"findings": [
{"rule": "NEW", "severity": "high", "title": "Projection counts on savings the cash flow may not support", "where": "Accounts / Spending", "why": "The accounts add yearly contributions (for example $23,000 to Priya's 401(k)) ...", "fix": "Confirm what is actually saved each year and rerun with that figure."}
],
"prescan": [
{"rule": "RR-CASHFLOW", "call": "agree", "note": "After-tax pay of $138,000 against $152,000 of outgoings means the ..."},
{"rule": "RR-EDU", "call": "agree", "note": "The 529 covers 59% of Maya's $128,000; about $930 a month would fund ..."}
],
"caveats": [
"This is a planning model, not financial advice, and we are not your financial adviser; the figures are probabilities, not guarantees."
]
}
Every figure in the reply - 88.3%, $83,000, 60.3%, $1,680, 93.1% - is one the facts print; the verdict matches the page's band; all five warnings (RR-CASHFLOW, RR-EDU, RR-RETURNS, RR-WILL, RR-BENEF) are answered in prescan, and the two info ids need no entry. The page's reply check finds nothing to note.
Build the input yourself
The projection and the reply checker are plain scripts that run under node (global.window = global; require("./rrplan.js"); require("./rrreply.js"), from the app at https://retire-runway.skillsafe.ai/rrplan.js and /rrreply.js). The Monte Carlo is seeded, so the same sheet gives the same facts as the page:
const S = RrPlan, R = RrReply;
const st = { plan: fs.readFileSync("plan.txt", "utf8"), focus: "" };
const b = S.buildInput(st, "review"); // or "intake"; b.ok is false (with b.note) when the plan cannot run
console.log(b.scan.success, b.scan.verdict, b.scan.findings.map(f => f.id)); // 88.3 on_track [ ... ]
const wire = S.toWire(b.input); // task, plan, focus, facts - all strings; POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b) // {notes, warn}: ungrounded numbers, unread scenarios, unanswered findings
// S.sheet(reply.sheet) // an intake reply's sheet as plan text - the plan for a review run
// R.md("The Shah household", b, reply) // the page's numbers, findings and the reply as Markdown
Get a token for the steps below from /tokens.html.
Step 1 - a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://retire-runway.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 = "YOUR_TOKEN" # from https://retire-runway.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"]
// retire-runway.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://retire-runway.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 = "YOUR_TOKEN" // from https://retire-runway.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
}
// RetireRunway.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 RetireRunway {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://retire-runway.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 = +"YOUR_TOKEN" # from https://retire-runway.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, 443, 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 = "YOUR_TOKEN"; // from https://retire-runway.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 = "YOUR_TOKEN"; // from https://retire-runway.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":"retire-runway"}' | 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": "retire-runway"})["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: "retire-runway" })).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": "retire-runway"}, 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", "retire-runway"), 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: "retire-runway" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "retire-runway"])["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 = "retire-runway" }))!["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. Save a worked-sample input above as input.json (task "intake" with the notes, or "review" with a sheet and the whole facts the page builds for it) to follow along.
# input.json: the run input object - {"task": "review", "plan": "Plan: ...\nPeople:\n- ...\nAccounts:\n- ...\nSpending: ...", "focus": "", "facts": "Plan: ...\nMonte Carlo ...\nFindings ..."}
# - see the input table
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": "review", "plan": "Plan: ...", "focus": "", "facts": "Plan: ...\nMonte Carlo ..."}
# - see the input table
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: "intake" or "review", plan, focus, facts} (see the input 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 retire-runway:<task>:<inputHash>:a<attempt> - the lane is part of the key, so an intake and a review are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. Each run is metered on gpt-terra; the job reports charged_credits, and truncated is true when the available balance cut the reply short. The code below parses the JSON object out of the output and prints, for "intake", the summary, the sheet's people, accounts and spending, the questions and what was left out, and for "review", the verdict, the summary, every scenario reading and the options to discuss in priority order.
KEY="retire-runway:$(jq -r .task input.json):$(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}'
jq -r 'if .task == "intake" then
.summary, "", "People:", (.sheet.people[]? | " - \(.name): age \(.age), retire at \(.retire_at), salary \(.salary)"),
"Accounts:", (.sheet.accounts[]? | " - \(.name) (\(.type)): \(.balance), adding \(.adding)"),
"Spending: \(.sheet.spending) a year",
(.questions[]? | "? \(.)"), (.left_out[]? | "left out: \(.item) - \(.why)")
else
"VERDICT: \(.verdict)", .summary, "", "Scenarios:", (.scenarios[]? | " - \(.name): \(.reading)"),
"Options to discuss:", (.actions | sort_by(.priority)[]? | " \(.priority). [\(.area)] \(.action) (\(.effect))"),
(.questions[]? | "? \(.)")
end' reply.json
import hashlib, time
key = "retire-runway:" + 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)
out = j["output"]["output"]
reply = json.loads(out[out.index("{"):out.rindex("}") + 1]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
if reply.get("task") == "intake":
sheet = reply.get("sheet", {})
print(reply.get("summary"), "\n\nPeople:")
for p in sheet.get("people", []):
print(f" - {p['name']}: age {p.get('age', '')}, retire at {p.get('retire_at', '')}, salary {p.get('salary', '')}")
print("Accounts:")
for a in sheet.get("accounts", []):
print(f" - {a['name']} ({a.get('type', '')}): {a.get('balance', '')}, adding {a.get('adding', '')}")
print("Spending:", sheet.get("spending", ""), "a year")
for q in reply.get("questions", []):
print("?", q)
for x in reply.get("left_out", []):
print(f"left out: {x['item']} - {x.get('why', '')}")
else: # review
print("VERDICT:", reply.get("verdict"))
print(reply.get("summary"), "\n\nScenarios:")
for s in reply.get("scenarios", []):
print(f" - {s['name']}: {s.get('reading', '')}")
print("Options to discuss:")
for a in sorted(reply.get("actions", []), key=lambda a: int(a.get("priority", 99))):
print(f" {a.get('priority')}. [{a.get('area', '')}] {a['action']} ({a.get('effect', '')})")
for q in reply.get("questions", []):
print("?", q)
import { createHash } from "node:crypto";
const key = `retire-runway:${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 out = j.output.output;
const reply = JSON.parse(out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1));
console.log(j.charged_credits, j.truncated);
if (reply.task === "intake") {
const sheet = reply.sheet || {};
console.log(reply.summary + "\n\nPeople:");
for (const p of sheet.people || []) console.log(` - ${p.name}: age ${p.age || ""}, retire at ${p.retire_at || ""}, salary ${p.salary || ""}`);
console.log("Accounts:");
for (const a of sheet.accounts || []) console.log(` - ${a.name} (${a.type || ""}): ${a.balance || ""}, adding ${a.adding || ""}`);
console.log("Spending:", sheet.spending || "", "a year");
for (const q of reply.questions || []) console.log("?", q);
for (const x of reply.left_out || []) console.log(`left out: ${x.item} - ${x.why || ""}`);
} else { // review
console.log("VERDICT:", reply.verdict);
console.log(reply.summary + "\n\nScenarios:");
for (const s of reply.scenarios || []) console.log(` - ${s.name}: ${s.reading || ""}`);
console.log("Options to discuss:");
for (const a of [...(reply.actions || [])].sort((x, y) => x.priority - y.priority))
console.log(` ${a.priority}. [${a.area || ""}] ${a.action} (${a.effect || ""})`);
for (const q of reply.questions || []) console.log("?", q);
}
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "strings", "time" to the imports
key := "retire-runway:" + 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 }
}
out := j["output"].(map[string]any)["output"].(string)
var reply map[string]any
json.Unmarshal([]byte(out[strings.Index(out, "{"):strings.LastIndex(out, "}")+1]), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
list := func(v any) []any { a, _ := v.([]any); return a }
obj := func(v any) map[string]any { o, _ := v.(map[string]any); if o == nil { o = map[string]any{} }; return o }
if reply["task"] == "intake" {
sheet := obj(reply["sheet"])
fmt.Printf("%v\n\nPeople:\n", reply["summary"])
for _, x := range list(sheet["people"]) { p := obj(x); fmt.Printf(" - %v: age %v, retire at %v, salary %v\n", p["name"], p["age"], p["retire_at"], p["salary"]) }
fmt.Println("Accounts:")
for _, x := range list(sheet["accounts"]) { a := obj(x); fmt.Printf(" - %v (%v): %v, adding %v\n", a["name"], a["type"], a["balance"], a["adding"]) }
fmt.Printf("Spending: %v a year\n", sheet["spending"])
for _, q := range list(reply["questions"]) { fmt.Println("?", q) }
for _, x := range list(reply["left_out"]) { o := obj(x); fmt.Printf("left out: %v - %v\n", o["item"], o["why"]) }
} else { // review; actions arrive most important first
fmt.Printf("VERDICT: %v\n%v\n\nScenarios:\n", reply["verdict"], reply["summary"])
for _, x := range list(reply["scenarios"]) { s := obj(x); fmt.Printf(" - %v: %v\n", s["name"], s["reading"]) }
fmt.Println("Options to discuss:")
for _, x := range list(reply["actions"]) { a := obj(x); fmt.Printf(" %v. [%v] %v (%v)\n", a["priority"], a["area"], a["action"], a["effect"]) }
for _, q := range list(reply["questions"]) { fmt.Println("?", q) }
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "retire-runway:" + 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()));
String out = j.get("output").get("output").asText();
JsonNode reply = M.readTree(out.substring(out.indexOf('{'), out.lastIndexOf('}') + 1));
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
if ("intake".equals(reply.path("task").asText())) {
JsonNode sheet = reply.path("sheet");
System.out.println(reply.path("summary").asText() + "\n\nPeople:");
for (JsonNode p : sheet.path("people")) System.out.println(" - " + p.path("name").asText() + ": age " + p.path("age").asText() + ", retire at " + p.path("retire_at").asText() + ", salary " + p.path("salary").asText());
System.out.println("Accounts:");
for (JsonNode a : sheet.path("accounts")) System.out.println(" - " + a.path("name").asText() + " (" + a.path("type").asText() + "): " + a.path("balance").asText() + ", adding " + a.path("adding").asText());
System.out.println("Spending: " + sheet.path("spending").asText() + " a year");
for (JsonNode q : reply.path("questions")) System.out.println("? " + q.asText());
for (JsonNode x : reply.path("left_out")) System.out.println("left out: " + x.path("item").asText() + " - " + x.path("why").asText());
} else { // review; actions arrive most important first
System.out.println("VERDICT: " + reply.path("verdict").asText() + "\n" + reply.path("summary").asText() + "\n\nScenarios:");
for (JsonNode s : reply.path("scenarios")) System.out.println(" - " + s.path("name").asText() + ": " + s.path("reading").asText());
System.out.println("Options to discuss:");
for (JsonNode a : reply.path("actions")) System.out.println(" " + a.path("priority").asText() + ". [" + a.path("area").asText() + "] " + a.path("action").asText() + " (" + a.path("effect").asText() + ")");
for (JsonNode q : reply.path("questions")) System.out.println("? " + q.asText());
}
require "digest"
key = "retire-runway:#{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
out = j["output"]["output"]
reply = JSON.parse(out[out.index("{")..out.rindex("}")])
puts [j["charged_credits"], j["truncated"]].inspect
if reply["task"] == "intake"
sheet = reply["sheet"] || {}
puts reply["summary"], "", "People:"
(sheet["people"] || []).each { |p| puts " - #{p["name"]}: age #{p["age"]}, retire at #{p["retire_at"]}, salary #{p["salary"]}" }
puts "Accounts:"
(sheet["accounts"] || []).each { |a| puts " - #{a["name"]} (#{a["type"]}): #{a["balance"]}, adding #{a["adding"]}" }
puts "Spending: #{sheet["spending"]} a year"
(reply["questions"] || []).each { |q| puts "? #{q}" }
(reply["left_out"] || []).each { |x| puts "left out: #{x["item"]} - #{x["why"]}" }
else # review
puts "VERDICT: #{reply["verdict"]}", reply["summary"], "", "Scenarios:"
(reply["scenarios"] || []).each { |s| puts " - #{s["name"]}: #{s["reading"]}" }
puts "Options to discuss:"
(reply["actions"] || []).sort_by { |a| a["priority"].to_i }.each { |a| puts " #{a["priority"]}. [#{a["area"]}] #{a["action"]} (#{a["effect"]})" }
(reply["questions"] || []).each { |q| puts "? #{q}" }
end
$key = "retire-runway:" . $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"]));
$out = $j["output"]["output"];
$from = strpos($out, "{");
$reply = json_decode(substr($out, $from, strrpos($out, "}") - $from + 1), true);
echo $j["charged_credits"], "\n";
if (($reply["task"] ?? "") === "intake") {
$sheet = $reply["sheet"] ?? [];
echo $reply["summary"] ?? "", "\n\nPeople:\n";
foreach ($sheet["people"] ?? [] as $p) echo " - ", $p["name"], ": age ", $p["age"] ?? "", ", retire at ", $p["retire_at"] ?? "", ", salary ", $p["salary"] ?? "", "\n";
echo "Accounts:\n";
foreach ($sheet["accounts"] ?? [] as $a) echo " - ", $a["name"], " (", $a["type"] ?? "", "): ", $a["balance"] ?? "", ", adding ", $a["adding"] ?? "", "\n";
echo "Spending: ", $sheet["spending"] ?? "", " a year\n";
foreach ($reply["questions"] ?? [] as $q) echo "? ", $q, "\n";
foreach ($reply["left_out"] ?? [] as $x) echo "left out: ", $x["item"], " - ", $x["why"] ?? "", "\n";
} else { // review
echo "VERDICT: ", $reply["verdict"] ?? "", "\n", $reply["summary"] ?? "", "\n\nScenarios:\n";
foreach ($reply["scenarios"] ?? [] as $s) echo " - ", $s["name"], ": ", $s["reading"] ?? "", "\n";
echo "Options to discuss:\n";
$actions = $reply["actions"] ?? [];
usort($actions, fn($a, $b) => (int)($a["priority"] ?? 99) <=> (int)($b["priority"] ?? 99));
foreach ($actions as $a) echo " ", $a["priority"] ?? "", ". [", $a["area"] ?? "", "] ", $a["action"], " (", $a["effect"] ?? "", ")\n";
foreach ($reply["questions"] ?? [] as $q) echo "? ", $q, "\n";
}
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"retire-runway:{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 output = j["output"]!["output"]!.GetValue<string>();
var from = output.IndexOf('{');
var reply = JsonNode.Parse(output.Substring(from, output.LastIndexOf('}') - from + 1))!;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
JsonArray Arr(JsonNode? n) => n as JsonArray ?? new JsonArray();
if ((string?)reply["task"] == "intake") {
var sheet = reply["sheet"] ?? new JsonObject();
Console.WriteLine($"{reply["summary"]}\n\nPeople:");
foreach (var p in Arr(sheet["people"])) Console.WriteLine($" - {p!["name"]}: age {p["age"]}, retire at {p["retire_at"]}, salary {p["salary"]}");
Console.WriteLine("Accounts:");
foreach (var a in Arr(sheet["accounts"])) Console.WriteLine($" - {a!["name"]} ({a["type"]}): {a["balance"]}, adding {a["adding"]}");
Console.WriteLine($"Spending: {sheet["spending"]} a year");
foreach (var q in Arr(reply["questions"])) Console.WriteLine($"? {q}");
foreach (var x in Arr(reply["left_out"])) Console.WriteLine($"left out: {x!["item"]} - {x["why"]}");
} else { // review; actions arrive most important first
Console.WriteLine($"VERDICT: {reply["verdict"]}\n{reply["summary"]}\n\nScenarios:");
foreach (var s in Arr(reply["scenarios"])) Console.WriteLine($" - {s!["name"]}: {s["reading"]}");
Console.WriteLine("Options to discuss:");
foreach (var a in Arr(reply["actions"])) Console.WriteLine($" {a!["priority"]}. [{a["area"]}] {a["action"]} ({a["effect"]})");
foreach (var q in Arr(reply["questions"])) Console.WriteLine($"? {q}");
}
Step 6 - POST /run-stream
The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.
# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: retire-runway:$(jq -r .task input.json):$(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 (see the input table)
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "retire-runway:" + rev["task"] + ":" + 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": `retire-runway:${rev.task}:${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" 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", "retire-runway:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
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", "retire-runway:" + M.readTree(rev).path("task").asText() + ":" + 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" => "retire-runway:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, 443, 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");
$task = json_decode($rev, true)["task"];
$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: retire-runway:$task:" . 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", $"retire-runway:{JsonNode.Parse(rev)!["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);