Drive Grows Here from your own code
Both metered tasks are available over HTTP. Each takes one piece of advice - with who gave it, what you know of their situation, your own situation and the page's soil test (six growing conditions, the giver's level against yours, each marked match, small gap, big gap or unknown) - and returns either a fit check (a verdict of compost, graft, pot or plant, what the advice was rooted in, what each gap means, how sure it sounds, the plain version and a trial) or a flip (a lean of opposite, blend, trial or original, the reasonable opposite advice, its three strongest reasons, who it is right for, when the original still wins, the common ground and a trial).
The division of labour first: the page owns the soil test and the rule; the model owns the judgement and the words; the page re-checks the reply. The gap marks, the counts, the sweeping-word scan and the verdict rule come from soilkit.js, which runs in node as well as in the browser. The model never counts anything: SoilKit.buildInput(form) hands it the figures as strings, and SoilKit.check(reply, built) re-checks what comes back.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"grows-here"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, advice, conditions, figures) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both tasks 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 and the verdict rule
Every run sends task: fit (does it grow here?) or flip (steelman the opposite); a missing or unknown one is treated as fit. The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:
- compost - 2 or more conditions are big gaps (
big_gaps). - graft - 1 is a big gap, or 3 or more are small gaps (
small_gaps). - pot - 3 or more are unknown (
unknowns), orstakesishigh, orundoishard. - plant - everything else.
A flip reply's lean follows the same check: compost - opposite, graft - blend, pot - trial, plant - original. The levels and the marks are your own rating and a rule of thumb, not a measurement. The declared input schema carries scalars only, so the array- and object-valued fields (conditions, figures, certainty) travel JSON-encoded as strings.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | fit or flip. Required. |
advice | string | The advice in the words you were given it; long advice keeps its opening and closing with the middle marked [...] (about 120 words). Required. |
source | string | Who gave it ("my aunt", "a founder on a podcast"), or empty. |
area, area_label | string | work, study, health, home, people, creative, money or other, and its plain label. |
giver_context, plot | string | What you know of the giver's situation (up to 300 characters) and your own (up to 400). |
conditions | string (JSON) | Six {id, label, them, you, gap}: cushion, time, risk, experience, support, freedom; levels little, some, plenty (them may be unknown); gap match, small, big or unknown. Required. |
figures | string (JSON) | {matches, small_gaps, big_gaps, unknowns, stakes, undo, certainty_hits, advice_words}, every value a string. Required. |
certainty | string (JSON) | The sweeping words found in the advice, possibly []. |
trial_window, trial_window_label | string | weekend, week, two_weeks or month, and its plain label. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object. For fit: task, verdict, title, summary, roots (2-4 {quote, needs, condition}, each quote copied from the advice), gaps (one {condition, effect} per small or big gap, in order), confidence, boring_version, trial ({try, watch}) and ask_yourself. For flip: task, lean, title, summary, opposite, case_for (3 strings), right_for, agree_if (2-3 strings, each starting "Keep the original if"), common_ground, trial and ask_yourself. Parse it with SoilKit.normalize(SoilKit.parseReply(text), task); a cut-off stream still parses (partial-JSON recovery).
Worked examples
Replies produced against the app's prompt for the four sample pieces of advice (made up; long fields shortened; the full replies ship in example-result.js), one per task. Together they cover every verdict and every lean.
fit: go all in, no plan B - compost
Input:
{
"task": "fit",
"advice": "Quit your job and go all in on the side project. A safety net is what keeps you from ever succeeding - the only way to win is to have no plan B.",
"source": "a founder on a podcast",
"area": "work",
"area_label": "work and career",
"giver_context": "Said on the show that a partner's salary covered the rent for the first two years, and there were no kids yet.",
"plot": "I am the only income at home and my mum lives with me. My side project is a small pottery shop that sells a few mugs a month.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"plenty\",\"you\":\"little\",\"gap\":\"big\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"plenty\",\"you\":\"some\",\"gap\":\"small\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"plenty\",\"you\":\"little\",\"gap\":\"big\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"t ... (515 characters)",
"figures": "{\"matches\":\"1\",\"small_gaps\":\"2\",\"big_gaps\":\"3\",\"unknowns\":\"0\",\"stakes\":\"high\",\"undo\":\"hard\",\"certainty_hits\":\"1\",\"advice_words\":\"32\"}",
"certainty": "[\"the only way\"]",
"trial_window": "month",
"trial_window_label": "one month"
}
Reply:
{
"task": "fit",
"verdict": "compost",
"title": "Grown in far richer soil than yours",
"summary": "You match the founder only on experience, while safety net, appetite for risk and room to move all run far shorter in your plot, with a little less time and support too. Since this would cost a lot and be hard to reverse, it needs reshaping before it fits you.",
"roots": [
{
"quote": "Quit your job and go all in on the side project",
"needs": "A partner's income covering rent for two years, and no kids yet.",
"condition": "risk"
},
{
"quote": "A safety net is what keeps you from ever succeeding",
"needs": "Another income already covering the rent while the net sat unused.",
"condition": "cushion"
},
{
"quote": "the only way to win is to have no plan B",
"needs": "Room to recover if the leap didn't pay off right away.",
"condition": "freedom"
}
],
"gaps": [
{
"condition": "cushion",
"effect": "You have far less safety net than the founder did, and you are the only income at home, so a misstep costs more immediately."
},
{
"condition": "time",
"effect": "You have a little less spare time than the founder did, enough to chip away at the shop but not to go all in."
},
{
"condition": "risk",
"effect": "Your appetite for risk is far less than the founder had, so going all in on the shop asks a lot more of you."
},
{
"condition": "support",
"effect": "You have a little less support around you than the founder did, which matters if this doesn't go smoothly at first."
},
{
"condition": "freedom",
"effect": "You have far less room to move than the founder did, since you are the only income at home right now."
}
],
"confidence": "The advice says 'the only way' to win is no plan B, sounding absolute, but it hides that the founder had another income and no kids yet.",
"boring_version": "Keep the job for now, and grow the pottery shop in whatever time and support you already have.",
"trial": {
"try": "For one month, spend a little spare time growing shop sales or orders without quitting your job.",
"watch": "Watch whether shop sales grow and whether it still leaves room for your job."
},
"ask_yourself": "If the shop had to replace your whole income tomorrow, could it, without the job underneath it?"
}
fit: up at 5 a.m. - graft
Input:
{
"task": "fit",
"advice": "Get up at 5 a.m. and do your hardest work before anyone else is awake. Everyone who gets ahead does this.",
"source": "a productivity video",
"area": "study",
"area_label": "study and learning",
"giver_context": "The presenter mentions living alone and working from home.",
"plot": "I am studying for a certification and I work evening shifts at a restaurant until 11 p.m. most weekdays. I think best late at night.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"plenty\",\"you\":\"little\",\"gap\":\"big\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"them\" ... (516 characters)",
"figures": "{\"matches\":\"2\",\"small_gaps\":\"2\",\"big_gaps\":\"1\",\"unknowns\":\"1\",\"stakes\":\"low\",\"undo\":\"easy\",\"certainty_hits\":\"1\",\"advice_words\":\"21\"}",
"certainty": "[\"everyone\"]",
"trial_window": "two_weeks",
"trial_window_label": "two weeks"
}
Reply:
{
"task": "fit",
"verdict": "graft",
"title": "A routine built for different soil",
"summary": "Your safety net and appetite for risk match the giver's, but you have far less spare time and a little less experience and room to move. The core idea could work here with changes to fit your plot.",
"roots": [
{
"quote": "Get up at 5 a.m.",
"needs": "An early bedtime and no one needing you before dawn.",
"condition": "freedom"
},
{
"quote": "do your hardest work before anyone else is awake",
"needs": "A quiet block of free time when the day's other demands have not started.",
"condition": "time"
},
{
"quote": "Everyone who gets ahead",
"needs": "This being the only route to getting ahead, not just one option.",
"condition": "other"
}
],
"gaps": [
{
"condition": "time",
"effect": "The giver had far more spare time; you work evening shifts until 11 p.m. most weekdays, leaving barely any room for a 5 a.m. start."
},
{
"condition": "experience",
"effect": "The giver had a little more experience in this area; you think best late at night instead."
},
{
"condition": "freedom",
"effect": "The giver had a little more room to move, living alone and working from home; your evenings are fixed by work."
}
],
"confidence": "The advice leans on the sweeping word \"everyone,\" which hides that it worked for someone living alone and working from home, with far more free time - not a fixed evening job.",
"boring_version": "Pick one quiet block of time each day for your hardest study work, even late at night after your shift, not necessarily 5 a.m.",
"trial": {
"try": "For two weeks, do your hardest study work in one quiet block late at night after your shift, instead of at 5 a.m.",
"watch": "Notice whether you finish the study session and still feel steady overall, not rushed or exhausted."
},
"ask_yourself": "Could your hardest study work fit better late at night, when you already think best, than at 5 a.m.?"
}
fit: ask for the promotion - pot
Input:
{
"task": "fit",
"advice": "Tell your manager exactly what you want. Ask for the promotion directly in your next one-on-one - nobody gets promoted by waiting to be noticed.",
"source": "my mentor",
"area": "work",
"area_label": "work and career",
"giver_context": "My mentor has led teams at two companies and was promoted twice after asking.",
"plot": "I have been on my team for three years and I lead most of our client calls. My manager is new and we have not had a one-on-one yet.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"plenty\",\"you\":\"some\",\"gap\":\"small\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"them\" ... (509 characters)",
"figures": "{\"matches\":\"4\",\"small_gaps\":\"2\",\"big_gaps\":\"0\",\"unknowns\":\"0\",\"stakes\":\"high\",\"undo\":\"easy\",\"certainty_hits\":\"1\",\"advice_words\":\"24\"}",
"certainty": "[\"nobody\"]",
"trial_window": "two_weeks",
"trial_window_label": "two weeks"
}
Reply:
{
"task": "fit",
"verdict": "pot",
"title": "Worth trying - ease into it first",
"summary": "Safety net, spare time, people in your corner and room to move match; your mentor had a little more appetite for risk and experience in this area. Because getting this wrong would cost you a lot, test a smaller, reversible step before the full ask.",
"roots": [
{
"quote": "Tell your manager exactly what you want",
"needs": "a manager open to hearing direct requests about what you want",
"condition": "other"
},
{
"quote": "Ask for the promotion directly in your next one-on-one",
"needs": "an established one-on-one meeting already happening with your manager",
"condition": "freedom"
},
{
"quote": "nobody gets promoted by waiting to be noticed",
"needs": "a willingness to take the risk of speaking up instead of waiting",
"condition": "risk"
}
],
"gaps": [
{
"condition": "risk",
"effect": "Your mentor had a little more appetite for risk, so the direct ask may feel riskier for you than it did for your mentor."
},
{
"condition": "experience",
"effect": "Your mentor had a little more experience in this area, having led teams and been promoted twice after asking."
}
],
"confidence": "The advice uses the sweeping word \"nobody,\" which hides that people get promoted in different ways and your manager is new to you.",
"boring_version": "Once you and your manager have your first one-on-one, bring up your growing role and ask what it would take to move up.",
"trial": {
"try": "Within two weeks, ask your manager to set up a one-on-one and say you would like to discuss your role and growth.",
"watch": "Notice whether your manager is open to setting a regular one-on-one and discussing your role and growth."
},
"ask_yourself": "What would you need to see before raising the promotion directly with your manager?"
}
fit: five dinners by heart - plant
Input:
{
"task": "fit",
"advice": "Learn five dinners you can cook without a recipe. It saves more weeknights than any meal plan.",
"source": "my aunt",
"area": "home",
"area_label": "home and everyday life",
"giver_context": "My aunt raised three kids and cooked most nights on a tight schedule.",
"plot": "I share a flat, I get home around 6, and I can already make pasta and an omelette. I end up ordering takeaway most evenings.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"little\",\"you\":\"little\",\"gap\":\"match\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"the ... (513 characters)",
"figures": "{\"matches\":\"4\",\"small_gaps\":\"2\",\"big_gaps\":\"0\",\"unknowns\":\"0\",\"stakes\":\"low\",\"undo\":\"easy\",\"certainty_hits\":\"0\",\"advice_words\":\"17\"}",
"certainty": "[]",
"trial_window": "week",
"trial_window_label": "one week"
}
Reply:
{
"task": "fit",
"verdict": "plant",
"title": "A plan built for your evenings too",
"summary": "Most of your conditions match your aunt's, with a little less cooking experience and a little more room to move. That close a fit means the advice should grow well in your plot as it stands.",
"roots": [
{
"quote": "Learn five dinners you can cook without a recipe",
"needs": "repeating the same five meals until they need no recipe",
"condition": "experience"
},
{
"quote": "saves more weeknights than any meal plan",
"needs": "evenings consistently tight on time, similar to your aunt's tight schedule",
"condition": "time"
}
],
"gaps": [
{
"condition": "experience",
"effect": "Your aunt had a little more cooking experience than you, though you already make pasta and an omelette, which gives you a start."
},
{
"condition": "freedom",
"effect": "You have a little more room to move than your aunt did, giving you room to try these dinners without added pressure."
}
],
"confidence": "The advice is stated without sweeping words, but it rests on having already learned a handful of meals well enough to not need a recipe.",
"boring_version": "Cook one of the dinners you already know - pasta or an omelette - on a weeknight instead of ordering takeaway.",
"trial": {
"try": "This one week, learn one dinner from the five without a recipe and cook it at home instead of ordering takeaway.",
"watch": "Notice whether you reach for that dinner on a weeknight instead of ordering takeaway."
},
"ask_yourself": "Which of the five dinners could you cook this week without reaching for a recipe or ordering takeaway?"
}
flip: go all in, no plan B - opposite
Input:
{
"task": "flip",
"advice": "Quit your job and go all in on the side project. A safety net is what keeps you from ever succeeding - the only way to win is to have no plan B.",
"source": "a founder on a podcast",
"area": "work",
"area_label": "work and career",
"giver_context": "Said on the show that a partner's salary covered the rent for the first two years, and there were no kids yet.",
"plot": "I am the only income at home and my mum lives with me. My side project is a small pottery shop that sells a few mugs a month.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"plenty\",\"you\":\"little\",\"gap\":\"big\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"plenty\",\"you\":\"some\",\"gap\":\"small\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"plenty\",\"you\":\"little\",\"gap\":\"big\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"t ... (515 characters)",
"figures": "{\"matches\":\"1\",\"small_gaps\":\"2\",\"big_gaps\":\"3\",\"unknowns\":\"0\",\"stakes\":\"high\",\"undo\":\"hard\",\"certainty_hits\":\"1\",\"advice_words\":\"32\"}",
"certainty": "[\"the only way\"]",
"trial_window": "month",
"trial_window_label": "one month"
}
Reply:
{
"task": "flip",
"lean": "opposite",
"title": "Too little soil here to go all in",
"summary": "The opposite says keep your job and grow the shop around it, since going all in needs a safety net, risk appetite and room to move you lack. With far less of each than the founder had, your soil leans firmly toward the opposite advice.",
"opposite": "Keep your income and grow the side project around it, only stepping away once it can support you.",
"case_for": [
"You are the only income at home and your mum lives with you, so losing it would cost far more than it did the founder.",
"Your appetite for risk and room to move are far smaller than the founder had, so a full exit carries far more danger for you.",
"The founder had a partner's income covering rent for two years, with no kids yet - very different from your situation."
],
"right_for": "Someone with far less safety net, risk appetite and room to move, who is the only income and has a parent living with them.",
"agree_if": [
"Keep the original if your safety net and room to move grow closer to what the founder had.",
"Keep the original if you are no longer the only income at home.",
"Keep the original if the shop earns enough on its own before you consider leaving your job."
],
"common_ground": "Both agree the pottery shop is worth building and that timing should match what safety net, income and room to move you actually have.",
"trial": {
"try": "For one month, keep your job and give the pottery shop your current spare time, then track how orders change.",
"watch": "Watch whether mug orders grow past a few a month while you keep your job."
},
"ask_yourself": "If you quit tomorrow, what covers your income while the shop grows?"
}
flip: up at 5 a.m. - blend
Input:
{
"task": "flip",
"advice": "Get up at 5 a.m. and do your hardest work before anyone else is awake. Everyone who gets ahead does this.",
"source": "a productivity video",
"area": "study",
"area_label": "study and learning",
"giver_context": "The presenter mentions living alone and working from home.",
"plot": "I am studying for a certification and I work evening shifts at a restaurant until 11 p.m. most weekdays. I think best late at night.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"plenty\",\"you\":\"little\",\"gap\":\"big\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"them\" ... (516 characters)",
"figures": "{\"matches\":\"2\",\"small_gaps\":\"2\",\"big_gaps\":\"1\",\"unknowns\":\"1\",\"stakes\":\"low\",\"undo\":\"easy\",\"certainty_hits\":\"1\",\"advice_words\":\"21\"}",
"certainty": "[\"everyone\"]",
"trial_window": "two_weeks",
"trial_window_label": "two weeks"
}
Reply:
{
"task": "flip",
"lean": "blend",
"title": "The case for working your own best hours",
"summary": "Working during your own sharpest hours, whenever those fall, can matter more than matching the early start the giver follows. Your soil is close in some ways and far off in others, so a blended version between the two fits better than either alone.",
"opposite": "Do your hardest study work whenever your mind is naturally sharpest, even if that is late at night after your shift, not at dawn.",
"case_for": [
"You said you think best late at night, so an early start could waste the hours when your mind works best.",
"Evening shifts until 11 p.m. most weekdays leave you with far less spare time than the giver, who mentions working from home.",
"The giver lives alone and works from home, which may make an early start simpler than it would be after a late evening shift."
],
"right_for": "Someone whose days are already filled by work or shifts, who already knows when they personally focus best, and has limited space left to change their schedule.",
"agree_if": [
"Keep the original if your evening shifts end and your mornings become free for hard work.",
"Keep the original if you notice your sharpest thinking actually happens early rather than late at night.",
"Keep the original if you gain a little more room to move and a little more experience with early starts."
],
"common_ground": "Both agree that doing your hardest work when you can focus without interruption is what matters most.",
"trial": {
"try": "For two weeks, do your hardest study work late at night after your shift instead of trying to start early.",
"watch": "Notice whether you focus better and retrieve more study material late at night than you expected."
},
"ask_yourself": "Does your hardest work happen better early in the morning or late at night?"
}
flip: ask for the promotion - trial
Input:
{
"task": "flip",
"advice": "Tell your manager exactly what you want. Ask for the promotion directly in your next one-on-one - nobody gets promoted by waiting to be noticed.",
"source": "my mentor",
"area": "work",
"area_label": "work and career",
"giver_context": "My mentor has led teams at two companies and was promoted twice after asking.",
"plot": "I have been on my team for three years and I lead most of our client calls. My manager is new and we have not had a one-on-one yet.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"plenty\",\"you\":\"some\",\"gap\":\"small\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"them\" ... (509 characters)",
"figures": "{\"matches\":\"4\",\"small_gaps\":\"2\",\"big_gaps\":\"0\",\"unknowns\":\"0\",\"stakes\":\"high\",\"undo\":\"easy\",\"certainty_hits\":\"1\",\"advice_words\":\"24\"}",
"certainty": "[\"nobody\"]",
"trial_window": "two_weeks",
"trial_window_label": "two weeks"
}
Reply:
{
"task": "flip",
"lean": "trial",
"title": "Feel it out before asking outright",
"summary": "The opposite advice says wait and let your new manager notice your work before you ask. Because getting this wrong would cost you more, your soil here favors trying a small step first rather than committing fully either way.",
"opposite": "Hold off asking directly - let your manager settle in and see your work speak for itself before you bring up promotion.",
"case_for": [
"Your manager is new and you have not had a one-on-one yet, so there has been no chance to be noticed yet.",
"Your mentor had a little more experience and appetite for risk than you do, which made asking sooner safer for them.",
"The advice claims nobody gets promoted without asking, but that is a sweeping claim worth questioning before you act on it."
],
"right_for": "Someone with a new manager relationship, less appetite for risk, and less experience asking for promotions directly.",
"agree_if": [
"Keep the original if you get a one-on-one soon and can point to your three years of client call work.",
"Keep the original if the small difference in appetite for risk and experience in this area does not worry you."
],
"common_ground": "Both agree you want the promotion and that staying silent forever will not get you there - the question is only when to speak.",
"trial": {
"try": "Over the next two weeks, use your first one-on-one to ask what it would take to get promoted, not for the promotion itself.",
"watch": "Notice whether your manager responds with real steps or stays vague about your path forward."
},
"ask_yourself": "Would you rather test the waters first, or ask directly and risk looking too early?"
}
flip: five dinners by heart - original
Input:
{
"task": "flip",
"advice": "Learn five dinners you can cook without a recipe. It saves more weeknights than any meal plan.",
"source": "my aunt",
"area": "home",
"area_label": "home and everyday life",
"giver_context": "My aunt raised three kids and cooked most nights on a tight schedule.",
"plot": "I share a flat, I get home around 6, and I can already make pasta and an omelette. I end up ordering takeaway most evenings.",
"conditions": "[{\"id\":\"cushion\",\"label\":\"safety net\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"time\",\"label\":\"spare time\",\"them\":\"little\",\"you\":\"little\",\"gap\":\"match\"},{\"id\":\"risk\",\"label\":\"appetite for risk\",\"them\":\"some\",\"you\":\"some\",\"gap\":\"match\"},{\"id\":\"experience\",\"label\":\"experience in this area\",\"the ... (513 characters)",
"figures": "{\"matches\":\"4\",\"small_gaps\":\"2\",\"big_gaps\":\"0\",\"unknowns\":\"0\",\"stakes\":\"low\",\"undo\":\"easy\",\"certainty_hits\":\"0\",\"advice_words\":\"17\"}",
"certainty": "[]",
"trial_window": "week",
"trial_window_label": "one week"
}
Reply:
{
"task": "flip",
"lean": "original",
"title": "This one still fits your soil",
"summary": "A written plan or recipe each time is a fair alternative, since you have a little less cooking experience than your aunt and a little more room to move. Still, your soil closely matches the one your aunt cooked in, so the original advice fits you best.",
"opposite": "Don't bother memorising dinners, follow a written meal plan or recipe each time instead.",
"case_for": [
"Your aunt had a little more cooking experience than you, so a written plan might guide you more reliably than memory alone.",
"You can already make pasta and an omelette, so building on that with a flexible plan could work as well as memorising five dinners.",
"You also have a little more room to move than your aunt did, so a flexible plan could suit your evenings better."
],
"right_for": "Someone with less experience cooking from memory, more room to move, and time to follow a written plan each week.",
"agree_if": [
"Keep the original if you'd rather spend an evening learning dishes than following a plan each week.",
"Keep the original if you want dinners that don't depend on having a recipe or plan on hand.",
"Keep the original if quick, flexible cooking matters more to you than variety from a plan."
],
"common_ground": "Both agree a reliable way to get dinner on the table beats ordering takeaway on weeknights.",
"trial": {
"try": "For one week, cook from a simple written plan each evening instead of relying on memory, using meals you can already make.",
"watch": "Notice whether you cook at home more often this week than you usually do."
},
"ask_yourself": "Would you rather learn five dinners by heart, or lean on a plan each week?"
}
Build the input yourself
Load the script in node (global.window = global; require("./soilkit.js")), then:
const K = SoilKit;
const form = K.form({ task: "fit", advice: "Answer every email the same day. Nobody trusts a slow reply.",
source: "my old manager", area: "work", plot: "I work part-time and look after two kids.",
soil: { time: { them: "plenty", you: "little" }, freedom: { them: "plenty", you: "some" } },
stakes: "low", undo: "easy", window: "week" });
const r = K.read(form); // r.rows: each condition and its gap; r.fig: counts, verdict, lean; r.sweeps
const built = K.buildInput(form);
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output), "fit");
// K.check(reply, built).notes // the page's re-check of the reply
// K.csv(built, reply) // the soil sheet, one row per condition
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://grows-here.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://grows-here.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"]
// grows-here.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://grows-here.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://grows-here.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
}
// GrowsHere.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 GrowsHere {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://grows-here.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://grows-here.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://grows-here.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://grows-here.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":"grows-here"}' | 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": "grows-here"})["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: "grows-here" })).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": "grows-here"}, 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", "grows-here"), 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: "grows-here" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "grows-here"])["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 = "grows-here" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "fit", "advice": "Get up at 5 a.m. and do your hardest work before anyone else is awake. Everyone who gets ahead does this.", "source": "a productivity video", "area": "study", "area_label": "study and learning", "giver_context": "The presenter mentions living alone and working from home.", "plot": "I am studying for a certification and I work evening shifts at a restaurant until 11 p.m. most weekdays. I think best late at night.", "conditions": "[...]", "figures": "{...}", "certainty": "[\"everyone\"]", "trial_window": "two_weeks", "trial_window_label": "two weeks"}
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": "fit", "advice": "Get up at 5 a.m. and do your hardest work before anyone else is awake. Everyone who gets ahead does this.", "source": "a productivity video", "area": "study", "area_label": "study and learning", "giver_context": "The presenter mentions living alone and working from home.", "plot": "I am studying for a certification and I work evening shifts at a restaurant until 11 p.m. most weekdays. I think best late at night.", "conditions": "[...]", "figures": "{...}", "certainty": "[\"everyone\"]", "trial_window": "two_weeks", "trial_window_label": "two weeks"}
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: "fit", advice, source, area, area_label, giver_context, plot, conditions, figures, certainty, trial_window, trial_window_label, ... (see the field table)}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses grows-here:<task>:<inputHash>:a<attempt> (the task is fit or flip), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="grows-here:fit:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "grows-here:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("title"))
import { createHash } from "node:crypto";
const key = `grows-here:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "grows-here:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "grows-here:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "grows-here:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "grows-here:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"grows-here:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: grows-here:fit:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "fit", "advice": "Get up at 5 a.m. and do your hardest work before anyone else is awake. Everyone who gets ahead does this.", "source": "a productivity video", "area": "study", "area_label": "study and learning", "giver_context": "The presenter mentions living alone and working from home.", "plot": "I am studying for a certification and I work evening shifts at a restaurant until 11 p.m. most weekdays. I think best late at night.", "conditions": "[...]", "figures": "{...}", "certainty": "[\"everyone\"]", "trial_window": "two_weeks", "trial_window_label": "two weeks"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "grows-here:fit:" + 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": `grows-here:fit:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "grows-here:fit:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "grows-here:fit:" + 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" => "grows-here:fit:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: grows-here:fit:" . 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", $"grows-here:fit:{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);