Drive the wind-down guide from your own code
The pacing happens in the browser: the page turns a breathing pattern and a segment length into breath slots - one per full breath - and a word budget for each phase at a slow reading pace. The guide over HTTP takes those facts and writes a script to fit them, or reads a session plan and the last few before/after ratings and proposes an adjusted plan. It is wellbeing practice, not medical advice: the guide never diagnoses and never advises on health.
Basics
- Base URL:
https://api.skillsafe.ai/v1/app-api. Every response is an envelope:{"data": ...}on success,{"error": {"code", "message"}}on failure. - The run input is one flat JSON object sent as the body itself - never wrapped in
{"input": ...}. Fields that hold structure (pattern,plan,patterns,library,logs) are JSON strings. - The model is
gpt-terra(currentlygpt-5.6-terra); each run is metered in credits at the platform rate plus a 10% publisher markup./estimateis free.
Error codes
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field has the wrong type. |
| 401 | UNAUTHORIZED | Missing, expired or revoked token. Get a new one on /tokens.html. |
| 402 | INSUFFICIENT_CREDITS | The balance is below min_credits. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried to run. Runs need a personal (signed-in) token. |
| 409 | IDEMPOTENCY_CONFLICT | The same Idempotency-Key was reused with a different body. |
| 429 | RATE_LIMITED | Slow down and retry with backoff. |
The task field: two lanes
| task | what it does | reply |
|---|---|---|
script | Writes a bespoke script for one segment (body scan, grounding, sleep story or visualisation): exactly slot_count short phrases, each tagged with the breath phase it lands on and within that phase's word budget. | {"task":"script","title","blurb","phrases":[{"slot","phase","text"}],"closing_note"} |
plan | Reviews the session plan and recent before/after log (intent: "review") or makes the same session a little longer (intent: "longer") and returns the full proposed plan with one reason per change. | {"task":"plan","headline","plan":[{"id","kind","minutes","pattern","script","reason"}],"removed":[{"id","reason"}],"order_reason","note"} |
Input fields for script
| field | type | meaning |
|---|---|---|
task | string | "script" |
segment_kind | string | scan, grounding, story or visual |
audience | string | adult, child, exam or midday |
pattern_id | string | one of box, 478, coherent, calm, settle, small |
pattern | JSON string | {id, name, cycle_seconds, phases:[{phase, seconds, max_words}]} - phases are in, hold, out, rest |
slot_count | number | breath slots in the segment: one phrase per slot |
minutes, words_per_second | number | the segment length and the slow pace the budgets assume (1.5) |
theme, avoid, tone | string | optional: what it is about, what to leave out, the voice wanted |
current_title | string | optional: the bundled script the segment plays now |
clip_note, retry_note | string | optional: what the page cut from a long field; a correction after an unreadable reply |
Input fields for plan
| field | type | meaning |
|---|---|---|
task | string | "plan" |
intent | string | review or longer |
audience, tone, plan_name | string | who the session is for, the voice wanted, its name |
plan | JSON string | segments in play order: {id, kind, minutes, pattern?, script?, script_title?} |
minutes_now, chime_between | number, boolean | the total including 6-second chimes between segments; whether those chimes play |
patterns, library | JSON string | every pattern and every script the app can play - the only ids a reply may use |
logs, log_count | JSON string, number | up to 8 recent sessions as {days_ago, plan, planned_min, played_min, completed, before, after, patterns, kinds} - never timestamps |
log_note, clip_note, retry_note | string | optional |
Worked examples
These are the page's four free examples - the exact bodies the page sends and the replies it replays.
script: a night-train sleep story on 4-7-8, 30 breath slots
Input:
{
"task": "script",
"segment_kind": "story",
"audience": "adult",
"tone": "",
"theme": "a slow night train through snowy country, seen from a warm sleeper cabin",
"avoid": "no stations announced, no other passengers talking",
"pattern_id": "478",
"pattern": "(a JSON string - decoded below)",
"slot_count": 30,
"minutes": 9.5,
"words_per_second": 1.5,
"current_title": "The night harbour"
}
pattern, decoded:
{
"id": "478",
"name": "4-7-8",
"cycle_seconds": 19,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "hold",
"seconds": 7,
"max_words": 10
},
{
"phase": "out",
"seconds": 8,
"max_words": 12
}
]
}
Reply:
{
"task": "script",
"title": "The Snowbound Sleeper",
"blurb": "A slow night train through snowy country, watched from a warm bunk as the world goes quiet.",
"phrases": [
{
"slot": 1,
"phase": "out",
"text": "You are lying in a narrow bunk, under a heavy wool blanket."
},
{
"slot": 2,
"phase": "in",
"text": "The cabin is small and warm."
},
{
"slot": 3,
"phase": "out",
"text": "A little lamp glows amber beside the window, turned down low."
},
{
"slot": 4,
"phase": "hold",
"text": "Outside, the snow keeps falling, soft and slow."
},
{
"slot": 5,
"phase": "out",
"text": "The train moves through the night at an easy, unhurried pace."
},
{
"slot": 6,
"phase": "in",
"text": "Notice the gentle sway beneath you."
},
{
"slot": 7,
"phase": "out",
"text": "Side to side, side to side, like a cradle rocking slowly."
},
{
"slot": 8,
"phase": "out",
"text": "The wheels hum on the rails, one low note, over and over."
},
{
"slot": 9,
"phase": "hold",
"text": "Beyond the glass, white fields stretch away, quiet and wide."
},
{
"slot": 10,
"phase": "out",
"text": "A line of dark pine trees drifts past, their branches dusted white."
},
{
"slot": 11,
"phase": "in",
"text": "Feel the warmth of the blanket."
},
{
"slot": 12,
"phase": "out",
"text": "Somewhere below, the heating ticks softly, keeping the little room cosy."
},
{
"slot": 13,
"phase": "hold",
"text": "A farmhouse light glows far off, then slips behind."
},
{
"slot": 14,
"phase": "out",
"text": "Snow on the window ledge grows a little deeper, a little rounder."
},
{
"slot": 15,
"phase": "out",
"text": "There is nowhere you need to be. The train knows the way."
},
{
"slot": 16,
"phase": "in",
"text": "The rocking carries on, steady."
},
{
"slot": 17,
"phase": "out",
"text": "A frozen river passes, still and silver beneath the moon."
},
{
"slot": 18,
"phase": "hold",
"text": "The snow falls on the river, and on the trees."
},
{
"slot": 19,
"phase": "out",
"text": "The hum of the wheels grows softer, further away."
},
{
"slot": 20,
"phase": "out",
"text": "Your hands are heavy and warm on the blanket."
},
{
"slot": 21,
"phase": "in",
"text": "The lamp glows a little dimmer."
},
{
"slot": 22,
"phase": "out",
"text": "Outside, the hills are round and white and sleeping."
},
{
"slot": 23,
"phase": "hold",
"text": "Only snow now, falling and falling."
},
{
"slot": 24,
"phase": "out",
"text": "The fields, the snow, the slow rocking."
},
{
"slot": 25,
"phase": "out",
"text": "Softer still."
},
{
"slot": 26,
"phase": "in",
"text": "One more slow breath."
},
{
"slot": 27,
"phase": "out",
"text": "The window fills with quiet white."
},
{
"slot": 28,
"phase": "hold",
"text": "Nothing to watch."
},
{
"slot": 29,
"phase": "out",
"text": "Let the train carry you."
},
{
"slot": 30,
"phase": "out",
"text": "Rest now."
}
],
"closing_note": ""
}
script: a child's body scan about a sleepy cat, small breaths, 20 slots
Input:
{
"task": "script",
"segment_kind": "scan",
"audience": "child",
"tone": "simple words for a four-year-old",
"theme": "a sleepy cat stretching and curling up, part by part",
"avoid": "",
"pattern_id": "small",
"pattern": "(a JSON string - decoded below)",
"slot_count": 20,
"minutes": 2.3,
"words_per_second": 1.5,
"current_title": "Floppy teddy"
}
pattern, decoded:
{
"id": "small",
"name": "Small breaths 3-4",
"cycle_seconds": 7,
"phases": [
{
"phase": "in",
"seconds": 3,
"max_words": 4
},
{
"phase": "out",
"seconds": 4,
"max_words": 6
}
]
}
Reply:
{
"task": "script",
"title": "Curl Up Like a Cat",
"blurb": "A tiny bedtime body scan: a sleepy cat stretches, softens and curls up, part by part.",
"phrases": [
{
"slot": 1,
"phase": "out",
"text": "Let's be a sleepy little cat."
},
{
"slot": 2,
"phase": "in",
"text": "Stretch your back paws."
},
{
"slot": 3,
"phase": "out",
"text": "Now let them go soft."
},
{
"slot": 4,
"phase": "in",
"text": "Stretch your long legs."
},
{
"slot": 5,
"phase": "out",
"text": "Floppy legs, sleepy legs."
},
{
"slot": 6,
"phase": "in",
"text": "Breathe into your tummy."
},
{
"slot": 7,
"phase": "out",
"text": "Your tummy is warm and round."
},
{
"slot": 8,
"phase": "out",
"text": "Your back sinks into the bed."
},
{
"slot": 9,
"phase": "in",
"text": "Stretch your front paws."
},
{
"slot": 10,
"phase": "out",
"text": "Now your paws go soft."
},
{
"slot": 11,
"phase": "in",
"text": "Shrug up your shoulders."
},
{
"slot": 12,
"phase": "out",
"text": "Let them drop down, down, down."
},
{
"slot": 13,
"phase": "out",
"text": "Your sleepy head is heavy."
},
{
"slot": 14,
"phase": "in",
"text": "Wiggle your little whiskers."
},
{
"slot": 15,
"phase": "out",
"text": "Now your face goes still."
},
{
"slot": 16,
"phase": "out",
"text": "Your ears are soft and quiet."
},
{
"slot": 17,
"phase": "out",
"text": "Curl your tail around you."
},
{
"slot": 18,
"phase": "in",
"text": "Cosy, cosy cat."
},
{
"slot": 19,
"phase": "out",
"text": "Curled up warm, nose to tail."
},
{
"slot": 20,
"phase": "out",
"text": "Goodnight paws, goodnight tail, goodnight cat."
}
],
"closing_note": ""
}
plan (intent review): a fortnight of evenings where the story is often abandoned
Input:
{
"task": "plan",
"intent": "review",
"audience": "adult",
"tone": "",
"plan_name": "Evening wind-down",
"plan": "(a JSON string - decoded below)",
"minutes_now": 20.4,
"chime_between": true,
"patterns": "(a JSON string - decoded below)",
"library": "(a JSON string - decoded below)",
"logs": "(a JSON string - decoded below)",
"log_count": 9,
"log_note": "I usually drift off or give up somewhere in the story.",
"clip_note": "sent the latest 8 of 9 sessions; the 1 older ones are not listed"
}
plan, decoded:
[
{
"id": "s1",
"kind": "breath",
"minutes": 2,
"pattern": "calm"
},
{
"id": "s2",
"kind": "scan",
"minutes": 5.7,
"pattern": "calm",
"script": "scan-evening",
"script_title": "Evening body scan"
},
{
"id": "s3",
"kind": "silence",
"minutes": 1
},
{
"id": "s4",
"kind": "story",
"minutes": 11.4,
"pattern": "478",
"script": "story-harbour",
"script_title": "The night harbour"
}
]
patterns, decoded:
[
{
"id": "box",
"name": "Box 4-4-4-4",
"cycle_seconds": 16,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "hold",
"seconds": 4,
"max_words": 6
},
{
"phase": "out",
"seconds": 4,
"max_words": 6
},
{
"phase": "rest",
"seconds": 4,
"max_words": 6
}
]
},
{
"id": "478",
"name": "4-7-8",
"cycle_seconds": 19,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "hold",
"seconds": 7,
"max_words": 10
},
{
"phase": "out",
"seconds": 8,
"max_words": 12
}
]
},
{
"id": "coherent",
"name": "Coherent 5.5",
"cycle_seconds": 11,
"phases": [
{
"phase": "in",
"seconds": 5.5,
"max_words": 8
},
{
"phase": "out",
"seconds": 5.5,
"max_words": 8
}
]
},
{
"id": "calm",
"name": "Calm 4-6",
"cycle_seconds": 10,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "out",
"seconds": 6,
"max_words": 9
}
]
},
{
"id": "settle",
"name": "Settle 4-2-6",
"cycle_seconds": 12,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "hold",
"seconds": 2,
"max_words": 3
},
{
"phase": "out",
"seconds": 6,
"max_words": 9
}
]
},
{
"id": "small",
"name": "Small breaths 3-4",
"cycle_seconds": 7,
"phases": [
{
"phase": "in",
"seconds": 3,
"max_words": 4
},
{
"phase": "out",
"seconds": 4,
"max_words": 6
}
]
}
]
library, decoded:
[
{
"id": "scan-evening",
"kind": "scan",
"title": "Evening body scan",
"audience": "adult",
"pattern": "calm",
"phrases": 34
},
{
"id": "ground-senses",
"kind": "grounding",
"title": "Five senses, slowly",
"audience": "adult",
"pattern": "box",
"phrases": 26
},
{
"id": "exam-steady",
"kind": "grounding",
"title": "Before the exam",
"audience": "exam",
"pattern": "settle",
"phrases": 21
},
{
"id": "story-harbour",
"kind": "story",
"title": "The night harbour",
"audience": "adult",
"pattern": "478",
"phrases": 36
},
{
"id": "story-fox",
"kind": "story",
"title": "The sleepy fox",
"audience": "child",
"pattern": "small",
"phrases": 33
},
{
"id": "visual-lake",
"kind": "visual",
"title": "The warm lake",
"audience": "adult",
"pattern": "coherent",
"phrases": 27
},
{
"id": "scan-floppy",
"kind": "scan",
"title": "Floppy teddy",
"audience": "child",
"pattern": "small",
"phrases": 24
},
{
"id": "midday-reset",
"kind": "grounding",
"title": "Midday reset",
"audience": "adult",
"pattern": "coherent",
"phrases": 17
}
]
logs, decoded:
[
{
"days_ago": 0,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 10.2,
"completed": false,
"before": 2,
"after": 3,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
},
{
"days_ago": 1,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 20.4,
"completed": true,
"before": 2,
"after": 4,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
},
{
"days_ago": 2,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 11.7,
"completed": false,
"before": 3,
"after": 3,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
},
{
"days_ago": 4,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 10.7,
"completed": false,
"before": 2,
"after": 3,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
},
{
"days_ago": 5,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 20.4,
"completed": true,
"before": 1,
"after": 4,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
},
{
"days_ago": 7,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 9.8,
"completed": false,
"before": 3,
"after": 3,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
},
{
"days_ago": 9,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 20.4,
"completed": true,
"before": 2,
"after": 4,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
},
{
"days_ago": 10,
"plan": "Evening wind-down",
"planned_min": 20.4,
"played_min": 11,
"completed": false,
"before": 2,
"after": null,
"patterns": [
"calm",
"478"
],
"kinds": [
"breath",
"scan",
"silence",
"story"
]
}
]
Reply:
{
"task": "plan",
"headline": "Keep the breathing and body scan that work, and play the story on Calm 4-6 instead of 4-7-8, so it is shorter and has no long holds.",
"plan": [
{
"id": "s1",
"kind": "breath",
"minutes": 2,
"pattern": "calm",
"script": "",
"reason": ""
},
{
"id": "s2",
"kind": "scan",
"minutes": 5.7,
"pattern": "calm",
"script": "scan-evening",
"reason": ""
},
{
"id": "s3",
"kind": "silence",
"minutes": 1,
"pattern": "",
"script": "",
"reason": ""
},
{
"id": "s4",
"kind": "story",
"minutes": 6,
"pattern": "calm",
"script": "story-harbour",
"reason": "5 of 8 sessions stopped early in the story; Calm 4-6 has no 7-second hold and fits 36 phrases in 6 min."
}
],
"removed": [],
"order_reason": "",
"note": "Drifting off during the story is fine; there is no need to reach the end."
}
plan (intent longer): the exam session tomorrow, a little longer
Input:
{
"task": "plan",
"intent": "longer",
"audience": "exam",
"tone": "",
"plan_name": "Before the exam",
"plan": "(a JSON string - decoded below)",
"minutes_now": 7,
"chime_between": true,
"patterns": "(a JSON string - decoded below)",
"library": "(a JSON string - decoded below)",
"logs": "[]",
"log_count": 0,
"log_note": ""
}
plan, decoded:
[
{
"id": "e1",
"kind": "breath",
"minutes": 2.1,
"pattern": "box"
},
{
"id": "e2",
"kind": "grounding",
"minutes": 4.2,
"pattern": "settle",
"script": "exam-steady",
"script_title": "Before the exam"
},
{
"id": "e3",
"kind": "silence",
"minutes": 0.5
}
]
patterns, decoded:
[
{
"id": "box",
"name": "Box 4-4-4-4",
"cycle_seconds": 16,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "hold",
"seconds": 4,
"max_words": 6
},
{
"phase": "out",
"seconds": 4,
"max_words": 6
},
{
"phase": "rest",
"seconds": 4,
"max_words": 6
}
]
},
{
"id": "478",
"name": "4-7-8",
"cycle_seconds": 19,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "hold",
"seconds": 7,
"max_words": 10
},
{
"phase": "out",
"seconds": 8,
"max_words": 12
}
]
},
{
"id": "coherent",
"name": "Coherent 5.5",
"cycle_seconds": 11,
"phases": [
{
"phase": "in",
"seconds": 5.5,
"max_words": 8
},
{
"phase": "out",
"seconds": 5.5,
"max_words": 8
}
]
},
{
"id": "calm",
"name": "Calm 4-6",
"cycle_seconds": 10,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "out",
"seconds": 6,
"max_words": 9
}
]
},
{
"id": "settle",
"name": "Settle 4-2-6",
"cycle_seconds": 12,
"phases": [
{
"phase": "in",
"seconds": 4,
"max_words": 6
},
{
"phase": "hold",
"seconds": 2,
"max_words": 3
},
{
"phase": "out",
"seconds": 6,
"max_words": 9
}
]
},
{
"id": "small",
"name": "Small breaths 3-4",
"cycle_seconds": 7,
"phases": [
{
"phase": "in",
"seconds": 3,
"max_words": 4
},
{
"phase": "out",
"seconds": 4,
"max_words": 6
}
]
}
]
library, decoded:
[
{
"id": "scan-evening",
"kind": "scan",
"title": "Evening body scan",
"audience": "adult",
"pattern": "calm",
"phrases": 34
},
{
"id": "ground-senses",
"kind": "grounding",
"title": "Five senses, slowly",
"audience": "adult",
"pattern": "box",
"phrases": 26
},
{
"id": "exam-steady",
"kind": "grounding",
"title": "Before the exam",
"audience": "exam",
"pattern": "settle",
"phrases": 21
},
{
"id": "story-harbour",
"kind": "story",
"title": "The night harbour",
"audience": "adult",
"pattern": "478",
"phrases": 36
},
{
"id": "story-fox",
"kind": "story",
"title": "The sleepy fox",
"audience": "child",
"pattern": "small",
"phrases": 33
},
{
"id": "visual-lake",
"kind": "visual",
"title": "The warm lake",
"audience": "adult",
"pattern": "coherent",
"phrases": 27
},
{
"id": "scan-floppy",
"kind": "scan",
"title": "Floppy teddy",
"audience": "child",
"pattern": "small",
"phrases": 24
},
{
"id": "midday-reset",
"kind": "grounding",
"title": "Midday reset",
"audience": "adult",
"pattern": "coherent",
"phrases": 17
}
]
Reply:
{
"task": "plan",
"headline": "The same session about a minute longer: more box breathing to start and a longer silence at the end, with the grounding script unchanged.",
"plan": [
{
"id": "e1",
"kind": "breath",
"minutes": 2.7,
"pattern": "box",
"script": "",
"reason": "Lengthened from 2.1 to 2.7 min, about 10 box cycles, to give a steadier start."
},
{
"id": "e2",
"kind": "grounding",
"minutes": 4.2,
"pattern": "settle",
"script": "exam-steady",
"reason": ""
},
{
"id": "e3",
"kind": "silence",
"minutes": 1,
"pattern": "",
"script": "",
"reason": "Doubled from 0.5 to 1 min, so the steadiness from the script has room to settle before you begin."
}
],
"removed": [],
"order_reason": "",
"note": ""
}
The page reconciles every reply; over the API, so should you
The page's own code is plain scripts you can load in node after global.window = {}, in this order: breath.js (patterns, phase timing, Breath.place - the phrase-slot engine that budgets words per phase and splits overlong phrases), scripts.js (the bundled library), plan.js (Plan.reconcilePlan turns a proposed plan into separate changes and flags unknown patterns, unknown scripts, out-of-range minutes and missing reasons), guide.js (Guide.buildInput builds these exact run bodies; Guide.normalize checks a reply).
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://nightfold.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://nightfold.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"]
// nightfold.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://nightfold.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://nightfold.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
}
// Nightfold.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 Nightfold {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://nightfold.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://nightfold.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://nightfold.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://nightfold.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":"nightfold"}' | 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": "nightfold"})["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: "nightfold" })).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": "nightfold"}, 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", "nightfold"), 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: "nightfold" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "nightfold"])["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 = "nightfold" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "script", "segment_kind": "story", "pattern_id": "478", "slot_count": 30, "pattern": "...", ...}
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": "script", "segment_kind": "story", "pattern_id": "478", "slot_count": 30, "pattern": "...", ...}
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, segment_kind, pattern_id, slot_count, pattern, ...}
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 task and the input: a retry with the same key never bills twice. The page uses nightfold:<task>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="nightfold:script:$(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 = "nightfold:" + 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("task"))
import { createHash } from "node:crypto";
const key = `nightfold:${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.task);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "nightfold:" + 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 = "nightfold:" + 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 = "nightfold:#{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["task"]].inspect
$key = "nightfold:" . $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["task"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"nightfold:{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: nightfold:script:$(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": "script", "segment_kind": "story", "pattern_id": "478", "slot_count": 30, "pattern": "...", ...}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "nightfold:script:" + 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": `nightfold:script:${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", "nightfold:script:"+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", "nightfold:script:" + 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" => "nightfold:script:#{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: nightfold:script:" . 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", $"nightfold:script:{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);