Drive Bulb Lasagna from your own code
Both metered steps - the grower's check and the colour story - are available over HTTP. A natural use is a garden-centre or bulb-catalogue page: your code builds the pot, sends it, and shows the answer beside the bulbs.
The split first: the page does the arithmetic; the model writes the judgement. Depth stacking, capacity, soil volume, the bloom matrix for each zone, the planting window, the colour facts per bloom wave and the reply checks ship as a plain script, bulbkit.js (BulbKit.analyse, buildInput, toWire, parseReply, normalize, reconcile, applyGrow, applySwaps), which you can load in node. Bulbs are generic garden types; depths, bloom windows and planting windows are rule-of-thumb estimates.
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":"bulb-lasagna"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: 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 comes first
Every run sends task, which routes the one system prompt to one of two contracts:
| task | What it returns | Verdicts |
|---|---|---|
grow | Grower's check: a summary; exactly one entry per layer with the recommended depth_in and count and a status (ok or change); timing (plant_by, chill, first_bloom); 4–8 planting steps; 3–6 care entries; risks; tips. | plant, adjust, rethink |
colour | Colour story: a story; one read per bloom wave with its harmony; 0–3 swaps whose to is one of that layer's colour_options; 0–3 keeps; tips. | sings, tweak, clash |
The declared input schema carries scalars only, so object- and array-valued fields travel JSON-encoded as strings (BulbKit.toWire does this). task, zone, pot, layers and facts are required.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | grow or colour. |
zone | number | USDA hardiness zone, 3–10. |
pot | string (JSON) | {diameter_in, depth_in, material, spot, deer_pressure}. |
layers | string (JSON) | Deepest first: {id, bulb, colour, count, depth_in, rec_depth_in, tip_depth_in, bulb_height_in, flower_height_in, fits_about, blooms, chill_weeks, zones, deer_resistant, manual_depth}; the colour task adds colour_options. |
notes | string | The grower's own words; over 1,500 characters keeps the first 1,000 and last 400 around a [...] marker. |
today | string | grow only: ISO date, for the planting window. |
facts | string (JSON) | grow: {checks[], plant_window, window_status, soil_quarts_est, bulbs_total, bloom_span, gaps[]}. colour: {palette[], palette_size, warm_waves, cool_waves, alternations, clashes[], low_contrast_waves[], neutrals[]}. |
mood | string | colour only: soft and calm, bold and bright, classic, moody and dark or surprise me. |
waves | string (JSON) | colour only: one entry per half-month in flower, {period, colours[], layers[], harmony, lean, lightness_range}. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object. Parse it with BulbKit.parseReply, then normalize, then check it with reconcile(reply, analysis): it flags layer ids that are not in the pot, missing layers, recommended depths that leave no soil below or over the bulbs or that would make layers touch, counts that do not fit, a plant verdict over hard problems, swaps to colours a bulb is not offered in, and a sings verdict over a flagged clash. applyGrow(layers, reply) and applySwaps(layers, reply) apply the fixes.
Worked examples
Replies produced against the app's prompt (lists shortened here; the full replies ship in example-result.js). The page replays each of them free from its example buttons.
task: grow - a classic three-layer pot (plant)
Input (decoded for reading; on the wire pot, layers, facts and waves are JSON strings):
{
"task": "grow",
"zone": 6,
"pot": {
"diameter_in": 14,
"depth_in": 14,
"material": "plastic",
"spot": "full sun",
"deer_pressure": false
},
"layers": [
{
"id": "L1",
"bulb": "Tulip, single late",
"colour": "purple",
"count": 12,
"depth_in": 8,
"rec_depth_in": 8,
"tip_depth_in": 6,
"bulb_height_in": 2,
"flower_height_in": 24,
"fits_about": 13,
"blooms": "early May to late May",
"chill_weeks": 15,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false
},
{
"id": "L2",
"bulb": "Hyacinth",
"colour": "white",
"count": 7,
"depth_in": 5.2,
"rec_depth_in": 6,
"tip_depth_in": 3,
"bulb_height_in": 2.2,
"flower_height_in": 10,
"fits_about": 10,
"blooms": "early Apr to late Apr",
"chill_weeks": 12,
"zones": "4-8",
"deer_resistant": true,
"manual_depth": false
},
{
"id": "L3",
"bulb": "Grape hyacinth",
"colour": "blue",
"count": 30,
"depth_in": 2.2,
"rec_depth_in": 3,
"tip_depth_in": 1.4,
"bulb_height_in": 0.8,
"flower_height_in": 7,
"fits_about": 54,
"blooms": "late Mar to late Apr",
"chill_weeks": 10,
"zones": "3-9",
"deer_resistant": true,
"manual_depth": false
}
],
"notes": "For the front step. I want something that looks good from the street from March to May.",
"today": "2026-10-19",
"facts": {
"checks": [
{
"level": "info",
"code": "no-handover",
"layer": "",
"text": "Some layers flower with no overlap with any other (L1) - an overlap of a couple of weeks makes the hand-over seamless."
},
{
"level": "info",
"code": "hidden",
"layer": "L3",
"text": "Grape hyacinth (7 in) flowers among the leaves of Tulip, single late (24 in), whose leaves are up weeks before its flowers - plant it towards the rim so it is seen."
},
{
"level": "info",
"code": "window-now",
"layer": "",
"text": "Plant now: zone 6's usual window is Oct 15 to Nov 30."
}
],
"plant_window": "Oct 15 to Nov 30",
"window_status": "now",
"soil_quarts_est": 37,
"bulbs_total": 49,
"bloom_span": "late Mar to late May",
"gaps": []
}
}
Reply:
{
"task": "grow",
"title": "Front Step Spring Bulb Lasagna",
"verdict": "plant",
"summary": "Plant this 14 in pot as stacked: L1 tulips at 8 in, L2 hyacinth at 5.2 in, and L3 grape hyacinth at 2.2 in all clear the 0.75 in tip-to-base buffer with 0.8 in to spare, and every depth stays within the pot's 12 in limit. All three counts sit between 55% and 92% of fits_about, so nothing is packed too tight or too thin. The main risk is winter: a pot's roots run about two zones colder than the ground, which puts the hyacinth (hardy to zone 4) right at its edge in this plastic pot, so give it real shelter.",
"layers": [
{
"id": "L1",
"depth_in": 8,
"count": 12,
"status": "ok",
"note": "8 in base leaves 6 in of mix below (pot is 14 in), well past the 2 in minimum. Clears L2 by 0.8 in (8-2 bulb height, minus L2's 5.2). 12 of 13 that fit (92%) is a full, not-crowded look."
},
{
"id": "L2",
"depth_in": 5.2,
"count": 7,
"status": "ok",
"note": "Clears L3 by 0.8 in (5.2-2.2 bulb height, minus L3's 2.2), just over the 0.75 in minimum. 7 of 10 that fit (70%) is comfortable. Hyacinth is hardy to zone 4, about where this pot's winter will sit, so it needs shelter."
},
{
"id": "L3",
"depth_in": 2.2,
"count": 30,
"status": "ok",
"note": "Tip cover is 1.4 in, above the 1 in minimum, and 2.2 in clears its own bulb height plus 1 in (1.8 in) as the top layer. 30 of 54 that fit (56%) reads full from the street without crowding."
}
],
"timing": {
"plant_by": "You're already in zone 6's window (Oct 15 to Nov 30) - plant now, no rush but no reason to wait either.",
"chill": "No pre-chill needed in zone 6; winter in the ground supplies the 10-15 weeks each layer needs. But shelter the pot (against the house wall, wrapped or mulched) so it chills without freezing solid, since it runs colder than ground.",
"first_bloom": "Grape hyacinth (L3) opens first, late Mar, with hyacinth (L2) joining in April and tulips (L1) finishing the show in May."
},
"steps": [
{
"step": 1,
"do": "Check the pot has drainage holes and nothing sits in a saucer underneath; set it on feet or pot risers."
},
{
"step": 2,
"do": "Fill with gritty, free-draining potting mix (no garden soil) to 8 in below the final soil surface."
},
{
"...": "6 more"
}
],
"care": [
{
"when": "after planting",
"do": "Water once well, then do not water again until the top inch of mix is dry - the mix should stay just damp through fall."
},
{
"when": "winter",
"do": "Shelter the pot against the house wall or wrap it (burlap, bubble wrap) and mulch the top, since its roots run colder than the ground around it."
},
{
"...": "2 more"
}
],
"risks": [
"Winter cold: the pot runs about two zones colder than zone 6 ground, right at hyacinth's (L2) zone-4 limit - an unsheltered hard freeze could kill it.",
"Squirrels dig freshly planted pots before the soil settles; a sheet of mesh under the mulch until frost sets in will stop them."
],
"tips": [
"Grape hyacinth and hyacinth are short (7-10 in) - raise the pot on a stand or step riser so the March-April waves read from the street before the 24 in tulips take over in May.",
"There's no bloom overlap between L1 and the other layers (L2/L3 finish late Apr, L1 starts early May) - the show is continuous but not blended; a few early-May annuals at the rim can bridge the look.",
{
"...": "2 more"
}
]
}
task: colour - a classic three-layer pot (sings)
Input (decoded for reading; on the wire pot, layers, facts and waves are JSON strings):
{
"task": "colour",
"zone": 6,
"pot": {
"diameter_in": 14,
"depth_in": 14,
"material": "plastic",
"spot": "full sun",
"deer_pressure": false
},
"layers": [
{
"id": "L1",
"bulb": "Tulip, single late",
"colour": "purple",
"count": 12,
"depth_in": 8,
"rec_depth_in": 8,
"tip_depth_in": 6,
"bulb_height_in": 2,
"flower_height_in": 24,
"fits_about": 13,
"blooms": "early May to late May",
"chill_weeks": 15,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false,
"colour_options": [
"purple",
"near-black",
"pink",
"white",
"red",
"burgundy",
"salmon"
]
},
{
"id": "L2",
"bulb": "Hyacinth",
"colour": "white",
"count": 7,
"depth_in": 5.2,
"rec_depth_in": 6,
"tip_depth_in": 3,
"bulb_height_in": 2.2,
"flower_height_in": 10,
"fits_about": 10,
"blooms": "early Apr to late Apr",
"chill_weeks": 12,
"zones": "4-8",
"deer_resistant": true,
"manual_depth": false,
"colour_options": [
"blue",
"pink",
"white",
"purple",
"apricot"
]
},
{
"id": "L3",
"bulb": "Grape hyacinth",
"colour": "blue",
"count": 30,
"depth_in": 2.2,
"rec_depth_in": 3,
"tip_depth_in": 1.4,
"bulb_height_in": 0.8,
"flower_height_in": 7,
"fits_about": 54,
"blooms": "late Mar to late Apr",
"chill_weeks": 10,
"zones": "3-9",
"deer_resistant": true,
"manual_depth": false,
"colour_options": [
"blue",
"sky blue",
"white"
]
}
],
"notes": "For the front step. I want something that looks good from the street from March to May.",
"mood": "classic",
"waves": [
{
"period": "late Mar",
"colours": [
"blue"
],
"layers": [
"L3 blue"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
},
{
"period": "early Apr",
"colours": [
"white",
"blue"
],
"layers": [
"L2 white",
"L3 blue"
],
"harmony": "one colour with neutrals",
"lean": "cool",
"lightness_range": 42
},
{
"period": "late Apr",
"colours": [
"white",
"blue"
],
"layers": [
"L2 white",
"L3 blue"
],
"harmony": "one colour with neutrals",
"lean": "cool",
"lightness_range": 42
},
{
"period": "early May",
"colours": [
"purple"
],
"layers": [
"L1 purple"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
},
{
"period": "late May",
"colours": [
"purple"
],
"layers": [
"L1 purple"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
}
],
"facts": {
"palette": [
"purple",
"white",
"blue"
],
"palette_size": 3,
"warm_waves": 0,
"cool_waves": 5,
"alternations": 0,
"clashes": [],
"low_contrast_waves": [],
"neutrals": [
"white"
]
}
}
Reply:
{
"task": "colour",
"title": "Cool Classic for the Front Step",
"verdict": "sings",
"story": "This pot stays in one cool family from March to May: L3's blue grape hyacinth opens alone, L2's white hyacinth joins to lift and brighten April, then L1's purple tulips close out May as a tall solid finish. There are no clashes and a clear blue-to-purple through-line, which reads as calm and classic rather than bold - a good match for a front step meant to look good from the street, not a pollinator mix.",
"waves": [
{
"period": "late Mar",
"harmony": "single colour",
"read": "L3's blue grape hyacinth opens alone, a solid cool carpet at 7 in tall; flat as a single colour but close to the ground, so it still reads as a clean blue drift from the street."
},
{
"period": "early Apr",
"harmony": "one colour with neutrals",
"read": "Continues through late Apr: L2's white hyacinth joins L3's blue, lifting the wave's lightness range to 42 so it reads crisp, not flat. White bridges toward May's purple for a classic blue-and-white doorstep look."
},
{
"period": "early May",
"harmony": "single colour",
"read": "Continues through late May: L1's purple tulips take over alone at 24 in tall, a solid colour block closing the season. It is flat in lightness terms, but height and saturation carry it from the street on their own."
}
],
"swaps": [],
"keep": [
{
"id": "L1",
"why": "Purple is the tallest, latest bloom and keeps the cool through-line from L3's blue; a warm swap like red or salmon would break the calm classic story the notes ask for."
},
{
"id": "L2",
"why": "White is the neutral that lifts the April wave above L3's solid blue, pushing its lightness range to 42; trading it for blue would flatten that wave back to monochrome."
},
{
"id": "L3",
"why": "Blue is the only colour open in late March and carries that wave alone; moving it to white would duplicate L2's neutral and leave the opening wave without its own identity."
}
],
"tips": [
"Place the pot where the 24 in purple tulips clear the step rail or threshold so May's finish reads from the street, not just up close.",
"A plain evergreen or dark door backdrop will make April's white hyacinth and blue grape hyacinth pop instead of washing out.",
{
"...": "1 more"
}
]
}
task: grow - a crammed balcony box (rethink)
Input (decoded for reading; on the wire pot, layers, facts and waves are JSON strings):
{
"task": "grow",
"zone": 5,
"pot": {
"diameter_in": 10,
"depth_in": 8,
"material": "plastic",
"spot": "full sun",
"deer_pressure": false
},
"layers": [
{
"id": "L1",
"bulb": "Allium, large globe",
"colour": "purple",
"count": 6,
"depth_in": 6,
"rec_depth_in": 8,
"tip_depth_in": 3.6,
"bulb_height_in": 2.4,
"flower_height_in": 30,
"fits_about": 2,
"blooms": "late May to early Jun",
"chill_weeks": 12,
"zones": "4-8",
"deer_resistant": true,
"manual_depth": false
},
{
"id": "L2",
"bulb": "Tulip, Darwin hybrid",
"colour": "red",
"count": 12,
"depth_in": 3,
"rec_depth_in": 8,
"tip_depth_in": 1,
"bulb_height_in": 2,
"flower_height_in": 22,
"fits_about": 6,
"blooms": "late Apr to early May",
"chill_weeks": 14,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false
},
{
"id": "L3",
"bulb": "Daffodil, large trumpet",
"colour": "yellow",
"count": 9,
"depth_in": 3,
"rec_depth_in": 7,
"tip_depth_in": 1,
"bulb_height_in": 2,
"flower_height_in": 16,
"fits_about": 4,
"blooms": "early Apr to late Apr",
"chill_weeks": 14,
"zones": "3-9",
"deer_resistant": true,
"manual_depth": false
},
{
"id": "L4",
"bulb": "Crocus, species (snow crocus)",
"colour": "purple",
"count": 40,
"depth_in": 1.8,
"rec_depth_in": 3,
"tip_depth_in": 1,
"bulb_height_in": 0.8,
"flower_height_in": 4,
"fits_about": 25,
"blooms": "early Mar to late Mar",
"chill_weeks": 13,
"zones": "3-8",
"deer_resistant": false,
"manual_depth": false
}
],
"notes": "Small apartment balcony, 4th floor, windy. I want as many flowers as possible.",
"today": "2026-10-19",
"facts": {
"checks": [
{
"level": "error",
"code": "crowded",
"layer": "L1",
"text": "6 Allium, large globe will not fit: about 2 fit a 10 in pot at lasagna spacing."
},
{
"level": "warn",
"code": "cold-pot",
"layer": "L1",
"text": "In a pot the roots sit about two zones colder than the ground: Allium, large globe is hardy to about zone 4, so protect the pot (garage, cold frame, or wrapped) in zone 5."
},
{
"level": "warn",
"code": "shallow",
"layer": "L2",
"text": "Tulip, Darwin hybrid is at 3 in against about 8 in recommended - in a pot down to about 60% is fine, shallower and it may flop."
},
{
"...": "10 more checks"
}
],
"plant_window": "Oct 1 to Nov 10",
"window_status": "now",
"soil_quarts_est": 11,
"bulbs_total": 67,
"bloom_span": "early Mar to early Jun",
"gaps": []
}
}
Reply:
{
"task": "grow",
"title": "Daffodil and Crocus Balcony Pot",
"verdict": "rethink",
"summary": "Not plantable as stacked: this 8 in pot only has room for about two bulb layers once a 2 in base buffer, 0.75 in gaps between layers, and 1 in of topsoil are counted, and every layer is also badly overcrowded against what actually fits. The biggest risk is the Allium (L1): it needs cold this pot cannot hold in zone 5, and its 30 in stems are unstable in balcony wind, so drop it along with the Tulip (L2). Rebuilt as a two-layer Daffodil-over-Crocus pot, it fits cleanly and still gives 29 bulbs across two back-to-back early waves.",
"layers": [
{
"id": "L1",
"depth_in": 6,
"count": 0,
"status": "change",
"note": "Drop from this pot: 6 asked but only about 2 fit, and a pot's roots run two zones colder, so zone 5 here acts like zone 3 - below this Allium's zone 4-8 range. Its 30 in stems are also too top-heavy for a 10 in pot in balcony wind."
},
{
"id": "L2",
"depth_in": 3,
"count": 0,
"status": "change",
"note": "Drop from this pot: 12 asked but only about 6 fit, and once the stack is corrected to the 0.75 in gap rule there is no safe depth left for a third layer in an 8 in pot. Save it for a second container."
},
{
"id": "L3",
"depth_in": 6,
"count": 4,
"status": "change",
"note": "Now the bottom layer: moved to 6 in (near its 7 in recommendation and the pot's 6 in max), count cut to the 4 that actually fit. This clears both the collision with the layer above it and the overcrowding."
},
{
"id": "L4",
"depth_in": 3,
"count": 25,
"status": "change",
"note": "Moved deeper, to its full 3 in recommended depth, 1 in clear of the Daffodil base above it. Count cut to the 25 that fit. This clears both the collision and the overcrowding."
}
],
"timing": {
"plant_by": "Zone 5's window is already open (Oct 1 to Nov 10) - plant by Nov 10, as soon as you can since today is Oct 19.",
"chill": "No fridge pre-chill needed in zone 5; outdoor winter cold meets both layers' chill hours. Shelter the pot in a corner or wrap it on the coldest nights, since its roots run about two zones colder than zone 5.",
"first_bloom": "Crocus opens first, early Mar, with Daffodil following early to late Apr."
},
"steps": [
{
"step": 1,
"do": "Set the empty pot in its final full sun spot now - once filled and planted it will be heavy to shift on the balcony."
},
{
"step": 2,
"do": "Check the drainage holes are clear and add no saucer; start with a thin layer of coarse grit in the base."
},
{
"...": "6 more"
}
],
"care": [
{
"when": "after planting",
"do": "Water once thoroughly so the mix settles, then hold off again until the top inch of mix is dry - wet dormant bulbs rot."
},
{
"when": "winter",
"do": "Tuck the pot into a sheltered corner or wrap it on the hardest-freeze nights; keep the mix barely moist, never soggy."
},
{
"...": "3 more"
}
],
"risks": [
"This 8 in pot only has room for two bulb layers; stacking all four as originally planned would crush roots and overlap bulbs.",
"A pot's roots run about two zones colder than the ground - zone 5 here acts like zone 3, below the dropped Allium's zone 4-8 range, so it would not survive an unprotected winter in this container.",
"The Allium's 30 in flower stalks in a narrow 10 in pot on an exposed 4th floor rail were a tip-over and wind-snap risk.",
"A clogged drainage hole or a wet saucer over winter will rot bulbs faster than cold ever would."
],
"tips": [
"Give the Allium and Tulip their own deeper (12 in or more) pot if you still want them - this one is too shallow and small for all four layers.",
"A heavier terracotta pot, or bricks tucked low inside this plastic one, resists tipping far better in balcony wind.",
{
"...": "2 more"
}
]
}
task: colour - a crammed balcony box (tweak)
Input (decoded for reading; on the wire pot, layers, facts and waves are JSON strings):
{
"task": "colour",
"zone": 5,
"pot": {
"diameter_in": 10,
"depth_in": 8,
"material": "plastic",
"spot": "full sun",
"deer_pressure": false
},
"layers": [
{
"id": "L1",
"bulb": "Allium, large globe",
"colour": "purple",
"count": 6,
"depth_in": 6,
"rec_depth_in": 8,
"tip_depth_in": 3.6,
"bulb_height_in": 2.4,
"flower_height_in": 30,
"fits_about": 2,
"blooms": "late May to early Jun",
"chill_weeks": 12,
"zones": "4-8",
"deer_resistant": true,
"manual_depth": false,
"colour_options": [
"purple",
"white",
"lilac"
]
},
{
"id": "L2",
"bulb": "Tulip, Darwin hybrid",
"colour": "red",
"count": 12,
"depth_in": 3,
"rec_depth_in": 8,
"tip_depth_in": 1,
"bulb_height_in": 2,
"flower_height_in": 22,
"fits_about": 6,
"blooms": "late Apr to early May",
"chill_weeks": 14,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false,
"colour_options": [
"red",
"orange",
"yellow",
"pink",
"apricot"
]
},
{
"id": "L3",
"bulb": "Daffodil, large trumpet",
"colour": "yellow",
"count": 9,
"depth_in": 3,
"rec_depth_in": 7,
"tip_depth_in": 1,
"bulb_height_in": 2,
"flower_height_in": 16,
"fits_about": 4,
"blooms": "early Apr to late Apr",
"chill_weeks": 14,
"zones": "3-9",
"deer_resistant": true,
"manual_depth": false,
"colour_options": [
"yellow",
"white",
"white and yellow"
]
},
{
"id": "L4",
"bulb": "Crocus, species (snow crocus)",
"colour": "purple",
"count": 40,
"depth_in": 1.8,
"rec_depth_in": 3,
"tip_depth_in": 1,
"bulb_height_in": 0.8,
"flower_height_in": 4,
"fits_about": 25,
"blooms": "early Mar to late Mar",
"chill_weeks": 13,
"zones": "3-8",
"deer_resistant": false,
"manual_depth": false,
"colour_options": [
"lilac",
"purple",
"cream",
"yellow",
"white"
]
}
],
"notes": "Small apartment balcony, 4th floor, windy. I want as many flowers as possible.",
"mood": "bold and bright",
"waves": [
{
"period": "early Mar",
"colours": [
"purple"
],
"layers": [
"L4 purple"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
},
{
"period": "late Mar",
"colours": [
"purple"
],
"layers": [
"L4 purple"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
},
{
"period": "early Apr",
"colours": [
"yellow"
],
"layers": [
"L3 yellow"
],
"harmony": "single colour",
"lean": "warm",
"lightness_range": 0
},
{
"period": "late Apr",
"colours": [
"red",
"yellow"
],
"layers": [
"L2 red",
"L3 yellow"
],
"harmony": "analogous",
"lean": "warm",
"lightness_range": 10
},
{
"period": "early May",
"colours": [
"red"
],
"layers": [
"L2 red"
],
"harmony": "single colour",
"lean": "warm",
"lightness_range": 0
},
{
"period": "late May",
"colours": [
"purple"
],
"layers": [
"L1 purple"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
},
{
"period": "early Jun",
"colours": [
"purple"
],
"layers": [
"L1 purple"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
}
],
"facts": {
"palette": [
"purple",
"red",
"yellow"
],
"palette_size": 3,
"warm_waves": 3,
"cool_waves": 4,
"alternations": 2,
"clashes": [],
"low_contrast_waves": [
"late Apr"
],
"neutrals": []
}
}
Reply:
{
"task": "colour",
"title": "Bold Balcony Colour Relay",
"verdict": "tweak",
"story": "This 10 in pot runs a true warm-to-cool arc for a bold mood: purple crocus opens in March, a white-and-red April overlap punches through once L3 swaps from yellow, red tulips hold solo into early May, and tall purple Allium closes late May into June on the same cool note it started on. Only the April overlap read flat at the original yellow-and-red value, so one swap keeps every wave reading as a bold, saturated block from the balcony rail.",
"waves": [
{
"period": "early Mar",
"harmony": "single colour",
"read": "Forty snow crocus open the show as one dense, saturated purple carpet across the whole pot top - the boldest possible start even though it is a single colour."
},
{
"period": "late Mar",
"harmony": "single colour",
"read": "The same purple crocus carpet is still the whole picture; with nothing else up yet, watch for early daffodil shoots as the only visible change."
},
{
"period": "early Apr",
"harmony": "single colour",
"read": "With L3 swapped to white, this solo wave becomes a crisp, bright pause between the purple crocus and the red tulips instead of a second yellow note."
},
{
"period": "late Apr",
"harmony": "one colour with neutrals",
"read": "L2's red now meets L3's white instead of yellow, trading a same-value pairing the page flagged as flat for real light-against-dark contrast while staying hot and loud."
},
{
"period": "early May",
"harmony": "single colour",
"read": "Red tulips carry the wave alone for two weeks, the single hottest note in the sequence and a clean pivot before the cool purple finale."
},
{
"period": "late May",
"harmony": "single colour",
"read": "Allium globes on 30 in stems take over as a tall, saturated purple finale, a strong change in scale as well as temperature after a month of red and white."
},
{
"period": "early Jun",
"harmony": "single colour",
"read": "The same purple Allium carries the pot through to early Jun, closing the season on the same cool note it opened on in March."
}
],
"swaps": [
{
"id": "L3",
"to": "white",
"why": "White lifts the late Apr overlap with L2's red out of the low-contrast zone the page flagged (lightness_range 10) and still reads crisp and bright solo in early Apr."
}
],
"keep": [
{
"id": "L1",
"why": "Deep purple Allium closes the season on the same cool note it opened on, anchoring the warm middle stretch without needing a change."
},
{
"id": "L2",
"why": "Red carries the hot midseason wave alone in early May, and once L3 swaps to white it no longer needs its own change to fix the flat late Apr overlap."
},
{
"id": "L4",
"why": "Saturated purple opens the show with maximum punch for a bold mood; a paler option like cream or lilac would mute the opening before anything else is blooming."
}
],
"tips": [
"Put the pot where the early Mar purple crocus carpet is visible at eye level near the rail - once L1 takes over, its 30 in Allium stems will tower above the 10 in pot and may need a wind-sheltered spot or light staking against balcony gusts.",
"A plain dark planter or a weathered backdrop will make the bright white-and-red late Apr pairing pop harder than a busy railing view.",
{
"...": "2 more"
}
]
}
task: grow - pink and orange tulips in zone 9 (adjust)
Input (decoded for reading; on the wire pot, layers, facts and waves are JSON strings):
{
"task": "grow",
"zone": 9,
"pot": {
"diameter_in": 16,
"depth_in": 16,
"material": "glazed ceramic",
"spot": "part sun",
"deer_pressure": false
},
"layers": [
{
"id": "L1",
"bulb": "Tulip, Darwin hybrid",
"colour": "orange",
"count": 20,
"depth_in": 8,
"rec_depth_in": 8,
"tip_depth_in": 6,
"bulb_height_in": 2,
"flower_height_in": 22,
"fits_about": 18,
"blooms": "early Apr",
"chill_weeks": 14,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false
},
{
"id": "L2",
"bulb": "Tulip, triumph",
"colour": "hot pink",
"count": 18,
"depth_in": 5.2,
"rec_depth_in": 7,
"tip_depth_in": 3.4,
"bulb_height_in": 1.8,
"flower_height_in": 18,
"fits_about": 18,
"blooms": "early Apr",
"chill_weeks": 14,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false
},
{
"id": "L3",
"bulb": "Windflower (Grecian anemone)",
"colour": "blue",
"count": 25,
"depth_in": 2,
"rec_depth_in": 2,
"tip_depth_in": 1.2,
"bulb_height_in": 0.8,
"flower_height_in": 5,
"fits_about": 41,
"blooms": "early Mar to late Mar",
"chill_weeks": 10,
"zones": "5-9",
"deer_resistant": true,
"manual_depth": false
}
],
"notes": "Patio by the reading chair - I want it calm and pretty, nothing loud.",
"today": "2026-10-19",
"facts": {
"checks": [
{
"level": "warn",
"code": "tight",
"layer": "L1",
"text": "20 Tulip, Darwin hybrid is a squeeze - about 18 fit comfortably."
},
{
"level": "warn",
"code": "prechill",
"layer": "L1",
"text": "Tulip, Darwin hybrid wants about 14 weeks of cold that zone 9 rarely gives - pre-chill it in a fridge first, and treat it as a one-season bulb."
},
{
"level": "warn",
"code": "prechill",
"layer": "L2",
"text": "Tulip, triumph wants about 14 weeks of cold that zone 9 rarely gives - pre-chill it in a fridge first, and treat it as a one-season bulb."
},
{
"...": "4 more checks"
}
],
"plant_window": "Dec 1 to Jan 15",
"window_status": "early",
"soil_quarts_est": 56,
"bulbs_total": 63,
"bloom_span": "early Mar to early Apr",
"gaps": []
}
}
Reply:
{
"task": "grow",
"title": "Three-Tier Patio Bulb Lasagna",
"verdict": "adjust",
"summary": "As stacked this pot is plantable with one fix: trim L1 from 20 to 18 Darwin hybrid tulips so they are not packed too tight. The depths already stack with enough clearance top, bottom and between layers. The biggest risk is timing: L1 and L2 need about 14 weeks of fridge chill that zone 9 cannot give outdoors, and only about 12.5 weeks remain before the Jan 15 window closes, so start chilling now and expect a lighter, one-season show from the tulips.",
"layers": [
{
"id": "L1",
"depth_in": 8,
"count": 18,
"status": "change",
"note": "20 Darwin hybrid is a squeeze (111% of fits_about 18); cut to 18 so bulbs do not touch. Depth of 8 in (full rec_depth_in) is fine: 8 in of mix below it and a 0.8 in gap to L2's base above clears the 0.75 in minimum."
},
{
"id": "L2",
"depth_in": 5.2,
"count": 18,
"status": "ok",
"note": "Count of 18 matches fits_about exactly - fine as is. Depth 5.2 in (about 74% of its 7 in rec_depth_in) leaves a 1.4 in gap to L3's base above, well clear of the 0.75 in minimum."
},
{
"id": "L3",
"depth_in": 2,
"count": 25,
"status": "ok",
"note": "25 corms is 61% of the 41 that fit, so no crowding. Depth 2 in with 1.2 in of cover over the tip clears the 1 in minimum over the top layer. Zones 5-9 means it is hardy here with no fridge chill needed."
}
],
"timing": {
"plant_by": "Hold bulbs cool and dry until the window opens around Dec 1; plant between Dec 1 and Jan 15, aiming for late in the window (near Jan 15) to give the chilling tulips as much fridge time as possible.",
"chill": "L1/L2 (zones 3-7) need 14 weeks of cold zone 9 will not give outdoors - start fridge pre-chilling today, away from fruit. Planting near Jan 15 gives about 12.5 weeks, about 1.5 short, so treat them as one season. L3 (zones 5-9) is hardy here, no fridge chill needed.",
"first_bloom": "Per facts.bloom_span: L3 opens early Mar, L1 and L2 follow in early Apr."
},
"steps": [
{
"step": 1,
"do": "Today, start pre-chilling the 18 Tulip, Darwin hybrid (L1) and 18 Tulip, triumph (L2) bulbs in a fridge, away from ripening fruit - they need about 14 weeks and zone 9 will not give that outdoors."
},
{
"step": 2,
"do": "Hold all bulbs in a cool, dry, airy spot until the window opens around Dec 1; do not plant before then."
},
{
"...": "6 more"
}
],
"care": [
{
"when": "after planting",
"do": "Water once well, then let the top inch of mix dry before watering again. Firm the surface and lay a temporary mesh over the top against squirrels until shoots show."
},
{
"when": "winter",
"do": "The pot's roots run about two zones colder than the ground; part sun and a glazed pot are fine in zone 9, but shift it against a wall or under eaves if a hard freeze is forecast."
},
{
"...": "2 more"
}
],
"risks": [
"L1 and L2 need about 14 weeks of fridge chill; starting today gives only about 12.5 weeks before the Jan 15 window closes, so bloom can be shorter or lighter, especially if chilling starts late.",
"Glazed ceramic drains slower than terracotta; with no saucer gap, a wet winter could still leave water sitting around the bulbs and rot them.",
"Squirrels often dig up bulbs in a freshly turned pot before shoots show."
],
"tips": [
"L3 (Windflower, zones 5-9) is already hardy in zone 9 and needs no fridge time, unlike the tulips above it.",
"L3's early-to-late Mar bloom does not overlap L1/L2's early Apr bloom, so expect two distinct waves rather than a seamless hand-over.",
{
"...": "2 more"
}
]
}
task: colour - pink and orange tulips in zone 9 (clash)
Input (decoded for reading; on the wire pot, layers, facts and waves are JSON strings):
{
"task": "colour",
"zone": 9,
"pot": {
"diameter_in": 16,
"depth_in": 16,
"material": "glazed ceramic",
"spot": "part sun",
"deer_pressure": false
},
"layers": [
{
"id": "L1",
"bulb": "Tulip, Darwin hybrid",
"colour": "orange",
"count": 20,
"depth_in": 8,
"rec_depth_in": 8,
"tip_depth_in": 6,
"bulb_height_in": 2,
"flower_height_in": 22,
"fits_about": 18,
"blooms": "early Apr",
"chill_weeks": 14,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false,
"colour_options": [
"red",
"orange",
"yellow",
"pink",
"apricot"
]
},
{
"id": "L2",
"bulb": "Tulip, triumph",
"colour": "hot pink",
"count": 18,
"depth_in": 5.2,
"rec_depth_in": 7,
"tip_depth_in": 3.4,
"bulb_height_in": 1.8,
"flower_height_in": 18,
"fits_about": 18,
"blooms": "early Apr",
"chill_weeks": 14,
"zones": "3-7",
"deer_resistant": false,
"manual_depth": false,
"colour_options": [
"purple",
"red",
"pink",
"hot pink",
"white",
"orange",
"apricot",
"yellow",
"near-black"
]
},
{
"id": "L3",
"bulb": "Windflower (Grecian anemone)",
"colour": "blue",
"count": 25,
"depth_in": 2,
"rec_depth_in": 2,
"tip_depth_in": 1.2,
"bulb_height_in": 0.8,
"flower_height_in": 5,
"fits_about": 41,
"blooms": "early Mar to late Mar",
"chill_weeks": 10,
"zones": "5-9",
"deer_resistant": true,
"manual_depth": false,
"colour_options": [
"blue",
"white",
"pink"
]
}
],
"notes": "Patio by the reading chair - I want it calm and pretty, nothing loud.",
"mood": "soft and calm",
"waves": [
{
"period": "early Mar",
"colours": [
"blue"
],
"layers": [
"L3 blue"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
},
{
"period": "late Mar",
"colours": [
"blue"
],
"layers": [
"L3 blue"
],
"harmony": "single colour",
"lean": "cool",
"lightness_range": 0
},
{
"period": "early Apr",
"colours": [
"orange",
"hot pink"
],
"layers": [
"L1 orange",
"L2 hot pink"
],
"harmony": "clashing",
"lean": "warm",
"lightness_range": 0
}
],
"facts": {
"palette": [
"orange",
"hot pink",
"blue"
],
"palette_size": 3,
"warm_waves": 1,
"cool_waves": 2,
"alternations": 1,
"clashes": [
{
"period": "early Apr",
"a": "orange",
"b": "hot pink"
}
],
"low_contrast_waves": [
"early Apr"
],
"neutrals": []
}
}
Reply:
{
"task": "colour",
"title": "Soft Patio Bulb Waves",
"verdict": "clash",
"story": "Early Mar carries into late Mar as one quiet blue wave from L3 - the calm, cool note the notes ask for, though with no lightness lift between the blues it reads a little flat up close. Early Apr is the one loud beat: L1's orange sits right against L2's hot pink, a pairing the page's own clash check flags, with zero contrast between them to soften it. Swapping L1 to apricot and L2 to white keeps April warm but quiet, closer to 'nothing loud'.",
"waves": [
{
"period": "early Mar",
"harmony": "single colour",
"read": "Merges early Mar and late Mar - both are the same solid blue from L3, a calm, cool opening note, just flat in value since nothing lightens the blue until the tulips arrive."
},
{
"period": "early Apr",
"harmony": "analogous",
"read": "L1's orange and L2's hot pink sit close in hue but far apart in intensity, which is why the page flags them as a clash rather than a true harmony; swapping to apricot and white settles it."
}
],
"swaps": [
{
"id": "L1",
"to": "apricot",
"why": "Apricot keeps the April wave warm but trades orange's punch for a muted tone that matches a calm patio mood."
},
{
"id": "L2",
"to": "white",
"why": "White removes the clash with L1's orange and adds the lightness lift this wave is missing."
}
],
"keep": [
{
"id": "L3",
"why": "A single cool blue in March already matches the calm mood and sits apart from the April clash, so it does not need to change."
}
],
"tips": [
"Place the apricot-and-white April tulips (L1, L2) nearest the reading chair so the softened pairing is what gets seen up close.",
"Let L3's blue Windflowers spill over the rim in March - a low, solid carpet reads calmer than scattered single stems.",
{
"...": "2 more"
}
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./bulbkit.js")), then:
const K = BulbKit;
const state = { pot: { diameter: 14, depth: 14, material: "plastic", spot: "full sun", zone: 6 },
layers: [ { id: "L1", bulb: "tulip-late", colour: "purple", count: 12 }, { id: "L2", bulb: "muscari", colour: "blue", count: 30 } ],
notes: "front step", mood: "classic" };
const { input, analysis } = K.buildInput("grow", state); // or "colour"
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// K.reconcile(reply, analysis) -> notes; K.applyGrow(state.layers, reply) -> the revised layers
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://bulb-lasagna.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://bulb-lasagna.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"]
// bulb-lasagna.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://bulb-lasagna.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://bulb-lasagna.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
}
// CueScroll.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 CueScroll {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://bulb-lasagna.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://bulb-lasagna.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://bulb-lasagna.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://bulb-lasagna.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":"bulb-lasagna"}' | 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": "bulb-lasagna"})["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: "bulb-lasagna" })).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": "bulb-lasagna"}, 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", "bulb-lasagna"), 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: "bulb-lasagna" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "bulb-lasagna"])["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 = "bulb-lasagna" }))!["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": "grow", "zone": 6, "pot": "{...}", "layers": "[...]", "facts": "{...}"}
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": "grow", "zone": 6, "pot": "{...}", "layers": "[...]", "facts": "{...}"}
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, zone, pot, layers, notes, today, facts, mood, waves}
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 bulb-lasagna:<task>:<inputHash>:a<attempt>, 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="bulb-lasagna:grow:$(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 = "bulb-lasagna:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `bulb-lasagna:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "bulb-lasagna:" + 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 = "bulb-lasagna:" + 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 = "bulb-lasagna:#{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["verdict"]].inspect
$key = "bulb-lasagna:" . $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["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"bulb-lasagna:{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: bulb-lasagna:grow:$(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": "grow", "zone": 6, "pot": "{...}", "layers": "[...]", "facts": "{...}"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "bulb-lasagna:grow:" + 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": `bulb-lasagna:grow:${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", "bulb-lasagna:grow:"+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", "bulb-lasagna:grow:" + 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" => "bulb-lasagna:grow:#{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: bulb-lasagna:grow:" . 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", $"bulb-lasagna:grow:{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);