← Lamp Layers
API

Drive Lamp Layers from your own code

The one metered task, light, is available over HTTP. It takes one room - its size, its lamps and the spots where people read, relax, work or eat - plus the page's light estimates, and returns a lighting plan: the verdict, a headline, the layers in plain words, one dimmer scene per spot, the lamps worth adding and a check to make first.

The division of labour first: the page estimates; the model plans. The page works out each lamp's share of light at each spot (whole lux that add up), the totals with every lamp at full against each spot's target, the ambient, task and accent layers and the verdict, and sends them as facts. The model never changes those numbers. After the reply, the page re-adds every scene's light from its own shares, places every suggested lamp on the plan and lists anything that does not fit. Estimates from a plan, not professional advice.

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": "..." } }

The token is minted for this app (the guest call takes {"slug":"lamp-layers"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, room, lamps, spots, facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the lighting plan is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field, the input and the rules

Every run sends task: light (the one task is light); a missing or different one is still answered as light. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways light.
roomyeskind - W m wide x D m deep, ceiling H m | walls: light, mid-tone or dark | name: ... | note: .... Name and note are none when empty.
lampsyesOne lamp a line: L1. name | type | layer | lumens | colour temperature | dimmable or not | where it is. One to ten lamps.
spotsyesOne spot a line: S1. name | reading, relaxing, desk, dining, counter or bedside | morning, midday, evening or night | target range | where it is. One to four spots.
factsyesThe page's numbers: each lamp's share of light at each spot at full, the totals and their status against the target, the layers and the missing ones, which lamps dim, colour temperatures, the largest lamp, the short spots shortest first and the verdict. Build it with LampKit.buildInput.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

The verdict, checked in order: short-of-light when a spot stays under the low end of its target with every lamp at full; otherwise missing-a-layer when there is no ambient lamp, no accent lamp, or no task lamp while the room has a reading, desk or counter spot; otherwise ready-for-scenes.

The reply

One JSON object, no prose around it:

{"task": "light", "verdict": "missing-a-layer", "headline": "...", "layers_note": "...",
 "scenes": [{"spot": "S1", "name": "...", "time": "night", "levels": {"L1": 10, "L2": 60}, "lux": 52, "why": "..."}],
 "additions": [{"type": "strip", "layer": "accent", "lumens": 400, "cct": 2700, "near": "S1", "why": "..."}],
 "check_first": "..."}
FieldWhat it holds
verdictshort-of-light, missing-a-layer or ready-for-scenes - always the page's.
headlineOne sentence on what the verdict means for this room.
layers_noteThe lamps in each layer by name, and the missing layers.
scenesOne per spot, in spot order: the spot id, a name, the time of day, a level (0-100 in tens) for every lamp, the light at the spot and why.
additionsLamps worth adding: a catalogue type, its layer, lumens, colour temperature and the spot it goes near; one or two when short of light, one per missing layer, none when ready.
check_firstOne practical check before changing anything: an electrician for a new fixed light, dimmable bulbs for dimmed lamps, or bulb wattage.

The page reads the reply with ReplyKit.normalize and checks it with ReplyKit.check, which lists, under "The page's check", a verdict that differs from the page's, a scene whose light does not add up or misses its target, a level a lamp cannot take, an added lamp outside the catalogue or that would still leave its spot short, and numbers or ids in the prose.

Worked examples - every verdict

These are the free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover every verdict - short of light, a layer missing and ready for scenes - a lamp that cannot dim, mixed colour temperatures, and spots used in the evening, at night and at midday.

A front room with one ceiling light - short-of-light

Input:

{
 "task": "light",
 "room": "Living room - 4.2 m wide x 3.6 m deep, ceiling 2.4 m | walls: mid-tone | name: The front room | note: We read and watch TV here most evenings.",
 "lamps": "L1. Ceiling light | Ceiling light (flush) | ambient | 1500 lm | 4000 K | not dimmable | 2.1 m from the left wall, 1.8 m from the back wall\nL2. Lamp on the side table | Table lamp | ambient | 450 lm | 2700 K | dimmable | 3.7 m from the left wall, 3.0 m from the back wall",
 "spots": "S1. Armchair by the window | reading | evening | target 300-500 lux | 0.7 m from the left wall, 0.7 m from the back wall\nS2. Sofa facing the TV | relaxing | evening | target 150-300 lux | 3.0 m from the left wall, 3.0 m from the back wall",
 "facts": "Light at each spot with every lamp at 100% (estimated lux on the spot's surface; each lamp's share already includes its light bounced off the walls, so the shares add up; a lamp at a lower level gives its share x level / 100):\nS1 Armchair by the window (reading, evening, target 300-500): L1 49 + L2 5 = 54 at full - short by 246\nS2 Sofa facing the TV (relaxing, evening, target 150-300): L1 62 + L2 24 = 86 at full - short by 64\nLayers: ambient: L1, L2 | task: none | accent: none (the room has a task spot)\nMissing layers (ambient always counts; task only when there is a reading, desk or counter spot; accent always counts): task, accent\nDimmable: L1 no, L2 yes\nColour temperature: L1 4000 K, L2 2700 K (spread 1300 K)\nLargest single lamp: L1, 77% of all lumens\nShort spots, shortest first (light at full as a share of the target's low end): S1 18%, S2 57%\nVerdict: short-of-light (rule, checked in order: short-of-light when any spot is under its target's low end with every lamp at full; otherwise missing-a-layer when a layer above is missing; otherwise ready-for-scenes)"
}

Reply:

{
 "task": "light",
 "verdict": "short-of-light",
 "headline": "The armchair by the window reaches about 54 lux at full power, short of its target's low end of 300 lux, so the room needs another lamp.",
 "layers_note": "The ambient layer has the ceiling light and the lamp on the side table, but the room is missing a task layer and an accent layer.",
 "scenes": [
  {
   "spot": "S1",
   "name": "Armchair by the window",
   "time": "evening",
   "levels": {
    "L1": 100,
    "L2": 100
   },
   "lux": 54,
   "why": "The ceiling light does most of the work here, but even with every lamp at full the armchair by the window stays below its target until the added lamp is in."
  },
  {
   "spot": "S2",
   "name": "Sofa facing the TV",
   "time": "evening",
   "levels": {
    "L1": 100,
    "L2": 100
   },
   "lux": 86,
   "why": "The ceiling light does most of the work here too, but even with every lamp at full the sofa facing the TV stays below its target until the added lamp is in."
  }
 ],
 "additions": [
  {
   "type": "reader",
   "layer": "task",
   "lumens": 500,
   "cct": 2700,
   "near": "S1",
   "why": "A reading lamp placed by the armchair by the window adds task light so the spot can reach its target."
  },
  {
   "type": "floor",
   "layer": "ambient",
   "lumens": 950,
   "cct": 2700,
   "near": "S2",
   "why": "A floor lamp placed by the sofa facing the TV adds ambient light so the spot can reach its target."
  }
 ],
 "check_first": "Keep each bulb within its fitting's maximum wattage."
}

A bedroom with bedside lamps - missing-a-layer

Input:

{
 "task": "light",
 "room": "Bedroom - 3.6 m wide x 3.2 m deep, ceiling 2.4 m | walls: light | name: Main bedroom | note: none",
 "lamps": "L1. Pendant | Pendant | ambient | 1000 lm | 2700 K | dimmable | 1.8 m from the left wall, 1.6 m from the back wall\nL2. Bedside lamp, left | Table lamp | ambient | 400 lm | 2700 K | dimmable | 0.8 m from the left wall, 0.4 m from the back wall\nL3. Bedside lamp, right | Table lamp | ambient | 400 lm | 2700 K | dimmable | 2.8 m from the left wall, 0.4 m from the back wall\nL4. Reading lamp by the chair | Reading lamp (angled) | task | 600 lm | 2700 K | dimmable | 3.1 m from the left wall, 2.5 m from the back wall",
 "spots": "S1. Left side of the bed | bedside | night | target 50-150 lux | 1.2 m from the left wall, 0.6 m from the back wall\nS2. Right side of the bed | bedside | night | target 50-150 lux | 2.4 m from the left wall, 0.6 m from the back wall\nS3. Chair in the corner | reading | evening | target 300-500 lux | 3.2 m from the left wall, 2.9 m from the back wall",
 "facts": "Light at each spot with every lamp at 100% (estimated lux on the spot's surface; each lamp's share already includes its light bounced off the walls, so the shares add up; a lamp at a lower level gives its share x level / 100):\nS1 Left side of the bed (bedside, night, target 50-150): L1 55 + L2 77 + L3 9 + L4 13 = 154 at full - over by 4 (dimming brings it into range)\nS2 Right side of the bed (bedside, night, target 50-150): L1 55 + L2 9 + L3 77 + L4 13 = 154 at full - over by 4 (dimming brings it into range)\nS3 Chair in the corner (reading, evening, target 300-500): L1 29 + L2 9 + L3 9 + L4 351 = 398 at full - inside the target\nLayers: ambient: L1, L2, L3 | task: L4 | accent: none (the room has a task spot)\nMissing layers (ambient always counts; task only when there is a reading, desk or counter spot; accent always counts): accent\nDimmable: L1 yes, L2 yes, L3 yes, L4 yes\nColour temperature: L1 2700 K, L2 2700 K, L3 2700 K, L4 2700 K (spread 0 K)\nLargest single lamp: L1, 42% of all lumens\nShort spots, shortest first (light at full as a share of the target's low end): none\nVerdict: missing-a-layer (rule, checked in order: short-of-light when any spot is under its target's low end with every lamp at full; otherwise missing-a-layer when a layer above is missing; otherwise ready-for-scenes)"
}

Reply:

{
 "task": "light",
 "verdict": "missing-a-layer",
 "headline": "Every spot in this bedroom can reach its target, but the room is missing an accent layer.",
 "layers_note": "Ambient light comes from the Pendant, the Bedside lamp, left and the Bedside lamp, right, task light comes from the Reading lamp by the chair, and the room has no accent layer yet.",
 "scenes": [
  {
   "spot": "S1",
   "name": "Left side of the bed",
   "time": "night",
   "levels": {
    "L1": 0,
    "L2": 100,
    "L3": 0,
    "L4": 0
   },
   "lux": 77,
   "why": "The Bedside lamp, left gives the light for the left side of the bed on its own."
  },
  {
   "spot": "S2",
   "name": "Right side of the bed",
   "time": "night",
   "levels": {
    "L1": 0,
    "L2": 0,
    "L3": 100,
    "L4": 0
   },
   "lux": 77,
   "why": "The Bedside lamp, right gives the light for the right side of the bed on its own."
  },
  {
   "spot": "S3",
   "name": "Chair in the corner",
   "time": "evening",
   "levels": {
    "L1": 10,
    "L2": 0,
    "L3": 0,
    "L4": 100
   },
   "lux": 354,
   "why": "The Reading lamp by the chair does most of the work for the chair in the corner, with the Pendant adding a little background ambient light."
  }
 ],
 "additions": [
  {
   "type": "strip",
   "layer": "accent",
   "lumens": 400,
   "cct": 2700,
   "near": "S1",
   "why": "A hidden LED strip would add the accent layer this room is missing, near the left side of the bed."
  }
 ],
 "check_first": "Because one of these scenes dims a lamp partway instead of fully on or off, check that lamp has a dimmable bulb fitted to a compatible dimmer before setting up the scenes."
}

A kitchen-diner - ready-for-scenes

Input:

{
 "task": "light",
 "room": "Kitchen-diner - 5.0 m wide x 3.5 m deep, ceiling 2.5 m | walls: light | name: Kitchen-diner | note: Cooking in the evening; the table doubles as a desk at midday.",
 "lamps": "L1. Downlight over the hob | Downlight | ambient | 500 lm | 3000 K | dimmable | 1.0 m from the left wall, 0.8 m from the back wall\nL2. Downlight over the sink | Downlight | ambient | 500 lm | 3000 K | dimmable | 2.4 m from the left wall, 0.8 m from the back wall\nL3. Strip under the cupboards | Under-cabinet strip | task | 400 lm | 4000 K | not dimmable | 1.6 m from the left wall, 0.3 m from the back wall\nL4. Pendant over the table | Pendant | ambient | 1000 lm | 2700 K | dimmable | 3.8 m from the left wall, 2.3 m from the back wall\nL5. Strip on the open shelf | Hidden LED strip | accent | 300 lm | 2700 K | dimmable | 4.9 m from the left wall, 1.0 m from the back wall\nL6. Desk lamp on the table | Desk lamp | task | 500 lm | 4000 K | dimmable | 3.2 m from the left wall, 2.8 m from the back wall",
 "spots": "S1. Worktop by the hob | counter | evening | target 300-750 lux | 1.4 m from the left wall, 0.5 m from the back wall\nS2. Dining table | dining | evening | target 150-300 lux | 3.8 m from the left wall, 2.3 m from the back wall\nS3. Table as a desk | desk | midday | target 300-500 lux | 3.4 m from the left wall, 2.6 m from the back wall",
 "facts": "Light at each spot with every lamp at 100% (estimated lux on the spot's surface; each lamp's share already includes its light bounced off the walls, so the shares add up; a lamp at a lower level gives its share x level / 100):\nS1 Worktop by the hob (counter, evening, target 300-750): L1 106 + L2 50 + L3 357 + L4 16 + L5 6 + L6 7 = 542 at full - inside the target\nS2 Dining table (dining, evening, target 150-300): L1 8 + L2 15 + L3 6 + L4 226 + L5 6 + L6 12 = 273 at full - inside the target\nS3 Table as a desk (desk, midday, target 300-500): L1 9 + L2 14 + L3 6 + L4 144 + L5 6 + L6 567 = 746 at full - over by 246 (dimming brings it into range)\nLayers: ambient: L1, L2, L4 | task: L3, L6 | accent: L5 (the room has a task spot)\nMissing layers (ambient always counts; task only when there is a reading, desk or counter spot; accent always counts): none\nDimmable: L1 yes, L2 yes, L3 no, L4 yes, L5 yes, L6 yes\nColour temperature: L1 3000 K, L2 3000 K, L3 4000 K, L4 2700 K, L5 2700 K, L6 4000 K (spread 1300 K)\nLargest single lamp: L4, 31% of all lumens\nShort spots, shortest first (light at full as a share of the target's low end): none\nVerdict: ready-for-scenes (rule, checked in order: short-of-light when any spot is under its target's low end with every lamp at full; otherwise missing-a-layer when a layer above is missing; otherwise ready-for-scenes)"
}

Reply:

{
 "task": "light",
 "verdict": "ready-for-scenes",
 "headline": "The lamps already cover every spot, so what's left is choosing the right dimmer scene for each one.",
 "layers_note": "Ambient light comes from the downlight over the hob, the downlight over the sink and the pendant over the table, task light comes from the strip under the cupboards and the desk lamp on the table, and accent light comes from the strip on the open shelf.",
 "scenes": [
  {
   "spot": "S1",
   "name": "Worktop by the hob",
   "time": "evening",
   "levels": {
    "L1": 100,
    "L2": 0,
    "L3": 100,
    "L4": 0,
    "L5": 10,
    "L6": 0
   },
   "lux": 464,
   "why": "The strip under the cupboards does most of the work and stays on despite running cooler than the evening tone, because the worktop can't reach its target without it, while the downlight over the hob adds the rest of the light."
  },
  {
   "spot": "S2",
   "name": "Dining table, evening",
   "time": "evening",
   "levels": {
    "L1": 0,
    "L2": 0,
    "L3": 0,
    "L4": 80,
    "L5": 10,
    "L6": 0
   },
   "lux": 181,
   "why": "The pendant over the table provides most of the light, dimmed for a softer evening glow, while the strip on the open shelf adds a touch of accent light."
  },
  {
   "spot": "S3",
   "name": "Table as a desk",
   "time": "midday",
   "levels": {
    "L1": 0,
    "L2": 0,
    "L3": 0,
    "L4": 10,
    "L5": 0,
    "L6": 70
   },
   "lux": 411,
   "why": "The desk lamp on the table does most of the work, dimmed down from full so the desk doesn't run over its target, with the pendant over the table adding a little ambient fill."
  }
 ],
 "additions": [],
 "check_first": "Since several of these scenes dim lamps partway rather than leaving them fully on or off, check that each dimmed lamp has a dimmable bulb fitted on a compatible dimmer before using them."
}

Build the input yourself

Load the engine in node (global.window = global; require("./lampkit.js"); require("./replykit.js")), then:

const K = LampKit, R = ReplyKit;
const built = K.buildInput(K.form({ name: "The front room", kind: "living", w: 4.2, d: 3.6, h: 2.4, walls: "mid", note: "",
  lamps: [{ label: "Ceiling light", type: "ceiling", layer: "ambient", lm: 1500, k: 4000, dim: false, x: 2.1, y: 1.8 }],
  spots: [{ label: "Armchair by the window", kind: "reading", when: "evening", x: 0.7, y: 0.7 }] }));  // built.ok is false, with built.note, until there is a lamp and a spot
const wire = K.toWire(built.input);   // POST this to /estimate and /run; built.analysis holds rows (shares, totals), layers, missing, verdict
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.check(reply, built).notes        // the page's re-check of the reply
// R.md(built, reply, K.title(built.form))   // the lamps, the spots and the plan as Markdown

Lamp keys: label, type (ceiling, pendant, downlight, floor, table, reader, desk, cabinet, uplight, picture, strip), layer, lm, k, dim, and x/y in metres from the left and back walls. Spot keys: label, kind, when, x, y. Room keys: name, kind, w, d, h, walls, note.

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://lamp-layers.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"}
}

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":"lamp-layers"}' | jq -r '.data.token')

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}' 

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": "light", "room": "Living room - 4.2 m wide x 3.6 m deep, ceiling 2.4 m | walls: mid-tone | ...", "lamps": "L1. Ceiling light | Ceiling light (flush) | ambient | 1500 lm | 4000 K | not dimmable | ...", "spots": "S1. Armchair by the window | reading | evening | target 300-500 lux | ...", "facts": "...", ...}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

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 lamp-layers:<task>:<inputHash>:a<attempt> (the one task is light), 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="lamp-layers:light:$(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}' 

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: lamp-layers:light:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json