API & developer notes
Butterfly Keys is a no-model app. There is no AI lane, so there is
no /estimate, no /run, no /run-stream and no
/sessions — documenting them would be inventing an API the app does
not have. What it does expose is real and useful: the platform key-value store where a
player’s garden lives. That is what this page covers, so you can read a
child’s progress, move it to another device, seed a classroom, or clear it.
Base URL %s. Every response is a
{"ok":true,"data":{...}} / {"ok":false,"error":{...}}
envelope. All calls below are free; the app charges nothing and has no metered path.
Error codes
| code | HTTP | what it means |
|---|---|---|
UNAUTHORIZED | 401 | Missing or malformed bearer token. |
INVALID_TOKEN | 401 | The token is expired or revoked. Mint a new guest token. |
NOT_FOUND | 404 | No such key. For /data/garden this simply means this player has never played. |
VALIDATION_ERROR | 400 | Malformed body. The commonest cause is sending the slug as a header instead of in the /guest body. |
RATE_LIMITED | 429 | Too many calls. Back off and retry. |
QUOTA_EXCEEDED | 413 | The document is over the 64 KB per-record cap. |
The garden record
| field | type | meaning |
|---|---|---|
v | number | Schema version. Currently 1. |
done | object | Level id (as a string key) → {stars: 1-3, plays: number}. Best stars are kept, never lowered. |
butterflies | number[] | Level ids whose collectible butterfly has hatched. One per level, twenty in all. |
flowers | number | Lifetime correct keystrokes. Monotonic. |
updated | number | Epoch milliseconds of the last change. |
A level is unlocked when the level before it has an entry in
done. To open every level for a returning player, give each id an entry.
The app never replaces one copy of this record with another. Whenever the
stored copy and the browser's local mirror disagree — on boot, on a first sign-in, or
across two devices — they are merged by taking the best of each: maximum
stars per level, the union of butterflies, the larger
flowers, the later updated. The merge is commutative and idempotent,
so a record you write here is added to, never overwritten wholesale, and a stale clock cannot
delete anything. Keep the document small; the platform caps a single document at 64 KB.
1 Get a token
Every call needs a bearer token. Butterfly Keys never asks a child to sign
in, so by default the token it uses is a guest token, minted by posting the
app's slug. A grown-up may optionally sign in from the app's grown-ups panel, in which case
the browser holds a personal account token instead and the garden record below
belongs to that account rather than to an anonymous guest — the endpoints are
identical either way.
The slug goes in the request body — an X-App-Slug header returns
400. If you would rather reuse the token the browser already holds, open
the token page; it shows it, copies it, and mints a fresh one,
so you never need the DevTools console.
POST https://api.skillsafe.ai/v1/app-api/guest
curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/guest' \
-H 'Content-Type: application/json' \
-d '{"slug":"butterfly-keys"}'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
method="POST",
data=json.dumps({"slug":"butterfly-keys"}).encode(),
headers={
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({"slug":"butterfly-keys"}),
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
"strings"
)
const token = "YOUR_TOKEN"
func main() {
body := strings.NewReader(`{"slug":"butterfly-keys"}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/guest", body)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(
"{\"slug\":\"butterfly-keys\"}"));
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/guest')
req = Net::HTTP::Post.new(uri)
req['Content-Type'] = 'application/json'
req.body = '{"slug":"butterfly-keys"}'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/guest');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, '{"slug":"butterfly-keys"}');
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/guest");
req.Content = new StringContent(@"{""slug"":""butterfly-keys""}", Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","credits":0}}
Guest identity note: every call to /guest mints a new subject.
Keep one token for the whole session, or you will be looking at a different (empty)
garden each time.
2 Check who the token is
/me tells you whether the token is a guest or a signed-in user, and
its credit balance. The balance is decorative here: Butterfly Keys has no model and charges
nothing, so a zero-credit guest can use every feature.
GET https://api.skillsafe.ai/v1/app-api/me
curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/me' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/me",
method="GET",
headers={
"Authorization": "Bearer " + TOKEN,
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/me", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
},
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
)
const token = "YOUR_TOKEN"
func main() {
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + TOKEN)
.method("GET", HttpRequest.BodyPublishers.noBody());
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/me')
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/me');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/me");
req.Headers.Add("Authorization", "Bearer " + token);
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"subject_type":"guest","subject_id":"gst_...","credits":0}}3 Confirm the app is free and model-less
/app-info is the authoritative statement of the app's commercial
shape. For Butterfly Keys price_credits is 0 and there is no model
bound, which is why none of the run endpoints exist.
GET https://api.skillsafe.ai/v1/app-api/app-info
curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/app-info' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/app-info",
method="GET",
headers={
"Authorization": "Bearer " + TOKEN,
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/app-info", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
},
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
)
const token = "YOUR_TOKEN"
func main() {
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/app-info", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/app-info"))
.header("Authorization", "Bearer " + TOKEN)
.method("GET", HttpRequest.BodyPublishers.noBody());
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/app-info')
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/app-info');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/app-info");
req.Headers.Add("Authorization", "Bearer " + token);
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"slug":"butterfly-keys","title":"Butterfly Keys","price_credits":0,"model":null}}4 List the keys this app stores
Butterfly Keys keeps exactly one record per player, under the key
garden, in the platform per-user key-value store. Listing keys is the quickest
way to confirm you are holding the right token.
GET https://api.skillsafe.ai/v1/app-api/data
curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/data' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/data",
method="GET",
headers={
"Authorization": "Bearer " + TOKEN,
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/data", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
},
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
)
const token = "YOUR_TOKEN"
func main() {
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/data", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/data"))
.header("Authorization", "Bearer " + TOKEN)
.method("GET", HttpRequest.BodyPublishers.noBody());
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/data')
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/data');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/data");
req.Headers.Add("Authorization", "Bearer " + token);
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"keys":["garden"]}}5 Read the garden
This is the whole of a player's progress. done maps a level id to the
best stars earned and how many times it has been played; butterflies lists the
level ids whose collectible has hatched; flowers is the lifetime count of
correct keystrokes. A 404 means this token has never played.
GET https://api.skillsafe.ai/v1/app-api/data/garden
curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/data/garden' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/data/garden",
method="GET",
headers={
"Authorization": "Bearer " + TOKEN,
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/data/garden", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
},
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
)
const token = "YOUR_TOKEN"
func main() {
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/data/garden", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/data/garden"))
.header("Authorization", "Bearer " + TOKEN)
.method("GET", HttpRequest.BodyPublishers.noBody());
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/data/garden')
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/data/garden');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/data/garden");
req.Headers.Add("Authorization", "Bearer " + token);
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"value":{"v":1,"done":{"1":{"stars":3,"plays":2},"2":{"stars":2,"plays":1}},"butterflies":[1,2],"flowers":46,"updated":1786000000000}}}6 Write the garden back
Useful for moving a child's progress to a new device, or for seeding a classroom of tokens at a particular level. The app normalises whatever it reads — unknown fields are dropped, out-of-range stars are clamped into 1–3, and a corrupt record degrades to a fresh garden rather than an error — so a partial document is safe to send.
PUT https://api.skillsafe.ai/v1/app-api/data/garden
curl -s -X PUT 'https://api.skillsafe.ai/v1/app-api/data/garden' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"value":{"v":1,"done":{"1":{"stars":3,"plays":2},"2":{"stars":2,"plays":1}},"butterflies":[1,2],"flowers":46,"updated":1786000000000}}'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/data/garden",
method="PUT",
data=json.dumps({"value":{"v":1,"done":{"1":{"stars":3,"plays":2},"2":{"stars":2,"plays":1}},"butterflies":[1,2],"flowers":46,"updated":1786000000000}}).encode(),
headers={
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/data/garden", {
method: "PUT",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"value":{"v":1,"done":{"1":{"stars":3,"plays":2},"2":{"stars":2,"plays":1}},"butterflies":[1,2],"flowers":46,"updated":1786000000000}}),
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
"strings"
)
const token = "YOUR_TOKEN"
func main() {
body := strings.NewReader(`{"value":{"v":1,"done":{"1":{"stars":3,"plays":2},"2":{"stars":2,"plays":1}},"butterflies":[1,2],"flowers":46,"updated":1786000000000}}`)
req, _ := http.NewRequest("PUT", "https://api.skillsafe.ai/v1/app-api/data/garden", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/data/garden"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + TOKEN)
.method("PUT", HttpRequest.BodyPublishers.ofString(
"{\"value\":{\"v\":1,\"done\":{\"1\":{\"stars\":3,\"plays\":2},\"2\":{\"stars\":2,\"plays\":1}},\"butterflies\":[1,2],\"flowers\":46,\"updated\":1786000000000}}"));
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/data/garden')
req = Net::HTTP::Put.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
req['Content-Type'] = 'application/json'
req.body = '{"value":{"v":1,"done":{"1":{"stars":3,"plays":2},"2":{"stars":2,"plays":1}},"butterflies":[1,2],"flowers":46,"updated":1786000000000}}'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/data/garden');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token", 'Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, '{"value":{"v":1,"done":{"1":{"stars":3,"plays":2},"2":{"stars":2,"plays":1}},"butterflies":[1,2],"flowers":46,"updated":1786000000000}}');
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("PUT"), "https://api.skillsafe.ai/v1/app-api/data/garden");
req.Headers.Add("Authorization", "Bearer " + token);
req.Content = new StringContent(@"{""value"":{""v"":1,""done"":{""1"":{""stars"":3,""plays"":2},""2"":{""stars"":2,""plays"":1}},""butterflies"":[1,2],""flowers"":46,""updated"":1786000000000}}", Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"ok":true}}The app merges a remote record with the device mirror by taking the most progress from each, so writing a smaller record will not erase butterflies that the browser still remembers locally.
7 Clear the garden
Deletes the record outright. The child's browser will still hold its local mirror until she uses the “Start the garden over” button on the grown-ups panel, which clears both.
DELETE https://api.skillsafe.ai/v1/app-api/data/garden
curl -s -X DELETE 'https://api.skillsafe.ai/v1/app-api/data/garden' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/data/garden",
method="DELETE",
headers={
"Authorization": "Bearer " + TOKEN,
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/data/garden", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${TOKEN}`,
},
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
)
const token = "YOUR_TOKEN"
func main() {
req, _ := http.NewRequest("DELETE", "https://api.skillsafe.ai/v1/app-api/data/garden", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/data/garden"))
.header("Authorization", "Bearer " + TOKEN)
.method("DELETE", HttpRequest.BodyPublishers.noBody());
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/data/garden')
req = Net::HTTP::Delete.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/data/garden');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("DELETE"), "https://api.skillsafe.ai/v1/app-api/data/garden");
req.Headers.Add("Authorization", "Bearer " + token);
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"ok":true}}8 Check storage use
A garden record is a few hundred bytes, so this will never be close to a limit — but the endpoint is the honest way to render a quota meter rather than inferring limits from rejection messages.
GET https://api.skillsafe.ai/v1/app-api/storage
curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/storage' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
TOKEN = "YOUR_TOKEN"
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/storage",
method="GET",
headers={
"Authorization": "Bearer " + TOKEN,
},
)
with urllib.request.urlopen(req) as r:
print(json.load(r))
const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/storage", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
},
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"fmt"
"io"
"net/http"
)
const token = "YOUR_TOKEN"
func main() {
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/storage", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
public class Demo {
static final String TOKEN = "YOUR_TOKEN";
public static void main(String[] args) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/storage"))
.header("Authorization", "Bearer " + TOKEN)
.method("GET", HttpRequest.BodyPublishers.noBody());
HttpResponse<String> r = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(r.body());
}
}
require 'json'
require 'net/http'
TOKEN = "YOUR_TOKEN"
uri = URI('https://api.skillsafe.ai/v1/app-api/storage')
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)
<?php
$token = "YOUR_TOKEN";
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/storage');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
$out = curl_exec($ch);
curl_close($ch);
print_r(json_decode($out, true));
using System.Net.Http;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/storage");
req.Headers.Add("Authorization", "Bearer " + token);
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"storage":{"max_doc_bytes":65536,"records":{"bytes":412}}}}What this app deliberately does not expose
Listed so nobody wastes an afternoon on it:
POST /estimate,POST /run,POST /run-stream— there is no model bound to this app, so there is nothing to estimate or run. A six-year-old cannot hold a credit balance, and a paid run path would make the game unusable for its actual user./sessions— multi-turn chat needs a model.- Collections,
ss.files,ss.drive— the app stores one small JSON record and no files. - Any third-party host. The bundle makes no outbound request other than to
api.skillsafe.ai, and the app CSP would block one anyway.
The game logic is on the client, and it is readable
Because there is no model, the whole curriculum is data in the bundle. If you want to
know exactly which keys a level teaches or how a star is awarded, read
/curriculum.js (levels, key→finger map, word pools,
starsFor) and /engine.js (the keypress state machine).
/SKILL.md-equivalent behaviour is stated on this page and in those two
files; nothing about scoring is hidden server-side.