Drive Watts Where from your own code
The one metered step - the plan for one home's electricity - is available over HTTP. A natural use is a home-energy dashboard, a smart-plug app or a chat assistant that already holds a list of what someone plugs in: your code sends the rooms and devices, and gets back a call for every room, the moves worth making ranked by the kWh they save, a check against the bill, a verdict and this week's steps.
The split first: the page does the arithmetic; the model plans; the page checks and prices it. Reading each line into watts, hours, duty cycle and standby, the kWh and cost per device and room, and the flags come from wattkit.js, which runs in node as well as in the browser. Its plan function then checks the reply: every move must name a listed device, a saving is capped at what the device uses (all moves on one device together), an unplug at its standby kWh and a shift at its running kWh; a shift is priced only with a time-of-use rate; a room without a call is shown as keep; a lean verdict over a large lever is flagged. The model never writes money - the page prices every move at the person's rate.
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":"watts-where"}), 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, or task is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the plan is metered and needs 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. There is one task, plan. It returns title, verdict, summary; rooms [{room, action, why}], one per input room; moves [{device, kind, move, kwh_month, effort}], largest saving first; bill_check (one sentence); this_week (three to five steps); caveats.
| verdict | Means |
|---|---|
lean | Nothing large to change; the moves are small or free. |
trim | A few moves - standby, an off-peak shift, one appliance - take a real bite, but the home's shape is fine. |
overhaul | One load dominates (heating, hot water, a hot tub, old fridges) or several large levers stack up; the fix is a swap or a change of how the home is heated. |
| Room action / move kind | Means |
|---|---|
keep | Room only: nothing in it is worth changing. |
trim | Use less of the same device - hours, thermostat, full loads. |
shift | Run the same kWh outside the peak window; kwh_month is the kWh moved. Only with a time-of-use rate. |
swap | Replace the device or the bulbs. |
unplug | Move only: kill standby; at most the device's standby_kwh. A room whose main move is an unplug is called trim. |
Ids are the page's: rooms R1…R16 and devices D1…D60, in the order the lines were given. effort is free, cheap or invest.
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (WattKit.toWire does this). Only task is required by the schema.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | Always plan. |
today | string | YYYY-MM-DD. |
currency | string | The symbol the person uses, for example $. |
home | string (JSON) | {name, heat, rate, rate_estimated, listed_kwh, listed_cost, listed_standby_kwh, peak_rate?, offpeak_rate?, peak_hours?, bill_kwh?}. The three time-of-use fields appear together or not at all. |
rooms | string (JSON) | [{id, name, kwh, cost, share, standby_kwh, devices[]}], up to 16 rooms. |
devices | string (JSON) | [{id, room, text, name, cat, qty, watts, hours, standby_w, kwh, standby_kwh, cost, shiftable?, timing?, old?, year?, estimated?}], up to 60. kwh is per month and includes standby. estimated lists the values that came from the page's typical-value table. |
page_reads | string (JSON) | [{level, code, text, ids[]}], level big, drain, shift, check or info. |
page_verdict | string | The page's own reading: lean, trim or overhaul. |
notes | string | Optional, the person's words; past 600 characters the middle is cut around a marker. |
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 WattKit.parseReply, then normalizePlan, then check and price it with plan(reply, state), which returns {moves, rooms, after, totals, notes}: each move with the kWh the page accepted (saved_kwh or moved_kwh) and its money, the room calls, kWh and cost per device and room after the plan, the totals, and the notes (ok, info, warn, bad).
Worked examples
Replies produced against the app's prompt for the sample homes (made-up homes and bills; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the long fields folded.
task: plan - Tidy one-bed apartment - lean
Input:
{
"task": "plan",
"today": "2026-10-19",
"currency": "$",
"home": "{\"name\":\"One-bed apartment\",\"heat\":\"gas furnace or boiler\",\"rate\":0.21,\"rate_estimated\":false,\"listed_kwh\":112.5,\"listed_cost\":23.63,\"listed_standby_kwh\":3.7,\"bill_kwh\":125}",
"rooms": "[{\"id\":\"R1\",\"name\":\"Kitchen\",\"kwh\":65.9,\"cost\":13.84,\"share\":0.59,\"standby_kwh\":1.4,\"devices\":[\"D1\",\"D2\",\"D3\",\"D4\"]},{\"id\":\"R2\",\"name\":\"Living room\",\"kwh\":22.3,\"cost\":4.68,\"share\":0.2,\"standby_kwh\":0.3,\"devices\":[\"D5\",\"D6\",\"D7\"]},\"... 2 more\"]",
"devices": "[{\"id\":\"D1\",\"room\":\"R1\",\"text\":\"Fridge from 2019\",\"name\":\"Fridge\",\"cat\":\"cold\",\"qty\":1,\"watts\":150,\"hours\":24,\"standby_w\":0,\"kwh\":32.8,\"standby_kwh\":0,\"cost\":6.89,\"year\":2019,\"estimated\":[\"watts\",\"hours\"]},{\"id\":\"D2\",\"room\":\"R1\",\"text\":\"Induction cooktop 30 min a day\",\"name\":\"Cooktop\",\"cat\":\"cook\",\"qty\":1,\"watts\":2000,\"hours\":0.5,\"standby_w\":0,\"kwh\":18.2,\"standby_kwh\":0,\"cost\":3.82,\"estimated\":[\"watts\"]},\"... 10 more\"]",
"page_reads": "[]",
"page_verdict": "lean",
"notes": "Gas heat and gas hot water are in the building. We line-dry most laundry."
}
Reply:
{
"task": "plan",
"title": "Standby trims for an already lean apartment",
"verdict": "lean",
"summary": "This one-bed apartment is lean: the fridge (D1, 32.8 kWh/month, 29% of the list) is the biggest single load, but it's a normal 2019 unit, and gas - not electricity - covers heat and hot water here, so nothing on this list is oversized. The biggest move available is using a lid and matching pot size on the induction cooktop, worth roughly 1.5 kWh a month; unplugging the microwave and laptop charger when idle adds a bit more. Most of the wattages and hours behind these numbers are typical-value estimates rather than measurements, so treat the kWh figures as rough.",
"rooms": [
{
"room": "R1",
"action": "trim",
"why": "The cooktop (D2, 18.2 kWh/month) is the kitchen's second-biggest load after the fridge, and a few free cooking habits can trim it a little."
},
{
"room": "R2",
"action": "keep",
"why": "The TV (D5, 9.4 kWh/month), router and LED lamps are already efficient choices; nothing here is worth changing."
},
"..."
],
"moves": [
{
"device": "D2",
"kind": "trim",
"move": "Put a lid on pots and match the pan to the cooktop ring so simmering and boiling finish sooner.",
"kwh_month": 1.5,
"effort": "free"
},
{
"device": "D4",
"kind": "unplug",
"move": "Put the microwave on a switched power strip and flip it off between uses to stop its standby/clock draw.",
"kwh_month": 1.4,
"effort": "cheap"
},
{
"device": "D8",
"kind": "unplug",
"move": "Unplug the laptop charger once the laptop is fully charged instead of leaving it plugged in all day.",
"kwh_month": 1.1,
"effort": "free"
},
"..."
],
"bill_check": "Your bill shows 125 kWh against 112.5 kWh listed here - a modest 12.5 kWh gap that, since heat and hot water are gas in this building, most likely comes from appliances running a bit longer than written rather than an unlisted heater.",
"this_week": [
"Put a lid on pots and match pan size to the cooktop ring when cooking.",
"Plug the microwave into a switched power strip and flip it off when not cooking.",
"Unplug the laptop charger once the laptop is fully charged.",
"..."
],
"caveats": [
"Most wattages and hours here are typical-value estimates, not measurements - a plug-in meter would confirm them.",
"Heat and hot water run on gas in this building, so they're not part of this electricity list or its moves."
]
}
task: plan - Townhouse on a time-of-use rate - trim, with shifts
Input:
{
"task": "plan",
"today": "2026-10-19",
"currency": "$",
"home": "{\"name\":\"Townhouse with an EV\",\"heat\":\"heat pump\",\"rate\":0.22,\"rate_estimated\":false,\"listed_kwh\":534.1,\"listed_cost\":172.37,\"listed_standby_kwh\":17.6,\"peak_rate\":0.38,\"offpeak_rate\":0.12,\"peak_hours\":\"16:00-21:00\",\"bill_kwh\":980}",
"rooms": "[{\"id\":\"R1\",\"name\":\"Kitchen\",\"kwh\":124.5,\"cost\":35.03,\"share\":0.23,\"standby_kwh\":3.5,\"devices\":[\"D1\",\"D2\",\"D3\",\"D4\"]},{\"id\":\"R2\",\"name\":\"Laundry\",\"kwh\":67.6,\"cost\":25.68,\"share\":0.13,\"standby_kwh\":0.7,\"devices\":[\"D5\",\"D6\"]},\"... 3 more\"]",
"devices": "[{\"id\":\"D1\",\"room\":\"R1\",\"text\":\"Fridge 2018\",\"name\":\"Fridge\",\"cat\":\"cold\",\"qty\":1,\"watts\":150,\"hours\":24,\"standby_w\":0,\"kwh\":32.8,\"standby_kwh\":0,\"cost\":7.22,\"rate\":0.22,\"year\":2018,\"estimated\":[\"watts\",\"hours\"]},{\"id\":\"D2\",\"room\":\"R1\",\"text\":\"Dishwasher 6 loads a week after dinner\",\"name\":\"Dishwasher\",\"cat\":\"cook\",\"qty\":1,\"watts\":1500,\"hours\":1.29,\"standby_w\":1,\"kwh\":47.7,\"standby_kwh\":0.7,\"cost\":18.13,\"rate\":0.38,\"shiftable\":true,\"timing\":\"peak\",\"estimated\":[\"watts\",\"standby\"]},\"... 11 more\"]",
"page_reads": "[{\"level\":\"drain\",\"code\":\"standby\",\"text\":\"Standby draw is 18 kWh a month ($4.10) from things that are off but plugged in: Game console (D8), Desktop computer (D11), Soundbar (D9).\",\"ids\":[\"D8\",\"D11\",\"D9\"]},{\"level\":\"big\",\"code\":\"dryer\",\"text\":\"Clothes dryer (D5) uses about 58 kWh a month - 11% of the list.\",\"ids\":[\"D5\"]},\"... 2 more\"]",
"page_verdict": "trim",
"notes": "Heat pump for heating is on its own meter line we cannot split out. Everyone gets home around 5pm and that is when the chores happen."
}
Reply:
{
"task": "plan",
"title": "Move Evening Loads Off Peak",
"verdict": "trim",
"summary": "This home comes out as a trim, not an overhaul: the page's one flagged big lever is the clothes dryer, about 58 kWh a month and 11% of the list, but most of the opportunity here is about timing rather than cutting usage. The single biggest move is shifting the EV charger out of the 4-9pm peak window, which moves about 228 kWh a month to cheaper hours - more than the dishwasher, dryer and washing machine combined. Most of the wattages and hours behind these numbers are estimates from typical-use tables rather than measurements, so treat the kWh figures as rough. None of this includes the home's heat pump heating, which sits on its own meter line and is the likely source of most of the gap between this list and the actual bill.",
"rooms": [
{
"room": "R1",
"action": "shift",
"why": "The dishwasher (D2) uses 47.7 kWh a month and runs right after dinner, inside the peak window, so moving its cycle later is the kitchen's biggest win."
},
{
"room": "R2",
"action": "shift",
"why": "The clothes dryer (D5) uses about 58.3 kWh a month, the single biggest lever on this list, and it runs at peak every evening."
},
"..."
],
"moves": [
{
"device": "D13",
"kind": "shift",
"move": "Schedule the EV charger to start after 9 pm (or before 7 am) instead of charging right after work.",
"kwh_month": 227.6,
"effort": "free"
},
{
"device": "D5",
"kind": "shift",
"move": "Set the dryer to start after 9 pm instead of running it right in the evening peak window.",
"kwh_month": 58.3,
"effort": "free"
},
{
"device": "D2",
"kind": "shift",
"move": "Delay the dishwasher to run after 9 pm instead of starting it right after dinner.",
"kwh_month": 47.1,
"effort": "free"
},
"..."
],
"bill_check": "Your bill shows 980 kWh but these rooms only add up to 534 kWh - the missing roughly 446 kWh a month is almost certainly the heat pump's heating, which runs on its own meter line not captured in this device list.",
"this_week": [
"Schedule the EV charger for after 9 pm (or before 7 am) instead of charging right when everyone gets home.",
"Set the dryer to start after 9 pm.",
"Set the dishwasher to start after 9 pm instead of right after dinner.",
"..."
],
"caveats": [
"Most wattages, hours and standby draws here are typical estimates, not measurements, so treat the kWh figures as rough.",
"The home's heat pump heating runs on its own meter and isn't in this device list, so its kWh and cost aren't estimated here."
]
}
task: plan - Drafty old house on plug-in heat - overhaul, with swaps
Input:
{
"task": "plan",
"today": "2026-10-19",
"currency": "$",
"home": "{\"name\":\"1950s house on electric heat\",\"heat\":\"electric resistance (baseboard or furnace)\",\"rate\":0.17,\"rate_estimated\":false,\"listed_kwh\":1548.7,\"listed_cost\":263.29,\"listed_standby_kwh\":2,\"bill_kwh\":2300}",
"rooms": "[{\"id\":\"R1\",\"name\":\"Living room\",\"kwh\":505.3,\"cost\":85.91,\"share\":0.33,\"standby_kwh\":0.6,\"devices\":[\"D1\",\"D2\",\"D3\"]},{\"id\":\"R2\",\"name\":\"Bedrooms\",\"kwh\":320.1,\"cost\":54.42,\"share\":0.21,\"standby_kwh\":0,\"devices\":[\"D4\",\"D5\",\"D6\"]},\"... 3 more\"]",
"devices": "[{\"id\":\"D1\",\"room\":\"R1\",\"text\":\"2 space heaters 1500W 6h\",\"name\":\"Space heater\",\"cat\":\"heat\",\"qty\":2,\"watts\":1500,\"hours\":6,\"standby_w\":0,\"kwh\":437.8,\"standby_kwh\":0,\"cost\":74.43},{\"id\":\"D2\",\"room\":\"R1\",\"text\":\"6 incandescent 60W bulbs 5h\",\"name\":\"Incandescent bulb\",\"cat\":\"light\",\"qty\":6,\"watts\":60,\"hours\":5,\"standby_w\":0,\"kwh\":54.7,\"standby_kwh\":0,\"cost\":9.3},\"... 11 more\"]",
"page_reads": "[{\"level\":\"big\",\"code\":\"heat-share\",\"text\":\"Heating is 48% of what you listed - 744 kWh, about $127 a month: Space heater (D1), Space heater (D4), Electric blanket (D5).\",\"ids\":[\"D1\",\"D4\",\"D5\"]},{\"level\":\"big\",\"code\":\"old-cold\",\"text\":\"Old or extra cold boxes run all day: Old fridge (D7), Old fridge (D10) - 146 kWh a month between them.\",\"ids\":[\"D7\",\"D10\"]},\"... 3 more\"]",
"page_verdict": "overhaul",
"notes": "Old windows, the living room is always cold. Baseboards are mostly off because they are slow; we use plug-in heaters instead."
}
Reply:
{
"task": "plan",
"title": "Fix The Heaters, Hot Water And Old Fridges",
"verdict": "overhaul",
"summary": "This home needs an overhaul: heating is the load that decides it - the living room's two 1500W space heaters (D1) alone burn about 437.8 kWh a month, 28% of the whole list, and with the bedroom's overnight heater and the water heater stacked on top, three loads together account for most of the bill. The single biggest move is swapping the bedroom's overnight oil heater for the electric blanket already in use, worth an estimated 250 kWh a month at no cost. Most of the numbers here, including these savings, are rough estimates built from each device's own hours and watts, not meter readings.",
"rooms": [
{
"room": "R1",
"action": "swap",
"why": "Two 1500W space heaters (D1) run 6 hours a day here, using 437.8 kWh a month because the room stays cold behind old windows and the baseboards are rarely used."
},
{
"room": "R2",
"action": "swap",
"why": "The oil-filled heater running 8 hours overnight (D4) uses 291.8 kWh a month, far more than the electric blanket (D5) already doing the same job for 14.6 kWh."
},
"..."
],
"moves": [
{
"device": "D4",
"kind": "swap",
"move": "Stop running the overnight oil-filled heater and rely on the electric blanket you already use instead.",
"kwh_month": 250,
"effort": "free"
},
{
"device": "D12",
"kind": "swap",
"move": "Replace the electric water heater with a heat pump water heater, which uses roughly a third as much for the same hot water.",
"kwh_month": 228,
"effort": "invest"
},
{
"device": "D1",
"kind": "swap",
"move": "Get the existing baseboard heating working well enough to retire the two plug-in space heaters, or replace them with a cold-climate heat pump.",
"kwh_month": 220,
"effort": "invest"
},
"..."
],
"bill_check": "Your bill shows 2300 kWh but the rooms you listed only add to about 1548.7 kWh - the missing 751 kWh is most likely the baseboard heating system itself, which isn't listed as its own device and is probably running more than \"mostly off\" on cold days.",
"this_week": [
"Move the garage fridge's contents into the kitchen fridge and unplug the second fridge.",
"Stop turning on the bedroom's oil-filled heater overnight and use the electric blanket instead.",
"Switch the living room's 6 incandescent bulbs to LEDs.",
"..."
],
"caveats": [
"Watts and hours for several devices (TV, oven, kettle, both old fridges, the freezer, the water heater) are estimated, not measured.",
"The savings in `moves` are rough calculations from each device's own numbers, not a plug-in meter reading.",
"..."
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./wattkit.js")), then:
const K = WattKit;
const st = K.state({
name: "Our place", currency: "$", rate: "0.21", heat: "gas",
peak_rate: "", offpeak_rate: "", peak_hours: "", // all three, or none
bill_kwh: "450", notes: "",
rooms: "Living room:\nSpace heater 1500W 5h\nTV 4h, standby 1W\n\nKitchen:\nFridge from 2003\nDishwasher 5 loads a week"
});
const { input } = K.buildInput(st, { today: "2026-10-19" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalizePlan(K.parseReply(job.output.output));
// const { moves, rooms, after, totals, notes } = K.plan(reply, st);
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://watts-where.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://watts-where.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"]
// watts-where.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://watts-where.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://watts-where.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
}
// WattsWhere.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 WattsWhere {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://watts-where.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://watts-where.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://watts-where.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://watts-where.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":"watts-where"}' | 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": "watts-where"})["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: "watts-where" })).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": "watts-where"}, 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", "watts-where"), 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: "watts-where" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "watts-where"])["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 = "watts-where" }))!["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": "plan", "today": "2026-10-19", "currency": "$", "home": "{...}", "rooms": "[...]", "devices": "[...]", "page_reads": "[...]", "page_verdict": "trim"}
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": "plan", "today": "2026-10-19", "currency": "$", "home": "{...}", "rooms": "[...]", "devices": "[...]", "page_reads": "[...]", "page_verdict": "trim"}
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: "plan", today, currency, home, rooms, devices, page_reads, page_verdict, notes?}
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 watts-where:plan:<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="watts-where:plan:$(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 = "watts-where:" + 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 = `watts-where:${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 := "watts-where:" + 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 = "watts-where:" + 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 = "watts-where:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "watts-where:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"watts-where:{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: watts-where:plan:$(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": "plan", "today": "2026-10-19", "currency": "$", "home": "{...}", "rooms": "[...]", "devices": "[...]", "page_reads": "[...]", "page_verdict": "trim"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "watts-where:plan:" + 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": `watts-where:plan:${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", "watts-where:plan:"+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", "watts-where:plan:" + 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" => "watts-where:plan:#{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: watts-where:plan:" . 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", $"watts-where:plan:{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);