Drive Room Palette from your own code
Everything the page does is available over HTTP. A natural use is a staging or listing workflow: measure each room photo as it comes in, ask for the designer's reading, and file the palette and the proposed changes with the photo.
One thing to be clear about first: the model never sees the photo. There is no vision model in this app. The caller measures the picture (palette, shares, thirds, temperature, values, cast, pair contrasts) and sends those numbers. The model returns a reading that refers to the swatches by id; the caller checks it against the measurements. The page's own measurer and checker ship as plain scripts you can load in node — see the end of the examples.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"room-palette"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: readings are metered and need a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the input
The app has one lane. Every run body is a JSON object of seven string fields (the declared input schema; task and facts are required, and /estimate warns when one is missing):
| Field | What it holds |
|---|---|
task | Always "read": the designer's reading of one room. Lanes: read only. |
room | One of living, bedroom, kitchen, dining, bathroom, office, kids, hall, other. |
name | Optional. The person's name for the room; the page uses it to find the last reading of the same room. |
goal | Optional. What they want from the room (the page cuts the middle past 600 characters, with a marker). |
keep | Optional. What is staying (cut past 300 characters). |
facts | The measurements as a JSON string: image (size, aspect, orientation), swatches[] (id S1…, hex, share_pct, CIELAB L/C/h, temp, lean, lrv, basic_name, main_third, thirds_pct in the order tl tc tr ml mc mr bl bc br), families_60_30_10, warm_cool_neutral_pct, values_pct, chroma_mean, cast, hue_geometry, pairs (WCAG contrast and CIEDE2000 for every two swatches), thirds[] and focal_third. |
previous | Optional (empty string when absent). The last reading of the same room as a JSON string: date, verdict, palette, 60-30-10 split and the changes proposed then. |
The reply text is one JSON object:
{
"lane": "read", "title": "...", "headline": "...",
"verdict": "working | tune | rework",
"scheme": { "type": "monochromatic|analogous|complementary|split_complementary|triadic|neutral_with_accent|unclear", "swatches": ["S2"], "why": "..." },
"roles": [{ "swatch": "S1", "role": "dominant|secondary|accent|base|trim|incidental", "name", "where", "note" }],
"ratio": { "reading", "fix" },
"light": { "reading", "advice" },
"callouts": [{ "id", "anchor": "S3 or tl|tc|tr|ml|mc|mr|bl|bc|br", "kind": "strength|issue|idea", "title", "text" }],
"moves": [{ "id", "action": "add|reduce|replace|repeat", "target": "S4", "hex": "#RRGGBB", "anchor": "tr", "what", "why", "effort": "low|medium|high" }],
"questions": [...], "summary": "..."
}
Worked examples
These are the four bundled examples on the page, covering every verdict: the exact run body the page builds from each illustrated room and a reply produced against the shipped prompt (the page replays the same replies for free).
working: a sage and terracotta kitchen
Input:
{
"task": "read",
"room": "kitchen",
"name": "Kitchen",
"goal": "Just finished the kitchen - sage cabinets, terracotta bits. Does it hang together? Anything I'd regret?",
"keep": "the sage cabinets and the oak worktop",
"facts": "(a JSON string - decoded below)",
"previous": ""
}
facts, decoded (6 swatches):
{
"image": {
"width": 960,
"height": 640,
"aspect": "3:2",
"orientation": "landscape"
},
"swatches": [
{
"id": "S1",
"hex": "#E4DED2",
"share_pct": 58.1,
"L": 88.6,
"C": 6.6,
"h": 90,
"temp": "neutral",
"lean": "warm",
"lrv": 73.4,
"basic_name": "warm white",
"main_third": "tl",
"thirds_pct": [
17.9,
17.5,
17.6,
10.3,
12,
11,
6.2,
1.9,
5.6
]
},
{
"id": "S2",
"hex": "#8B9B7F",
"share_pct": 28.2,
"L": 62.1,
"C": 16.9,
"h": 131,
"temp": "cool",
"lrv": 30.5,
"basic_name": "mid muted green",
"main_third": "bl",
"thirds_pct": [
1.8,
0.7,
0.7,
10.5,
8.1,
12.3,
23.7,
21.5,
20.8
]
},
{
"id": "S3",
"hex": "#AC5C3C",
"share_pct": 6.1,
"L": 47.9,
"C": 44.2,
"h": 48,
"temp": "warm",
"lrv": 16.8,
"basic_name": "deep terracotta",
"main_third": "bc",
"thirds_pct": [
2.2,
0,
12.7,
13.8,
8.8,
0,
8.8,
45.4,
8.4
]
},
{
"id": "S4",
"hex": "#363633",
"share_pct": 3.4,
"L": 22.5,
"C": 2,
"h": 110,
"temp": "neutral",
"lean": "warm",
"lrv": 3.7,
"basic_name": "dark taupe",
"main_third": "br",
"thirds_pct": [
4.1,
22.8,
0,
8.7,
8.7,
8.7,
0,
22,
24.9
]
},
{
"id": "S5",
"hex": "#BC9A6D",
"share_pct": 3.2,
"L": 65.7,
"C": 29.1,
"h": 77,
"temp": "warm",
"lrv": 34.9,
"basic_name": "mid tan",
"main_third": "ml",
"thirds_pct": [
0,
0,
0,
33.8,
33.8,
32.5,
0,
0,
0
]
},
{
"id": "S6",
"hex": "#707967",
"share_pct": 1,
"L": 49.6,
"C": 11.2,
"h": 128,
"temp": "cool",
"lrv": 18.1,
"basic_name": "deep muted green",
"main_third": "br",
"thirds_pct": [
2,
5.6,
0,
2.4,
2.4,
2.4,
9.6,
26.1,
49.4
]
}
],
"families_60_30_10": {
"first_pct": 58.1,
"second_pct": 28.2,
"rest_pct": 13.7,
"groups": [
"S1 58.1%",
"S2 28.2%",
"S3 6.1%",
"S4 3.4%",
"S5 3.2%",
"S6 1%"
]
},
"warm_cool_neutral_pct": {
"warm": 9.7,
"cool": 28.8,
"neutral": 61.5
},
"values_pct": {
"light": 58.1,
"mid": 38.5,
"dark": 3.3,
"L_p5": 46.4,
"L_p95": 93.6
},
"chroma_mean": 12.6,
"cast": {
"a": -0.2,
"b": 6.4,
"label": "warm (yellow) cast",
"neutral_share": 0.62
},
"hue_geometry": {
"chromatic": [
"S2",
"S3",
"S5"
],
"span_deg": 83,
"complementary_pairs": []
},
"pairs": [
"S1/S2 contrast 2.21 dE 22.8",
"S1/S3 contrast 3.6 dE 38.6",
"S1/S4 contrast 9.05 dE 62.2",
"S1/S5 contrast 1.96 dE 21",
"S1/S6 contrast 3.39 dE 31.6",
"S2/S3 contrast 1.63 dE 34.5",
"S2/S4 contrast 4.09 dE 38.1",
"S2/S5 contrast 1.13 dE 19.4",
"S2/S6 contrast 1.54 dE 12.4",
"S3/S4 contrast 2.51 dE 31.6",
"S3/S5 contrast 1.83 dE 22.2",
"S3/S6 contrast 1.06 dE 29.1",
"S4/S5 contrast 4.61 dE 43.8",
"S4/S6 contrast 2.67 dE 24.2",
"S5/S6 contrast 1.73 dE 23.3"
],
"thirds": [
{
"id": "tl",
"dominant": "S1",
"dominant_pct": 92.9,
"L": 90.4,
"L_sd": 10.7,
"C": 7.6
},
{
"id": "tc",
"dominant": "S1",
"dominant_pct": 90.8,
"L": 85.6,
"L_sd": 17.9,
"C": 6
},
{
"id": "tr",
"dominant": "S1",
"dominant_pct": 91.3,
"L": 85.6,
"L_sd": 10.8,
"C": 9.2
},
{
"id": "ml",
"dominant": "S1",
"dominant_pct": 53.5,
"L": 76.3,
"L_sd": 16.8,
"C": 14.8
},
{
"id": "mc",
"dominant": "S1",
"dominant_pct": 62.3,
"L": 77.9,
"L_sd": 16.2,
"C": 12.5
},
{
"id": "mr",
"dominant": "S1",
"dominant_pct": 57.1,
"L": 75.4,
"L_sd": 14.7,
"C": 11.5
},
{
"id": "bl",
"dominant": "S2",
"dominant_pct": 61,
"L": 68.3,
"L_sd": 10,
"C": 15.8
},
{
"id": "bc",
"dominant": "S2",
"dominant_pct": 55.4,
"L": 56.7,
"L_sd": 13.5,
"C": 21.8
},
{
"id": "br",
"dominant": "S2",
"dominant_pct": 53.6,
"L": 61.5,
"L_sd": 15.2,
"C": 14.1
}
],
"focal_third": "bc"
}
Reply:
{
"lane": "read",
"title": "Sage, oak and terracotta: a kitchen that holds",
"headline": "The S2 sage cabinets, S5 oak and S3 terracotta sit in one warm-leaning hue family under an S1 warm white, so the kitchen hangs together; the only soft spot is that sage, oak and terracotta are close in lightness, so the scheme relies on hue rather than value to separate them.",
"verdict": "working",
"scheme": {
"type": "analogous",
"swatches": [
"S2",
"S5",
"S3"
],
"why": "The three chromatic swatches, S3 terracotta, S5 tan and S2 sage, span an arc of 83 degrees with no opposite pair, which is a classic analogous run from earth-red through ochre to grey-green."
},
"roles": [
{
"swatch": "S1",
"role": "dominant",
"name": "Warm parchment white",
"where": "Across the whole top row (tl, tc, tr) and much of the middle row - most likely the walls, ceiling and upper splashback.",
"note": "Its warm lean matches the S4 dark taupe and the warm S5 oak, so the neutrals agree with each other rather than fighting."
},
{
"swatch": "S2",
"role": "secondary",
"name": "Soft sage",
"where": "The bottom row (bl, bc, br) with some in ml, mc, mr - most likely the base cabinets you are keeping.",
"note": "Muted enough to read as a calm colour rather than a statement, and it fills the secondary slot almost exactly at 28.2%."
},
{
"swatch": "S3",
"role": "accent",
"name": "Fired terracotta",
"where": "Concentrated in bc, with smaller amounts in tr, ml, mc, bl and br - likely tiles, a runner or pots near the centre of the cabinet run.",
"note": "The only high-chroma colour in the room and it already appears in several thirds, so it reads as intended rather than accidental."
},
{
"swatch": "S4",
"role": "trim",
"name": "Charcoal taupe",
"where": "Small amounts in tc, bc and br - most likely handles, a tap, a range hood or light fittings.",
"note": "It carries almost all of the room's dark value and gives the scheme the little anchoring it has."
},
{
"swatch": "S5",
"role": "base",
"name": "Honey oak",
"where": "A band spread evenly across ml, mc and mr - almost certainly the oak worktop you are keeping.",
"note": "It bridges the S3 terracotta and the S1 white in warmth, but sits close to S2 sage in lightness so the worktop edge relies on hue to stand out."
},
{
"swatch": "S6",
"role": "incidental",
"name": "Shadowed sage",
"where": "Mainly br and bc, inside the S2 areas - most likely the shaded side of the cabinets and the toe kick.",
"note": "Barely a different colour from S2 sage, it is the cabinet colour in shadow rather than a separate choice."
}
],
"ratio": {
"reading": "The family groups land close to the classic proportion: S1 at 58.1% as the dominant, S2 at 28.2% as the secondary, and S3 at 6.1% as the accent, with S4, S5 and S6 making up small supporting parts. S6 is really a shade of the S2 cabinets, which nudges the sage family slightly higher than its single figure.",
"fix": ""
},
"light": {
"reading": "The neutrals carry a warm (yellow) cast, which could be warm evening light or warm bulbs, or simply the camera's white balance - one photo cannot separate them. Values are mostly light (58.1%) and mid (38.5%) with very little dark (3.3%), so the room reads airy and bright but a little soft-edged.",
"advice": "Keep lamps at 2700K-3000K so the S2 sage stays grey-green and does not drift cold; under a 4000K or cooler light the warm S1 white will look creamier against cooler-looking sage. Check the S1 wall colour in morning daylight before deciding it is too yellow - the cast may be the camera."
},
"callouts": [
{
"id": "c1",
"anchor": "S3",
"kind": "strength",
"title": "Terracotta repeated, not dropped",
"text": "S3 shows up in several thirds, heaviest in bc but also in tr and ml, so the accent reads as a deliberate thread through the room."
},
{
"id": "c2",
"anchor": "S1",
"kind": "strength",
"title": "Neutrals share one undertone",
"text": "S1 and S4 both lean warm and agree with the S5 oak, so there is no warm-versus-cool white clash against the sage."
},
{
"id": "c3",
"anchor": "S5",
"kind": "issue",
"title": "Oak and sage close in value",
"text": "S2 sage and S5 oak are near-identical in lightness (contrast 1.13), so the worktop line separates from the cabinets by hue only and can look slightly blurred in low light."
},
{
"id": "c4",
"anchor": "tc",
"kind": "idea",
"title": "Upper half has no warmth",
"text": "The top row is almost all S1; S3 is absent from tc, so the eye stays low on the cabinets. A small terracotta or oak touch up high would tie the two halves together."
},
{
"id": "c5",
"anchor": "S4",
"kind": "strength",
"title": "Dark trim does the anchoring",
"text": "S4 is the only real dark in a room with very little dark value; keep it, as it stops the sage-and-white from floating."
}
],
"moves": [
{
"id": "m1",
"action": "repeat",
"target": "S3",
"hex": "",
"anchor": "tc",
"what": "terracotta pot or jug on an upper shelf",
"why": "S3 is absent from tc and the top row is almost all S1, so one terracotta touch up high carries the accent into the upper half.",
"effort": "low"
},
{
"id": "m2",
"action": "repeat",
"target": "S5",
"hex": "",
"anchor": "tl",
"what": "oak open shelf or board on the upper wall",
"why": "S5 sits only in the middle row; echoing the oak higher up links the worktop to the walls and adds warmth where S1 dominates.",
"effort": "medium"
}
],
"questions": [
"Was the photo taken under your kitchen lights or daylight? The warm cast changes how the S1 walls will read at other times of day."
],
"summary": "Yes, it hangs together: sage, oak and terracotta form one analogous run under a warm white, and the proportions already fall where a designer would put them. Nothing here looks like a future regret. If you do anything, bring a little terracotta or oak into the upper half of the room, keep the dark S4 trim, and stay with warm 2700K-3000K bulbs so the sage keeps its softness."
}
tune: a flat greige living room
Input:
{
"task": "read",
"room": "living",
"name": "Front room",
"goal": "It feels flat and a bit beige. I want it calm but with some life in it - nothing loud.",
"keep": "the oak floor and the grey sofa",
"facts": "(a JSON string - decoded below)",
"previous": ""
}
facts, decoded (5 swatches):
{
"image": {
"width": 960,
"height": 640,
"aspect": "3:2",
"orientation": "landscape"
},
"swatches": [
{
"id": "S1",
"hex": "#C9C2B5",
"share_pct": 54.1,
"L": 78.7,
"C": 7.4,
"h": 89,
"temp": "neutral",
"lean": "warm",
"lrv": 54.3,
"basic_name": "pale greige",
"main_third": "tl",
"thirds_pct": [
16.9,
15.4,
8.6,
9.5,
8.9,
9.8,
8.4,
12.4,
10.3
]
},
{
"id": "S2",
"hex": "#E1E3E1",
"share_pct": 17.5,
"L": 90,
"C": 1.3,
"h": 144,
"temp": "neutral",
"lrv": 76.4,
"basic_name": "white",
"main_third": "tr",
"thirds_pct": [
11.9,
16.5,
37.5,
0,
5.4,
28.7,
0,
0,
0
]
},
{
"id": "S3",
"hex": "#88857F",
"share_pct": 14.8,
"L": 55.6,
"C": 3.6,
"h": 90,
"temp": "neutral",
"lean": "warm",
"lrv": 23.5,
"basic_name": "mid greige",
"main_third": "ml",
"thirds_pct": [
0,
0,
0,
41.2,
35.6,
0.6,
11.1,
10.2,
1.4
]
},
{
"id": "S4",
"hex": "#94704E",
"share_pct": 11.9,
"L": 50,
"C": 26.2,
"h": 68,
"temp": "warm",
"lrv": 18.4,
"basic_name": "mid brown",
"main_third": "bl",
"thirds_pct": [
0,
0,
0,
0,
0,
0,
40.1,
20.9,
39
]
},
{
"id": "S5",
"hex": "#685441",
"share_pct": 1.8,
"L": 37.2,
"C": 15.2,
"h": 70,
"temp": "warm",
"lrv": 9.7,
"basic_name": "deep muted brown",
"main_third": "mr",
"thirds_pct": [
0,
0,
0,
0,
10.9,
45,
0,
13.3,
30.8
]
}
],
"families_60_30_10": {
"first_pct": 71.6,
"second_pct": 14.8,
"rest_pct": 13.7,
"groups": [
"S1+S2 71.6%",
"S3 14.8%",
"S4 11.9%",
"S5 1.8%"
]
},
"warm_cool_neutral_pct": {
"warm": 13.4,
"cool": 0,
"neutral": 86.6
},
"values_pct": {
"light": 71.5,
"mid": 28.3,
"dark": 0.2,
"L_p5": 49.1,
"L_p95": 93.1
},
"chroma_mean": 8.2,
"cast": {
"a": 0.1,
"b": 5.2,
"label": "warm (yellow) cast",
"neutral_share": 0.867
},
"hue_geometry": {
"chromatic": [
"S4"
],
"span_deg": 0,
"complementary_pairs": []
},
"pairs": [
"S1/S2 contrast 1.37 dE 9.6",
"S1/S3 contrast 2.08 dE 18.7",
"S1/S4 contrast 2.53 dE 27",
"S1/S5 contrast 4.05 dE 38.3",
"S2/S3 contrast 2.85 dE 25.9",
"S2/S4 contrast 3.47 dE 35.7",
"S2/S5 contrast 5.55 dE 45.9",
"S3/S4 contrast 1.22 dE 15.9",
"S3/S5 contrast 1.95 dE 20.1",
"S4/S5 contrast 1.6 dE 13.3"
],
"thirds": [
{
"id": "tl",
"dominant": "S1",
"dominant_pct": 81.6,
"L": 83.9,
"L_sd": 5.9,
"C": 6.9
},
{
"id": "tc",
"dominant": "S1",
"dominant_pct": 74.3,
"L": 83.3,
"L_sd": 6,
"C": 6.4
},
{
"id": "tr",
"dominant": "S2",
"dominant_pct": 58.6,
"L": 85.3,
"L_sd": 6,
"C": 4.7
},
{
"id": "ml",
"dominant": "S3",
"dominant_pct": 54.4,
"L": 66.5,
"L_sd": 11.5,
"C": 5
},
{
"id": "mc",
"dominant": "S3",
"dominant_pct": 47,
"L": 67,
"L_sd": 12.8,
"C": 5
},
{
"id": "mr",
"dominant": "S1",
"dominant_pct": 47.3,
"L": 77.6,
"L_sd": 12.6,
"C": 5.2
},
{
"id": "bl",
"dominant": "S4",
"dominant_pct": 43.5,
"L": 63.2,
"L_sd": 13.6,
"C": 15
},
{
"id": "bc",
"dominant": "S1",
"dominant_pct": 61.3,
"L": 67.6,
"L_sd": 13.7,
"C": 11.1
},
{
"id": "br",
"dominant": "S1",
"dominant_pct": 50.9,
"L": 62.3,
"L_sd": 14.5,
"C": 15.2
}
],
"focal_third": "br"
}
Reply:
{
"lane": "read",
"title": "Front room: warm greige with no dark anchor",
"headline": "Almost nothing in the room is dark (0.2% of the picture), so the S1 walls, S2 white and S3 sofa blur into one pale mass. That is why it reads flat and beige.",
"verdict": "tune",
"scheme": {
"type": "neutral_with_accent",
"swatches": [
"S1",
"S3",
"S4"
],
"why": "S4 is the only chromatic swatch, so there is no hue arc to speak of. The picture is mostly neutral, and the warm brown of the S4 floor is the one note of colour."
},
"roles": [
{
"swatch": "S1",
"role": "dominant",
"name": "Pale warm greige",
"where": "Everywhere, heaviest in tl and tc. Most likely the walls.",
"note": "A warm-leaning neutral at 54.1% that the yellow cast pushes towards beige. This is the main source of the beige feeling."
},
{
"swatch": "S2",
"role": "dominant",
"name": "Soft off-white",
"where": "Mostly tr and mr. Probably a window, its light, or the ceiling.",
"note": "It belongs to the same family as S1 (S1+S2 71.6%) and sits too close to it in value to give the walls any edge."
},
{
"swatch": "S3",
"role": "secondary",
"name": "Warm mid grey",
"where": "ml and mc. Almost certainly the grey sofa you are keeping.",
"note": "Its warm lean matches S1, which is good. But it is only a mid tone and barely separates from the S4 floor under it."
},
{
"swatch": "S4",
"role": "base",
"name": "Honey oak",
"where": "Across bl, bc and br. The oak floor.",
"note": "The only real colour in the room. It grounds the bottom third but cannot carry a whole scheme alone."
},
{
"swatch": "S5",
"role": "accent",
"name": "Deep walnut brown",
"where": "mr and br. Possibly a side table, legs or a frame.",
"note": "At 1.8% it is the nearest the room has to a dark note, but it is confined to the right side and too small to anchor anything."
}
],
"ratio": {
"reading": "One pale family (S1+S2 71.6%) takes nearly three-quarters of the picture, the S3 sofa is a modest second at 14.8%, and the S4 floor at 11.9% does the accent's job. The proportion is roughly right, but all three steps share one temperature and almost one depth, so they don't read as distinct layers.",
"fix": "Keep the proportion but separate the layers. Take S1 a little deeper or greener so it parts from S2, and give the room a small, repeated dark accent of a colour it doesn't have yet."
},
"light": {
"reading": "The neutrals carry a warm (yellow) cast, and S1 already leans warm, so the walls read beige rather than greige. It could be lamp or evening light, or simply the camera's white balance. A single photo can't tell those apart.",
"advice": "Before buying any paint, look at a sample on the S1 wall in daylight and again in the evening. In the evening use 2700K lamps at low level, not a warm ceiling light, so the walls aren't bathed in yellow from above. If daylight is cool, a sage-leaning wall will hold steadier than a yellow-leaning one."
},
"callouts": [
{
"id": "c1",
"anchor": "S3",
"kind": "strength",
"title": "Undertones agree",
"text": "The S3 sofa and S1 walls both lean warm, so nothing is fighting. The calm you want is already built in."
},
{
"id": "c2",
"anchor": "tr",
"kind": "issue",
"title": "Walls and white merge",
"text": "S1 and S2 are so close in value that the white in tr does little to frame the room. The top half reads as one pale field."
},
{
"id": "c3",
"anchor": "ml",
"kind": "issue",
"title": "Sofa sinks into floor",
"text": "S3 sits almost level with the S4 floor below it, so the sofa in ml and mc has no edge. A darker layer between them would define it."
},
{
"id": "c4",
"anchor": "S4",
"kind": "strength",
"title": "Oak gives the warmth",
"text": "S4 is the only chromatic colour and spans the whole bottom third. It is a good warm base to set a cooler accent against."
},
{
"id": "c5",
"anchor": "br",
"kind": "idea",
"title": "Dark note stuck right",
"text": "S5 lives only in mr and br. Echoing it on the left and up high would stop the dark from looking accidental."
}
],
"moves": [
{
"id": "m1",
"action": "add",
"target": "",
"hex": "#3F5E5A",
"anchor": "mc",
"what": "deep teal cushions or a throw on the sofa",
"why": "The room has almost no dark values, and a muted blue-green sits opposite the warm S4 oak. It adds depth and some life without being loud, and it lifts the S3 sofa off the floor.",
"effort": "low"
},
{
"id": "m2",
"action": "add",
"target": "",
"hex": "#3F5E5A",
"anchor": "tr",
"what": "the same teal in a vase, print or book spines",
"why": "Repeating the accent in a second third, well away from the sofa, makes it look intended rather than a one-off.",
"effort": "low"
},
{
"id": "m3",
"action": "repeat",
"target": "S5",
"hex": "",
"anchor": "tl",
"what": "dark walnut frame, lamp base or shelf",
"why": "S5 is currently only in mr and br. Taking it to tl balances the room's dark notes and anchors the pale upper walls.",
"effort": "low"
},
{
"id": "m4",
"action": "replace",
"target": "S1",
"hex": "#ABAE97",
"anchor": "tl",
"what": "repaint the walls a soft warm sage",
"why": "S1 is the 54.1% that reads beige. A slightly deeper, grey-green neutral keeps the warm lean that suits the S3 sofa and S4 oak, separates more clearly from S2, and sits in the same hue family as the teal.",
"effort": "high"
}
],
"questions": [
"Was the photo taken in the evening under lamps, or in daylight? That decides how much of the beige is the paint and how much is the light.",
"Are the walls due for repainting, or should the changes stay with textiles and objects?"
],
"summary": "Nothing in the room is wrong, but it has no dark values and only one colour, the oak floor, which is why it feels flat. Start with deep teal cushions on the sofa and the same teal in one object at the top right, then take the dark walnut of S5 over to the left. If that isn't enough, repaint the S1 walls a soft warm sage so they stop reading as beige under warm light."
}
tune: a navy feature-wall bedroom
Input:
{
"task": "read",
"room": "bedroom",
"name": "Main bedroom",
"goal": "The navy wall went up last month. Does the mustard work or is it too much? I want it cosy, not dark.",
"keep": "the navy wall",
"facts": "(a JSON string - decoded below)",
"previous": ""
}
facts, decoded (6 swatches):
{
"image": {
"width": 960,
"height": 640,
"aspect": "3:2",
"orientation": "landscape"
},
"swatches": [
{
"id": "S1",
"hex": "#E3DFD6",
"share_pct": 36.6,
"L": 88.9,
"C": 4.9,
"h": 92,
"temp": "neutral",
"lean": "warm",
"lrv": 74,
"basic_name": "warm white",
"main_third": "mr",
"thirds_pct": [
14.9,
0,
21,
21.3,
16.2,
22,
2.3,
0,
2.3
]
},
{
"id": "S2",
"hex": "#23314A",
"share_pct": 27.4,
"L": 20.2,
"C": 17.4,
"h": 278,
"temp": "cool",
"lrv": 3,
"basic_name": "dark muted blue",
"main_third": "tc",
"thirds_pct": [
12.8,
40.9,
12.8,
8.1,
15.4,
8.1,
1,
0,
0.9
]
},
{
"id": "S3",
"hex": "#66472F",
"share_pct": 22.6,
"L": 33,
"C": 22.1,
"h": 63,
"temp": "warm",
"lrv": 7.5,
"basic_name": "deep brown",
"main_third": "bl",
"thirds_pct": [
0,
0,
0,
0,
0,
0,
36.8,
26.5,
36.7
]
},
{
"id": "S4",
"hex": "#C48E27",
"share_pct": 7.7,
"L": 62.7,
"C": 59.8,
"h": 79,
"temp": "warm",
"lrv": 31.2,
"basic_name": "mid vivid ochre",
"main_third": "bc",
"thirds_pct": [
0,
0,
0,
1.3,
13.4,
1.3,
11.5,
61.1,
11.5
]
},
{
"id": "S5",
"hex": "#8A6341",
"share_pct": 3,
"L": 45.2,
"C": 27.9,
"h": 66,
"temp": "warm",
"lrv": 14.7,
"basic_name": "deep brown",
"main_third": "ml",
"thirds_pct": [
0.1,
0,
0,
24.2,
0,
24.1,
20.9,
8.6,
22.2
]
},
{
"id": "S6",
"hex": "#B97E43",
"share_pct": 2.7,
"L": 57.7,
"C": 44.1,
"h": 67,
"temp": "warm",
"lrv": 25.6,
"basic_name": "mid orange",
"main_third": "tl",
"thirds_pct": [
83,
0,
0,
12.9,
1.1,
3,
0,
0,
0
]
}
],
"families_60_30_10": {
"first_pct": 36.6,
"second_pct": 27.4,
"rest_pct": 36,
"groups": [
"S1 36.6%",
"S2 27.4%",
"S3+S5 25.6%",
"S4+S6 10.4%"
]
},
"warm_cool_neutral_pct": {
"warm": 36.7,
"cool": 27.4,
"neutral": 35.9
},
"values_pct": {
"light": 36.6,
"mid": 13.8,
"dark": 49.6,
"L_p5": 18.9,
"L_p95": 92.4
},
"chroma_mean": 18.2,
"cast": {
"a": -0.1,
"b": 4.8,
"label": "warm (yellow) cast",
"neutral_share": 0.361
},
"hue_geometry": {
"chromatic": [
"S2",
"S3",
"S4",
"S5",
"S6"
],
"span_deg": 161,
"complementary_pairs": [
"S2/S4 161deg"
]
},
"pairs": [
"S1/S2 contrast 9.81 dE 67.8",
"S1/S3 contrast 6.3 dE 50.4",
"S1/S4 contrast 2.18 dE 29.5",
"S1/S5 contrast 4.01 dE 38.3",
"S1/S6 contrast 2.58 dE 30.6",
"S2/S3 contrast 1.56 dE 28",
"S2/S4 contrast 4.5 dE 54.6",
"S2/S5 contrast 2.45 dE 35.2",
"S2/S6 contrast 3.81 dE 47.2",
"S3/S4 contrast 2.89 dE 33",
"S3/S5 contrast 1.57 dE 11",
"S3/S6 contrast 2.44 dE 25.2",
"S4/S5 contrast 1.84 dE 21.1",
"S4/S6 contrast 1.18 dE 9.6",
"S5/S6 contrast 1.55 dE 13.9"
],
"thirds": [
{
"id": "tl",
"dominant": "S1",
"dominant_pct": 48.7,
"L": 62.6,
"L_sd": 31.6,
"C": 17.5
},
{
"id": "tc",
"dominant": "S2",
"dominant_pct": 100.1,
"L": 19.5,
"L_sd": 0.3,
"C": 18
},
{
"id": "tr",
"dominant": "S1",
"dominant_pct": 68.7,
"L": 67,
"L_sd": 32.2,
"C": 9.2
},
{
"id": "ml",
"dominant": "S1",
"dominant_pct": 69.7,
"L": 71.7,
"L_sd": 28.2,
"C": 11
},
{
"id": "mc",
"dominant": "S1",
"dominant_pct": 53,
"L": 62.9,
"L_sd": 31.7,
"C": 13.5
},
{
"id": "mr",
"dominant": "S1",
"dominant_pct": 71.9,
"L": 69.8,
"L_sd": 27.1,
"C": 9.6
},
{
"id": "bl",
"dominant": "S3",
"dominant_pct": 76,
"L": 40.5,
"L_sd": 16.1,
"C": 24.2
},
{
"id": "bc",
"dominant": "S3",
"dominant_pct": 54.7,
"L": 45.7,
"L_sd": 14.7,
"C": 38.3
},
{
"id": "br",
"dominant": "S3",
"dominant_pct": 75.8,
"L": 38.9,
"L_sd": 15.4,
"C": 23.4
}
],
"focal_third": "tl"
}
Reply:
{
"lane": "read",
"title": "Navy and mustard bedroom: lift the dark lower half",
"headline": "The mustard (S4) is the right amount and the right colour against the navy (S2); what makes the room feel dark is the deep brown (S3) below, which merges with the navy into one heavy mass.",
"verdict": "tune",
"scheme": {
"type": "complementary",
"swatches": [
"S2",
"S4"
],
"why": "S2/S4 is the listed opposite pair in the hue geometry: navy and ochre sit across the wheel, and the warm browns S3, S5 and S6 stay on the ochre side of that pair."
},
"roles": [
{
"swatch": "S1",
"role": "dominant",
"name": "Warm linen white",
"where": "Spread across tl, tr, ml, mc and mr, most likely the side walls and ceiling around the navy wall",
"note": "Its warm lean sits easily with the browns and the mustard and gives the room its only real light value."
},
{
"swatch": "S2",
"role": "secondary",
"name": "Ink navy",
"where": "Concentrated in tc and running into tl, tr and mc, most likely the new feature wall behind the bed",
"note": "A calm, even field of colour that is staying; everything else should be tuned to it, not the other way round."
},
{
"swatch": "S3",
"role": "base",
"name": "Walnut brown",
"where": "Only in the bottom row, bl, bc and br, most likely the floor or a dark wooden bed base",
"note": "It is almost as dark as the navy, so the two read as one dark block from top centre to the floor."
},
{
"swatch": "S4",
"role": "accent",
"name": "Mustard ochre",
"where": "Mostly bc with some in mc, bl and br, most likely a throw or bedding across the foot of the bed",
"note": "High chroma and opposite the navy, so it does exactly an accent's job; it is just gathered in one place."
},
{
"swatch": "S5",
"role": "base",
"name": "Mid walnut",
"where": "In ml and mr and along the bottom row, possibly matching bedside tables either side of the bed",
"note": "A lighter shade of the S3 wood that adds a little mid value at the sides."
},
{
"swatch": "S6",
"role": "accent",
"name": "Amber glow",
"where": "Almost all in tl, the focal third, possibly a lit lamp or a warm wooden or brass piece",
"note": "It is close to S4 and already carries the mustard family up to the top of the room on one side."
}
],
"ratio": {
"reading": "The families are S1 36.6%, S2 27.4%, S3+S5 25.6% and S4+S6 10.4%. The accent family is right on the 10% mark, so the mustard is not too much. The imbalance is elsewhere: the two dark families together outweigh the light S1, and values_pct puts dark at 49.6% against light at 36.6%, with very little in the middle.",
"fix": "Keep the mustard at its current amount and move some of the dark weight off the floor: a light rug over the S3 area and a mid-value textile on the bed would push the light share up and fill the empty middle of the value range."
},
"light": {
"reading": "The neutrals carry a warm yellow cast, which points to lamplight or evening sun, though the camera's white balance could add some of it. In that light the warm-leaning S1 looks creamy and the mustard glows; the navy S2, with an LRV of 3, takes in almost all the light that hits it.",
"advice": "Use 2700K bulbs in bedside and table lamps and place at least one where it washes the navy wall, so the wall reads as deep colour, not a black hole. In daylight the navy will look colder and the warm white whiter, so check the room at both times before buying anything else for it."
},
"callouts": [
{
"id": "c1",
"anchor": "S4",
"kind": "strength",
"title": "Mustard is the right partner",
"text": "S4 sits opposite the navy S2 on the wheel and is the most saturated colour in the room, so it gives the dark wall a warm, deliberate counterweight. It is not too much: its family is the 10% accent share."
},
{
"id": "c2",
"anchor": "tc",
"kind": "strength",
"title": "Navy wall reads as one field",
"text": "The top centre is almost pure S2 with almost no change in lightness, so the new wall reads as a clean, intentional block behind the bed."
},
{
"id": "c3",
"anchor": "S3",
"kind": "issue",
"title": "Navy and brown merge",
"text": "S2 and S3 are only just different in lightness (contrast 1.56), so the navy wall and the dark brown below read as one dark mass. That, not the mustard, is what makes the room feel dark."
},
{
"id": "c4",
"anchor": "bc",
"kind": "issue",
"title": "Accent stuck at bed level",
"text": "Most of S4 sits in bc and none of it reaches the top row, so the mustard reads as one throw, not a thread through the room."
},
{
"id": "c5",
"anchor": "tl",
"kind": "idea",
"title": "Amber corner already helps",
"text": "S6 in tl, the focal third, is a warm echo of the mustard up high; a matching echo on the right side would balance it."
},
{
"id": "c6",
"anchor": "S1",
"kind": "strength",
"title": "Warm white suits the scheme",
"text": "S1 leans warm, like the browns and the mustard, so there is no undertone clash among the neutrals; keep any new whites on this warm side."
}
],
"moves": [
{
"id": "m1",
"action": "add",
"target": "",
"hex": "#CBBBA0",
"anchor": "bl",
"what": "warm oatmeal rug over the dark floor",
"why": "Breaking up S3 across the bottom row separates the brown from the navy and adds light below the bed, which is what makes the room feel less dark; its warm lean matches S1.",
"effort": "medium"
},
{
"id": "m2",
"action": "add",
"target": "",
"hex": "#7E8CA6",
"anchor": "mc",
"what": "dusty blue-grey bed linen or cushions",
"why": "A mid-value tint of the S2 hue fills the near-empty middle of the value range and makes a softer step between the navy wall and the warm white.",
"effort": "low"
},
{
"id": "m3",
"action": "repeat",
"target": "S4",
"hex": "",
"anchor": "tr",
"what": "mustard lampshade, print or cushion on the right",
"why": "S4 is absent from the top row and S6 only lifts tl, so one mustard touch high on the right makes the accent read as intended and balances the room.",
"effort": "low"
}
],
"questions": [
"Is the deep brown across the bottom of the photo the floor, or a dark bed frame and bedding?",
"Is the amber patch in the top left a lamp that was switched on when you took the photo?"
],
"summary": "Keep the mustard: it is the right amount and the n
... (shortened here; the page shows it whole)
rework: a rainbow kids' room
Input:
{
"task": "read",
"room": "kids",
"name": "Sam's room",
"goal": "Our son is six and loves colour, but the room feels chaotic and he can't settle at bedtime.",
"keep": "the blue floor, for now",
"facts": "(a JSON string - decoded below)",
"previous": ""
}
facts, decoded (8 swatches):
{
"image": {
"width": 960,
"height": 640,
"aspect": "3:2",
"orientation": "landscape"
},
"swatches": [
{
"id": "S1",
"hex": "#4975C8",
"share_pct": 25.2,
"L": 49.9,
"C": 49,
"h": 283,
"temp": "cool",
"lrv": 18.3,
"basic_name": "deep vivid blue",
"main_third": "bc",
"thirds_pct": [
5.6,
0,
5.6,
2.2,
0,
2.2,
27.1,
29.4,
28
]
},
{
"id": "S2",
"hex": "#77BD76",
"share_pct": 23.6,
"L": 70.6,
"C": 46.6,
"h": 141,
"temp": "cool",
"lrv": 41.6,
"basic_name": "pale vivid green",
"main_third": "tl",
"thirds_pct": [
23.7,
23.7,
23.7,
9.4,
9.4,
9.4,
0,
0.7,
0.1
]
},
{
"id": "S3",
"hex": "#ECDE71",
"share_pct": 17.3,
"L": 87.6,
"C": 55.3,
"h": 99,
"temp": "warm",
"lrv": 71.3,
"basic_name": "pale vivid yellow",
"main_third": "mr",
"thirds_pct": [
0,
0,
0,
18.8,
27.2,
29.2,
4.5,
9.4,
10.9
]
},
{
"id": "S4",
"hex": "#8D5CBF",
"share_pct": 10.5,
"L": 48.4,
"C": 59.7,
"h": 312,
"temp": "cool",
"lrv": 17.1,
"basic_name": "deep vivid violet",
"main_third": "bl",
"thirds_pct": [
0,
13.4,
13.4,
21,
12,
5.3,
23.8,
7.6,
3.5
]
},
{
"id": "S5",
"hex": "#DF4746",
"share_pct": 7.7,
"L": 52.6,
"C": 68.1,
"h": 30,
"temp": "warm",
"lrv": 20.6,
"basic_name": "mid vivid red",
"main_third": "mr",
"thirds_pct": [
18.3,
18.3,
0,
7.2,
21,
22.3,
4.8,
2.1,
6.1
]
},
{
"id": "S6",
"hex": "#EFC537",
"share_pct": 6.7,
"L": 81.1,
"C": 71.6,
"h": 89,
"temp": "warm",
"lrv": 58.6,
"basic_name": "pale vivid yellow",
"main_third": "ml",
"thirds_pct": [
21,
21,
0,
25.9,
12.3,
0.1,
0.1,
10.8,
8.9
]
},
{
"id": "S7",
"hex": "#3DB5AC",
"share_pct": 4.8,
"L": 67.3,
"C": 35.3,
"h": 188,
"temp": "cool",
"lrv": 37,
"basic_name": "mid teal",
"main_third": "tc",
"thirds_pct": [
0,
29.2,
29.2,
0,
11.5,
11.5,
9.2,
1,
8.5
]
},
{
"id": "S8",
"hex": "#E475AD",
"share_pct": 4.3,
"L": 63.5,
"C": 50.4,
"h": 349,
"temp": "warm",
"lrv": 32.2,
"basic_name": "mid vivid pink",
"main_third": "tl",
"thirds_pct": [
32.6,
0,
32.6,
16.3,
1.1,
12.9,
0.8,
0.9,
2.8
]
}
],
"families_60_30_10": {
"first_pct": 25.2,
"second_pct": 24,
"rest_pct": 50.8,
"groups": [
"S1 25.2%",
"S3+S6 24%",
"S2 23.6%",
"S4 10.5%",
"S5 7.7%",
"S7 4.8%",
"S8 4.3%"
]
},
"warm_cool_neutral_pct": {
"warm": 35.9,
"cool": 64,
"neutral": 0.1
},
"values_pct": {
"light": 39.7,
"mid": 60.3,
"dark": 0,
"L_p5": 47.5,
"L_p95": 88.6
},
"chroma_mean": 52.9,
"cast": {
"a": -1.4,
"b": 1.4,
"label": "too few neutrals to judge",
"neutral_share": 0.001
},
"hue_geometry": {
"chromatic": [
"S1",
"S2",
"S3",
"S4",
"S5",
"S6",
"S7",
"S8"
],
"span_deg": 265,
"complementary_pairs": [
"S1/S3 176deg",
"S1/S6 166deg",
"S2/S4 171deg",
"S2/S8 152deg",
"S5/S7 158deg",
"S7/S8 161deg"
]
},
"pairs": [
"S1/S2 contrast 2 dE 50.8",
"S1/S3 contrast 3.27 dE 63",
"S1/S4 contrast 1.06 dE 18.8",
"S1/S5 contrast 1.1 dE 42.3",
"S1/S6 contrast 2.73 dE 61.8",
"S1/S7 contrast 1.8 dE 34.4",
"S1/S8 contrast 1.6 dE 34.9",
"S2/S3 contrast 1.64 dE 23.2",
"S2/S4 contrast 2.11 dE 51.5",
"S2/S5 contrast 1.82 dE 63.6",
"S2/S6 contrast 1.36 dE 29.1",
"S2/S7 contrast 1.11 dE 19.3",
"S2/S8 contrast 1.25 dE 66",
"S3/S4 contrast 3.45 dE 70.4",
"S3/S5 contrast 2.97 dE 52.4",
"S3/S6 contrast 1.2 dE 9.4",
"S3/S7 contrast 1.82 dE 35.6",
"S3/S8 contrast 2.05 dE 59.1",
"S4/S5 contrast 1.16 dE 34.5",
"S4/S6 contrast 2.88 dE 68.5",
"S4/S7 contrast 1.9 dE 42.4",
"S4/S8 contrast 1.69 dE 22.3",
"S5/S6 contrast 2.48 dE 47.3",
"S5/S7 contrast 1.64 dE 59.6",
"S5/S8 contrast 1.45 dE 23.4",
"S6/S7 contrast 1.51 dE 39.5",
"S6/S8 contrast 1.71 dE 57.4",
"S7/S8 contrast 1.13 dE 51.8"
],
"thirds": [
{
"id": "tl",
"dominant": "S2",
"dominant_pct": 50,
"L": 68.7,
"L_sd": 8.8,
"C": 54.9
},
{
"id": "tc",
"dominant": "S2",
"dominant_pct": 50,
"L": 67.1,
"L_sd": 9.8,
"C": 53.5
},
{
"id": "tr",
"dominant": "S2",
"dominant_pct": 50,
"L": 64.1,
"L_sd": 7.5,
"C": 47.4
},
{
"id": "ml",
"dominant": "S3",
"dominant_pct": 29.1,
"L": 71.8,
"L_sd": 15.9,
"C": 57.6
},
{
"id": "mc",
"dominant": "S3",
"dominant_pct": 42.1,
"L": 73.2,
"L_sd": 15.4,
"C": 55.9
},
{
"id": "mr",
"dominant": "S3",
"dominant_pct": 45.2,
"L": 71.9,
"L_sd": 14.6,
"C": 53.3
},
{
"id": "bl",
"dominant": "S1",
"dominant_pct": 62.4,
"L": 52.8,
"L_sd": 10.8,
"C": 52.3
},
{
"id": "bc",
"dominant": "S1",
"dominant_pct": 67.7,
"L": 56.4,
"L_sd": 14.7,
"C": 51.3
},
{
"id": "br",
"dominant": "S1",
"dominant_pct": 64.5,
"L": 56.6,
"L_sd": 14.9,
"C": 50.1
}
],
"focal_third": "ml"
}
Reply:
{
"lane": "read",
"title": "Sam's room: eight brights, no resting place",
"headline": "Every one of the eight swatches is a vivid colour and there is no neutral and no dark value at all, so nothing in the room is quiet enough for a six-year-old to wind down against.",
"verdict": "rework",
"scheme": {
"type": "unclear",
"swatches": [
"S1",
"S2",
"S3",
"S4",
"S5",
"S6",
"S7",
"S8"
],
"why": "All eight swatches are chromatic and spread round most of the hue wheel, with six opposite pairs (S1/S3, S1/S6, S2/S4, S2/S8, S5/S7, S7/S8) all pulling at once; that is a rainbow, not a named scheme."
},
"roles": [
{
"swatch": "S1",
"role": "base",
"name": "Cornflower floor blue",
"where": "Across the whole bottom row (bl, bc, br) - the blue floor you are keeping",
"note": "A strong, saturated base that grounds the bottom of the picture, but at mid lightness it is no darker than the colours above it, so it does not anchor them."
},
{
"swatch": "S2",
"role": "dominant",
"name": "Apple green",
"where": "Evenly across the top row (tl, tc, tr) and into the middle row - most likely the upper walls",
"note": "The largest wall colour is as saturated as the accents, so the walls themselves add to the noise instead of framing it."
},
{
"swatch": "S3",
"role": "secondary",
"name": "Lemon sherbet",
"where": "A band through the middle row (ml, mc, mr) with some in the bottom row - likely a lower wall band or large furniture",
"note": "Very light and very vivid; together with S6 it makes one yellow family that sits right at eye level where the room is busiest."
},
{
"swatch": "S6",
"role": "secondary",
"name": "Sunflower yellow",
"where": "Upper left (tl, tc) and ml, with smaller patches in bc and br - likely toys, a shelf or bedding",
"note": "Barely different from S3 (dE 9.4), so the two read as one yellow; S6 is the more saturated of them and the best candidate to keep as the single accent."
},
{
"swatch": "S4",
"role": "accent",
"name": "Grape violet",
"where": "Mostly bl and ml, with some in tc and tr - perhaps a bed, beanbag or storage",
"note": "Almost the same lightness as the S1 floor (contrast 1.06) so where they meet they blur into one dark-ish mass, yet it is a different hue, which reads as clutter rather than depth."
},
{
"swatch": "S5",
"role": "accent",
"name": "Pillar-box red",
"where": "Spread across tl, tc, mc and mr - small objects scattered across the picture",
"note": "The most saturated colour in the room, scattered in several thirds, so the eye keeps jumping between red spots."
},
{
"swatch": "S7",
"role": "accent",
"name": "Pool teal",
"where": "Top centre and top right (tc, tr), with bits in mc, mr, bl and br - likely wall decals or a mobile",
"note": "Sits next to the S2 wall green at near-identical lightness (contrast 1.11), so it adds a sixth hue without adding any structure."
},
{
"swatch": "S8",
"role": "accent",
"name": "Bubblegum pink",
"where": "Upper corners (tl, tr) and down the sides in ml and mr - likely curtains or wall art",
"note": "Opposite the S2 green on the wheel and at the same height in the picture, so the top of the room vibrates."
}
],
"ratio": {
"reading": "The families are almost level: S1 at 25.2%, S3+S6 at 24% and S2 at 23.6%, with S4, S5, S7 and S8 sharing the rest. Three equal leaders and four accents means nothing dominates and nothing is clearly an accent, which is exactly what reads as chaotic.",
"fix": "Let one calm wall colour take over the top and middle rows, keep the S1 floor as the base, and let one bright (the S6 yellow) be the accent repeated in a few places; gather the other brights into toys and storage that can be closed away."
},
"light": {
"reading": "There are effectively no neutrals in the picture, so the cast cannot be judged, and the photo's white balance may be adding saturation of its own. The value structure is the clearer problem: no dark values at all, and most neighbouring pairs have so little contrast (S1/S4 1.06, S2/S7 1.11, S7/S8 1.13) that they merge into one busy field.",
"advice": "Use warm 2700K bedside or reading lamps on a dimmer and turn the ceiling light off at bedtime; warm low light mutes saturated colour more than anything else you can do cheaply. Test any new wall colour on the wall both in daylight and under the evening lamps before committing."
},
"callouts": [
{
"id": "c1",
"anchor": "S1",
"kind": "strength",
"title": "The floor gives a clear base",
"text": "S1 owns the whole bottom row, so the room already has one strong, consistent colour to build on - the blue floor can stay and lead."
},
{
"id": "c2",
"anchor": "tc",
"kind": "issue",
"title": "Busy walls at bed height",
"text": "The top row is S2 green with S7 teal and S8 pink against it, so the walls a child lies looking at are as loud as the toys."
},
{
"id": "c3",
"anchor": "ml",
"kind": "issue",
"title": "Focal third is the busiest",
"text": "ml is the busiest ninth, mixing S3, S6, S4 and S8; if the bed is on this side it sits in the most restless part of the room."
},
{
"id": "c4",
"anchor": "S4",
"kind": "issue",
"title": "No dark value anywhere",
"text": "The darkest colours, S4 and S1, are mid-tones that barely separate (contrast 1.06), so the room has no deep colour to rest the eye or hold the brights together."
},
{
"id": "c5",
"anchor": "S6",
"kind": "idea",
"title": "Pick one hero colour",
"text": "S6 sunflower is cheerful, sits opposite the S1 floor on the wheel and already appears in several thirds; letting it be the one accent keeps the room fun for a colour-loving six-year-old."
}
],
"moves": [
{
"id": "m1",
"action": "replace",
"target": "S2",
"hex": "#DDE4DA",
"anchor": "tc",
"what": "soft misty-sage paint on the upper walls",
"why": "Keeps the hue family of the S2 green Sam already knows but drops almost all its chroma, so the largest surface stops competing and the S1 floor and S6 accent can stand out against it.",
"effort": "high"
},
{
"id": "m2",
"action": "add",
"target": "",
"hex": "#2B3A63",
"anchor": "ml",
"what": "deep navy headboard, bed throw or bookcase",
"why": "The room has no dark values at all; a navy from the same hue as the S1 floor adds the missing anchor without adding a
... (shortened here; the page shows it whole)
The page measures the photo and re-checks the reply; the API does not
Over the API you send numbers and get the designer's reading back as JSON. The page does the measuring and the checking, and you can too with the same plain scripts: palette.js (Palette.analyze({width, height, data}) over RGBA pixels for the facts, Palette.buildInput(facts, {room, name, goal, keep}, previous) for this exact run body) and read.js (Read.normalize(reply), Read.reconcile(result, facts) for the disagreements - a swatch with no role, a scheme name the hue geometry does not allow, a hex or percentage the page never measured, a dominant role on a small swatch, a proposed colour too close to one already in the room - and Read.annotatedSvg, Read.paletteSvg, Read.gpl, Read.resultMarkdown for the exports). Both load in node after global.window = {}; feed analyze any decoded image buffer.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://room-palette.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://room-palette.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"]
// room-palette.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://room-palette.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://room-palette.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
}
// RoomPalette.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 RoomPalette {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://room-palette.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://room-palette.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://room-palette.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://room-palette.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":"room-palette"}' | 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": "room-palette"})["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: "room-palette" })).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": "room-palette"}, 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", "room-palette"), 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: "room-palette" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "room-palette"])["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 = "room-palette" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "read", "room": "living", "name": "...", "goal": "...", "keep": "...", "facts": "...", "previous": ""}
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": "read", "room": "living", "name": "...", "goal": "...", "keep": "...", "facts": "...", "previous": ""}
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, room, name, goal, keep, facts, previous}
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 room-palette:read:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="room-palette:read:$(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 lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "room-palette:" + 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("headline"))
import { createHash } from "node:crypto";
const key = `room-palette:${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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "room-palette:" + 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 = "room-palette:" + 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 = "room-palette:#{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["headline"]].inspect
$key = "room-palette:" . $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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"room-palette:{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: room-palette:read:$(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": "read", "room": "living", "name": "...", "goal": "...", "keep": "...", "facts": "...", "previous": ""}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "room-palette:read:" + 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": `room-palette:read:${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", "room-palette:read:"+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", "room-palette:read:" + 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" => "room-palette:read:#{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: room-palette:read:" . 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", $"room-palette:read:{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);