Drive Six-Legged Lodgers from your own code
The one metered task, trace, is available over HTTP. It takes a sightings log (one line per sighting), what the person thinks the insect is, where things stand, any warning signs and a description of the house, and returns a verdict (get it identified first, seal up now, or wait them out), a six-bone fishbone with weighted causes and quoted evidence, one to three root causes, three to six fixes ordered today, this week and before next autumn, mistakes to avoid and what to watch next.
The division of labour first: the page owns the sightings tally, the clue words and the verdict rule; the model owns the judgement and the words; the page re-checks the reply. All three come from lodgekit.js, which you can run yourself (see Build the input yourself). The plan is sealing and removal by hand only - never chemicals.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"six-legged-lodgers"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, bug_kind, stage, sightings, clues) 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: tracing 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 and the verdict
Every run sends task: trace; a missing or different one is still answered as trace. The verdict is checked in a fixed order and the first that applies wins:
- check-id -
warning_signsisyes(bites, in food or bedding, damage to wood or fabric, tiny young ones) orbug_kindisnot-sure. The first fix is getting one identified today. - seal-now -
stageisarriving. At least two fixes seal outside openings this week. - wait-them-out - everything else. Removal today, inside gaps this week, outside sealing before next autumn.
The page computes the same rule (LodgeKit.expected) and flags a reply that disagrees.
The run input
Every value is a string (objects are never sent). app.js builds it with LodgeKit.buildInput(form).input.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always trace. |
bug_kind | yes | Starts with stink-bug, lady-beetle, boxelder-bug, cluster-fly or not-sure, then a short description in brackets. |
stage | yes | Starts with arriving, inside or spring, then a plain-words gloss. |
warning_signs | no | yes or no. |
sightings | yes | Numbered lines, one per sighting, in the person's words. At most 30 lines are sent (the first 10 and the newest 20, with a [...] line between); the tally still counts every line. |
room_tally | no | The page's count per place, Place: count joined by | , most first. The place is the first room word in a line; the count is the number next to an insect word, "about 30", "x4", or a word such as "a few" (3); otherwise 1. |
house | no | The house and its surroundings (at most 1,200 characters; a longer text keeps its start and end), or not given. |
clues | yes | One line per bone whose word list matched the log or the house text: <bone id> | <words>. Bone ids: gaps, pipes, roof, warmth, outside, habits. Or none found. |
clip_note | no | What the page shortened, or absent. |
retry_note | no | Sent only on the page's one reformat retry. |
The reply
The reply is one JSON object in job.output.output. The page parses it with LodgeKit.parseReply and LodgeKit.normalize (a cut-off stream is closed by LodgeKit.closeJson and shown as "N of 10 sections recovered").
{"task": "trace", "verdict": "check-id | seal-now | wait-them-out", "verdict_reason": "...", "title": "...", "summary": "...",
"suspect": {"name": "...", "match": "fits | unsure", "why": "..."},
"bones": [{"id": "gaps", "causes": [{"cause": "...", "evidence": "\"words from the input\"", "weight": "strong | possible | check"}]},
... six bones: gaps, pipes, roof, warmth, outside, habits],
"root_causes": [{"bone": "gaps", "cause": "...", "why": "..."}],
"fixes": [{"when": "today | this-week | before-next-autumn", "do": "...", "bone": "gaps | ... | none"}],
"dont": ["Don't ..."], "watch": "..."}
| Field | Contract |
|---|---|
bones | Exactly six, in the fixed order, each with 1 to 3 causes. weight in a fixed order: strong if the input names the opening or condition itself, else possible if it only hints at it, else check (one ordinary spot worth a look, evidence exactly Not mentioned - worth a look.). A bone with a strong or possible cause has no check cause; every clues line needs a strong or possible cause on its bone. |
root_causes | 1 to 3, most likely first, each copying a strong or possible cause; empty only when no bone has one. |
fixes | 3 to 6 steps a person can do themselves. Never chemicals, sprays, foggers or traps - sealing with caulk, foam, weatherstripping, mesh or screen repair, and removal with a vacuum, a jar or soapy water. |
suspect | Not identified yet and unsure when the insect is not known; unsure also when warning signs are ticked. |
The page then checks the reply against its own facts (LodgeKit.check): the verdict against its rule, all six bones present, a cause from your text on every bone with a clue, quoted evidence found word for word in the input, root causes that match a cause on the bones, the number and timing of fixes for the verdict, an identification step first for check-id, any chemical or trap mentioned, the busiest place named in the summary, and internal field ids in the prose.
Worked examples, one per verdict
These are the three free samples on the page: the exact input and a stand-in model's reply, unedited.
Stink bugs on the sunny back wall - seal-now
Input:
{
"task": "trace",
"bug_kind": "stink-bug (shield-shaped, brown, smells when handled)",
"stage": "arriving (they are gathering on outside walls or only just starting to come in)",
"warning_signs": "no",
"sightings": "1. Outside on the back wall, about 30 on the siding in the afternoon\n2. Back bedroom, 4 stink bugs around the window air conditioner\n3. Kitchen, 2 on the door screen\n4. Back bedroom, 3 on the curtain next to the air conditioner",
"room_tally": "Outside walls: 30 | Bedroom: 7 | Kitchen: 2",
"house": "Two-storey house with old wooden windows. The window air conditioner in the back bedroom stays in all year. The back wall gets the afternoon sun and there is a row of trees behind the fence. The screen on the kitchen door is torn at the bottom.",
"clues": "gaps | old wooden windows, screen, torn, siding\npipes | air conditioner\nwarmth | sun\noutside | trees"
}
Reply:
{
"task": "trace",
"verdict": "seal-now",
"verdict_reason": "Your answers that they are still gathering on the back wall and have only just started coming in are what call for closing up their ways in now.",
"title": "Back wall stink bugs, time to seal",
"summary": "Most of the insects so far are on the back wall, with far fewer in the bedroom and kitchen. Since they are only just gathering there and starting to come in, this is the time to seal up their ways in before more arrive.",
"suspect": {
"name": "Stink bug",
"match": "fits",
"why": "The sightings describe ordinary wall, screen and window gathering spots consistent with the brown, shield-shaped insect described, with nothing suggesting a different kind of insect."
},
"bones": [
{
"id": "gaps",
"causes": [
{
"cause": "Torn screen on the kitchen door",
"evidence": "\"screen on the kitchen door is torn at the bottom\"",
"weight": "strong"
},
{
"cause": "Old wooden windows may have a small crack",
"evidence": "\"old wooden windows\"",
"weight": "possible"
},
{
"cause": "Unseen opening in the siding where they gathered",
"evidence": "\"on the siding\"",
"weight": "possible"
}
]
},
{
"id": "pipes",
"causes": [
{
"cause": "An opening around the window air conditioner",
"evidence": "\"window air conditioner in the back bedroom stays in all year\"",
"weight": "strong"
}
]
},
{
"id": "roof",
"causes": [
{
"cause": "A possible opening around the roofline, eaves or chimney",
"evidence": "Not mentioned - worth a look.",
"weight": "check"
}
]
},
{
"id": "warmth",
"causes": [
{
"cause": "Afternoon sun warming the back wall",
"evidence": "\"back wall gets the afternoon sun\"",
"weight": "strong"
}
]
},
{
"id": "outside",
"causes": [
{
"cause": "Row of trees close behind the fence",
"evidence": "\"row of trees behind the fence\"",
"weight": "strong"
}
]
},
{
"id": "habits",
"causes": [
{
"cause": "Possible open doors or items carried in",
"evidence": "Not mentioned - worth a look.",
"weight": "check"
}
]
}
],
"root_causes": [
{
"bone": "gaps",
"cause": "Torn screen on the kitchen door",
"why": "Two insects were seen on the kitchen door screen, matching the tear noted there."
},
{
"bone": "pipes",
"cause": "An opening around the window air conditioner",
"why": "The bedroom had the highest count indoors, with sightings right around and next to the air conditioner."
},
{
"bone": "warmth",
"cause": "Afternoon sun warming the back wall",
"why": "The back wall tally of 30 is the largest count, matching where they were seen gathering in the sun."
}
],
"fixes": [
{
"when": "today",
"do": "Vacuum up or collect in a jar any insects gathering on the back wall or siding.",
"bone": "none"
},
{
"when": "this-week",
"do": "Patch or replace the torn screen on the kitchen door.",
"bone": "gaps"
},
{
"when": "this-week",
"do": "Seal the opening around the window air conditioner in the back bedroom with foam or weatherstripping.",
"bone": "pipes"
},
{
"when": "this-week",
"do": "Add weatherstripping or caulk around the old wooden windows to close any small openings.",
"bone": "gaps"
},
{
"when": "before-next-autumn",
"do": "Check the siding closely and fill any small cracks you find with caulk.",
"bone": "gaps"
}
],
"dont": [
"Don't squash them indoors, since it can leave a smell or a stain.",
"Don't seal the torn screen or window openings while insects are still behind them, as you could trap them inside."
],
"watch": "Note whether the numbers gathering on the back wall and siding start to drop, and whether fewer turn up by the kitchen door screen or the bedroom air conditioner after you seal things up."
}
Lady beetles in the attic bedroom - wait-them-out
Input:
{
"task": "trace",
"bug_kind": "lady-beetle (round, orange to red, often spotted)",
"stage": "inside (they are already turning up indoors)",
"warning_signs": "no",
"sightings": "1. Attic bedroom, 12 on the skylight on a warm afternoon\n2. Attic bedroom, 5 around the ceiling light\n3. Bathroom, 2 near the extractor fan\n4. Landing, 1 on the window",
"room_tally": "Attic: 17 | Bathroom: 2 | Hall and stairs: 1",
"house": "Older house with a converted attic bedroom under the roof and a skylight. The bathroom has an extractor fan that vents through the roof. There is a field behind the house.",
"clues": "pipes | extractor, vents\nroof | attic, roof, skylight\nwarmth | warm\noutside | field"
}
Reply:
{
"task": "trace",
"verdict": "wait-them-out",
"verdict_reason": "This is based on you saying the insects are already turning up inside the house, rather than still gathering on the outer walls or waking up and heading for the windows.",
"title": "Lady beetles gathering around the attic skylight",
"summary": "Most of the lady beetles are turning up in the attic (17), far more than the bathroom (2) or the hall and stairs (1). Since they are already indoors rather than still gathering on the outer walls, the plan is to remove the ones you see now and gradually seal their ways in, rather than rushing to treat anything.",
"suspect": {
"name": "Lady beetle",
"match": "fits",
"why": "Nothing in the sightings conflicts with the round, orange to red, often spotted description you gave for the insect."
},
"bones": [
{
"id": "gaps",
"causes": [
{
"cause": "A window that could be a way in",
"evidence": "\"1 on the window\"",
"weight": "possible"
},
{
"cause": "General wear and cracks in an older house",
"evidence": "\"Older house\"",
"weight": "possible"
}
]
},
{
"id": "pipes",
"causes": [
{
"cause": "The bathroom extractor fan vent",
"evidence": "\"extractor fan that vents through the roof\"",
"weight": "strong"
}
]
},
{
"id": "roof",
"causes": [
{
"cause": "The skylight in the attic bedroom",
"evidence": "\"a converted attic bedroom under the roof and a skylight\"",
"weight": "strong"
}
]
},
{
"id": "warmth",
"causes": [
{
"cause": "The ceiling light drawing them in",
"evidence": "\"5 around the ceiling light\"",
"weight": "strong"
},
{
"cause": "The warm afternoon near the skylight",
"evidence": "\"on a warm afternoon\"",
"weight": "possible"
}
]
},
{
"id": "outside",
"causes": [
{
"cause": "The field behind the house",
"evidence": "\"There is a field behind the house\"",
"weight": "strong"
}
]
},
{
"id": "habits",
"causes": [
{
"cause": "Doors or windows left open",
"evidence": "Not mentioned - worth a look.",
"weight": "check"
}
]
}
],
"root_causes": [
{
"bone": "roof",
"cause": "The skylight in the attic bedroom",
"why": "Twelve of the seventeen insects counted in the attic were seen right on the skylight."
},
{
"bone": "warmth",
"cause": "The ceiling light drawing them in",
"why": "Five of the attic sightings were insects gathered right around the ceiling light."
},
{
"bone": "pipes",
"cause": "The bathroom extractor fan vent",
"why": "Both insects counted in the bathroom were found near the extractor fan."
}
],
"fixes": [
{
"when": "today",
"do": "Vacuum up any insects you find, or collect them in a cup of soapy water, rather than squashing them.",
"bone": "none"
},
{
"when": "this-week",
"do": "Seal around the ceiling light fitting in the attic bedroom from the inside with caulk or foam.",
"bone": "warmth"
},
{
"when": "this-week",
"do": "Seal around the extractor fan cover in the bathroom from the inside.",
"bone": "pipes"
},
{
"when": "this-week",
"do": "Seal around the window trim on the landing from the inside with caulk or weatherstripping.",
"bone": "gaps"
},
{
"when": "before-next-autumn",
"do": "Have the skylight's outer seal checked and repaired where it meets the roof.",
"bone": "roof"
}
],
"dont": [
"Don't squash them indoors - it can leave a smell or a stain.",
"Don't seal the cracks around the skylight or ceiling light while insects are still gathered there."
],
"watch": "Note whether fewer insects are seen around the attic skylight and ceiling light after sealing, and whether any keep appearing near the bathroom extractor fan or the landing window."
}
Small beetles in the flour - check-id
Input:
{
"task": "trace",
"bug_kind": "not-sure (I don't know what it is)",
"stage": "inside (they are already turning up indoors)",
"warning_signs": "yes",
"sightings": "1. Kitchen, a few small dark beetles in an open bag of flour in the cupboard\n2. Kitchen, 3 on the window sill\n3. Kitchen, 1 on the counter",
"room_tally": "Kitchen: 7",
"house": "Ground-floor flat. The kitchen window opens onto the yard and the bins are just outside the back door.",
"clues": "none found"
}
Reply:
{
"task": "trace",
"verdict": "check-id",
"verdict_reason": "You weren't sure what kind of insect this was and also flagged one of the warning signs, so getting it identified comes first.",
"title": "Unidentified Insects Gathering in the Kitchen",
"summary": "All seven insects counted so far have turned up in the kitchen. Since you're not sure what kind of insect this is and flagged a warning sign, the next step is to get it identified before doing anything else.",
"suspect": {
"name": "Not identified yet",
"match": "unsure",
"why": "You said you aren't sure what kind of insect this is, so there's nothing yet to compare the sightings against."
},
"bones": [
{
"id": "gaps",
"causes": [
{
"cause": "The kitchen window as a possible entry point",
"evidence": "\"3 on the window sill\"",
"weight": "possible"
},
{
"cause": "The back door, close to the outside bins",
"evidence": "\"the bins are just outside the back door\"",
"weight": "possible"
}
]
},
{
"id": "pipes",
"causes": [
{
"cause": "Vents, pipes or cables entering the kitchen",
"evidence": "Not mentioned - worth a look.",
"weight": "check"
}
]
},
{
"id": "roof",
"causes": [
{
"cause": "The attic, roof edges or chimney",
"evidence": "Not mentioned - worth a look.",
"weight": "check"
}
]
},
{
"id": "warmth",
"causes": [
{
"cause": "Warm sunny walls or outside lights",
"evidence": "Not mentioned - worth a look.",
"weight": "check"
}
]
},
{
"id": "outside",
"causes": [
{
"cause": "Trees, plants or woodpiles close to the house",
"evidence": "Not mentioned - worth a look.",
"weight": "check"
}
]
},
{
"id": "habits",
"causes": [
{
"cause": "An open bag of food left out",
"evidence": "\"an open bag of flour in the cupboard\"",
"weight": "strong"
}
]
}
],
"root_causes": [
{
"bone": "habits",
"cause": "An open bag of food left out",
"why": "All seven insects were counted in the kitchen, the same room where the beetles were found inside an open bag of flour."
},
{
"bone": "gaps",
"cause": "The kitchen window as a possible entry point",
"why": "Several of the insects were seen sitting on the kitchen window sill, in the same room where all seven were counted."
},
{
"bone": "gaps",
"cause": "The back door, close to the outside bins",
"why": "All seven insects turned up in the kitchen, the room next to the back door that sits right by the outside bins."
}
],
"fixes": [
{
"when": "today",
"do": "Catch one of the insects in a clear jar, or take a sharp close-up photo, and have it identified by a local extension service or a pest professional before doing anything else.",
"bone": "none"
},
{
"when": "today",
"do": "Put the open bag of flour in a sealed bag and throw it away.",
"bone": "none"
},
{
"when": "this-week",
"do": "Fill any gaps around the kitchen window frame with caulk or weatherstripping.",
"bone": "gaps"
},
{
"when": "this-week",
"do": "Check the back door for gaps and seal them with weatherstripping or caulk.",
"bone": "gaps"
},
{
"when": "before-next-autumn",
"do": "Once the insect is identified, check other possible entry points such as the roof edges, vents and siding.",
"bone": "none"
}
],
"dont": [
"Don't squash the insects indoors, as this can leave a smell or a stain.",
"Don't try to get rid of them yourself before the insect has been identified.",
"Don't seal up the window or door gaps while insects might still be behind them."
],
"watch": "Watch whether the number of insects you see in the kitchen goes down once the food is put away and the window and door gaps are sealed."
}
Build the input yourself
Load the script in node (global.window = global; require("./lodgekit.js")), then:
const K = LodgeKit;
const form = K.form({ bug_kind: "boxelder-bug", stage: "arriving", warning: false,
sightings: "Porch, about 20 on the sunny wall\nLiving room, 2 on the window", house: "Old wooden windows and a gap under the front door." });
K.sightings(form); // one row per line: place, count, insect
K.tally(K.sightings(form)); // [{name: "Outside walls", count: 20}, {name: "Living room", count: 2}]
K.clues(form); // clue words per bone: gaps, warmth ...
K.expected(form); // {verdict: "seal-now", why: [...]} - the page's rule
const built = K.buildInput(form);
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply) // the trace as Markdown
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://six-legged-lodgers.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://six-legged-lodgers.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"]
// six-legged-lodgers.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://six-legged-lodgers.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://six-legged-lodgers.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
}
// SixLeggedLodgers.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 SixLeggedLodgers {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://six-legged-lodgers.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://six-legged-lodgers.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://six-legged-lodgers.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://six-legged-lodgers.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":"six-legged-lodgers"}' | 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": "six-legged-lodgers"})["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: "six-legged-lodgers" })).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": "six-legged-lodgers"}, 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", "six-legged-lodgers"), 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: "six-legged-lodgers" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "six-legged-lodgers"])["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 = "six-legged-lodgers" }))!["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": "trace", "bug_kind": "stink-bug (shield-shaped, brown, smells when handled)", "stage": "arriving (they are gathering on outside walls or only just starting to come in)", "warning_signs": "no", "sightings": "1. Outside on the back wall, about 30 on the siding in the afternoon\n2. Back bedroom, 4 stink bugs around the window air conditioner\n3. Kitchen, 2 on the door screen\n4. Back bedroom, 3 on the curtain next to the air conditioner", "room_tally": "Outside walls: 30 | Bedroom: 7 | Kitchen: 2", "house": "Two-storey house with old wooden windows. The window air conditioner in the back bedroom stays in all year. The back wall gets the afternoon sun and there is a row of trees behind the fence. The screen on the kitchen door is torn at the bottom.", "clues": "gaps | old wooden windows, screen, torn, siding\npipes | air conditioner\nwarmth | sun\noutside | trees"}
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": "trace", "bug_kind": "stink-bug (shield-shaped, brown, smells when handled)", "stage": "arriving (they are gathering on outside walls or only just starting to come in)", "warning_signs": "no", "sightings": "1. Outside on the back wall, about 30 on the siding in the afternoon\n2. Back bedroom, 4 stink bugs around the window air conditioner\n3. Kitchen, 2 on the door screen\n4. Back bedroom, 3 on the curtain next to the air conditioner", "room_tally": "Outside walls: 30 | Bedroom: 7 | Kitchen: 2", "house": "Two-storey house with old wooden windows. The window air conditioner in the back bedroom stays in all year. The back wall gets the afternoon sun and there is a row of trees behind the fence. The screen on the kitchen door is torn at the bottom.", "clues": "gaps | old wooden windows, screen, torn, siding\npipes | air conditioner\nwarmth | sun\noutside | trees"}
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: "trace", bug_kind, stage, warning_signs, sightings, room_tally, house, clues, clip_note} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses six-legged-lodgers:<task>:<inputHash>:a<attempt> (the one task is trace), 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="six-legged-lodgers:trace:$(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 = "six-legged-lodgers:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("title"))
import { createHash } from "node:crypto";
const key = `six-legged-lodgers:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "six-legged-lodgers:" + 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 = "six-legged-lodgers:" + 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 = "six-legged-lodgers:#{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 = "six-legged-lodgers:" . $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 = $"six-legged-lodgers:{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: six-legged-lodgers:trace:$(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": "trace", "bug_kind": "stink-bug (shield-shaped, brown, smells when handled)", "stage": "arriving (they are gathering on outside walls or only just starting to come in)", "warning_signs": "no", "sightings": "1. Outside on the back wall, about 30 on the siding in the afternoon\n2. Back bedroom, 4 stink bugs around the window air conditioner\n3. Kitchen, 2 on the door screen\n4. Back bedroom, 3 on the curtain next to the air conditioner", "room_tally": "Outside walls: 30 | Bedroom: 7 | Kitchen: 2", "house": "Two-storey house with old wooden windows. The window air conditioner in the back bedroom stays in all year. The back wall gets the afternoon sun and there is a row of trees behind the fence. The screen on the kitchen door is torn at the bottom.", "clues": "gaps | old wooden windows, screen, torn, siding\npipes | air conditioner\nwarmth | sun\noutside | trees"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "six-legged-lodgers:trace:" + 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": `six-legged-lodgers:trace:${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", "six-legged-lodgers:trace:"+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", "six-legged-lodgers:trace:" + 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" => "six-legged-lodgers:trace:#{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: six-legged-lodgers:trace:" . 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", $"six-legged-lodgers:trace:{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);