EduLab

Drive EduLab from your own code

The same model, the same prompt and the same lesson contract the web app uses — reachable directly, so you can generate lessons in a batch, wire them into your own courseware, or check the output against your own solver.

Base URL and envelope

Every route lives under https://api.skillsafe.ai/v1/app-api. There is no /apps/{slug}/ segment — the app is bound to your token when it is issued, which is why the token page matters. Responses are wrapped:

{ "data": { … } }          // success
{ "error": { "code": "insufficient_credits", "message": "…" } }   // failure

Requests are rate limited per token. Back off on 429 rather than retrying immediately.

1 · Get a token

Every call carries Authorization: Bearer <token>. Open the token page to read the one this app already created for you, copy it, or start a fresh guest session — no developer console required. A personal token is tied to your SkillSafe account and its credit balance; a guest token can browse but cannot run this app's model.

The examples below read the token from an environment variable named SKILLSAFE_TOKEN, or fall back to the literal "YOUR_TOKEN" so you can paste yours in directly.

2 · Check who you are and what you can spend

GET /me returns the subject behind the token and its credit balance. Call it first: a lesson run reserves credits up front, so this is how you find out whether a run can succeed before you send one.

{
  "data": {
    "subject_type": "user",
    "credits": 48210,
    "profile": { "username": "you" }
  }
}
curl -sS -X GET "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import os, json, urllib.request

TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
BASE = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    if data: req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]

out = call("GET", "/me")
print(json.dumps(out, indent=2, ensure_ascii=False))
const TOKEN = "YOUR_TOKEN";           // see step 1
const BASE = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Authorization": "Bearer " + TOKEN,
      ...(body ? { "Content-Type": "application/json" } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.code + ": " + json.error?.message);
  return json.data;
}

const out = await call("GET", "/me");
console.log(out);
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

const base = "https://api.skillsafe.ai/v1/app-api"

func call(method, path string, body []byte) ([]byte, error) {
	var r io.Reader
	if body != nil {
		r = bytes.NewReader(body)
	}
	req, _ := http.NewRequest(method, base+path, r)
	token := os.Getenv("SKILLSAFE_TOKEN")
	req.Header.Set("Authorization", "Bearer "+token)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	return io.ReadAll(res.Body)
}

func main() {
	out, err := call("GET", "/me", nil)
	if err != nil {
		panic(err)
	}
	var pretty map[string]any
	json.Unmarshal(out, &pretty)
	fmt.Println(pretty["data"])
}
import java.net.URI;
import java.net.http.*;

public class EduLab {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");

  static String call(String method, String path, String body) throws Exception {
    HttpRequest.BodyPublisher pub = body == null
        ? HttpRequest.BodyPublishers.noBody()
        : HttpRequest.BodyPublishers.ofString(body);
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Authorization", "Bearer " + TOKEN)
        .method(method, pub);
    if (body != null) b.header("Content-Type", "application/json");
    return HttpClient.newHttpClient()
        .send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
  }

  public static void main(String[] args) throws Exception {
    System.out.println(call("GET", "/me", null));
  }
}
require 'net/http'
require 'json'

BASE = URI('https://api.skillsafe.ai/v1/app-api')
TOKEN = ENV.fetch('SKILLSAFE_TOKEN', 'YOUR_TOKEN')

def call(method, path, body = nil)
  uri = URI(BASE.to_s + path)
  klass = { 'GET' => Net::HTTP::Get, 'POST' => Net::HTTP::Post }[method]
  req = klass.new(uri)
  req['Authorization'] = "Bearer #{TOKEN}"
  if body
    req['Content-Type'] = 'application/json'
    req.body = JSON.dump(body)
  end
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  JSON.parse(res.body)['data']
end

pp call('GET', '/me')
<?php
$base  = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";

function call($method, $path, $body = null) {
    global $base, $token;
    $headers = ["Authorization: Bearer $token"];
    if ($body !== null) $headers[] = "Content-Type: application/json";
    $ch = curl_init($base . $path);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $out = curl_exec($ch);
    curl_close($ch);
    return json_decode($out, true)["data"];
}

print_r(call("GET", "/me"));
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;

var baseUrl = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var res = await http.GetAsync(baseUrl + "/me");
Console.WriteLine(await res.Content.ReadAsStringAsync());

3 · Price the run — free, and it creates no job

POST /estimate takes the same input object /run takes and returns what the run would reserve. Nothing is charged and no job is created. hold_credits is a reservation sized for the full output cap, not the price — the settled charged_credits is normally much lower.

{
  "data": {
    "model": "gpt-5.6-terra",
    "model_alias": "gpt-terra",
    "markup_bps": 1000,
    "hold_credits": 2940,
    "min_credits": 420
  }
}

If your balance sits between min_credits and hold_credits the run still executes with a reduced output cap and comes back with "truncated": true — treat that as a partial lesson, not a complete one.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fields": {"problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.", "mode": "auto", "language": "auto", "depth": "standard"}, "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."}'
import os, json, urllib.request

TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
BASE = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    if data: req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]

body = {
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
}
out = call("POST", "/estimate", body)
print(json.dumps(out, indent=2, ensure_ascii=False))
const TOKEN = "YOUR_TOKEN";           // see step 1
const BASE = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Authorization": "Bearer " + TOKEN,
      ...(body ? { "Content-Type": "application/json" } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.code + ": " + json.error?.message);
  return json.data;
}

const body = {
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
};
const out = await call("POST", "/estimate", body);
console.log(out);
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

const base = "https://api.skillsafe.ai/v1/app-api"

func call(method, path string, body []byte) ([]byte, error) {
	var r io.Reader
	if body != nil {
		r = bytes.NewReader(body)
	}
	req, _ := http.NewRequest(method, base+path, r)
	token := os.Getenv("SKILLSAFE_TOKEN")
	req.Header.Set("Authorization", "Bearer "+token)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	return io.ReadAll(res.Body)
}

func main() {
	body := []byte(`{"fields": {"problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.", "mode": "auto", "language": "auto", "depth": "standard"}, "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."}`)
	out, err := call("POST", "/estimate", body)
	if err != nil {
		panic(err)
	}
	var pretty map[string]any
	json.Unmarshal(out, &pretty)
	fmt.Println(pretty["data"])
}
import java.net.URI;
import java.net.http.*;

public class EduLab {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");

  static String call(String method, String path, String body) throws Exception {
    HttpRequest.BodyPublisher pub = body == null
        ? HttpRequest.BodyPublishers.noBody()
        : HttpRequest.BodyPublishers.ofString(body);
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Authorization", "Bearer " + TOKEN)
        .method(method, pub);
    if (body != null) b.header("Content-Type", "application/json");
    return HttpClient.newHttpClient()
        .send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
  }

  public static void main(String[] args) throws Exception {
    String body = """
    {
      "fields": {
        "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
        "mode": "auto",
        "language": "auto",
        "depth": "standard"
      },
      "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
    }
    """;
    System.out.println(call("POST", "/estimate", body));
  }
}
require 'net/http'
require 'json'

BASE = URI('https://api.skillsafe.ai/v1/app-api')
TOKEN = ENV.fetch('SKILLSAFE_TOKEN', 'YOUR_TOKEN')

def call(method, path, body = nil)
  uri = URI(BASE.to_s + path)
  klass = { 'GET' => Net::HTTP::Get, 'POST' => Net::HTTP::Post }[method]
  req = klass.new(uri)
  req['Authorization'] = "Bearer #{TOKEN}"
  if body
    req['Content-Type'] = 'application/json'
    req.body = JSON.dump(body)
  end
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  JSON.parse(res.body)['data']
end

body = JSON.parse(<<~JSON)
  {
    "fields": {
      "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
      "mode": "auto",
      "language": "auto",
      "depth": "standard"
    },
    "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
  }
JSON
pp call('POST', '/estimate', body)
<?php
$base  = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";

function call($method, $path, $body = null) {
    global $base, $token;
    $headers = ["Authorization: Bearer $token"];
    if ($body !== null) $headers[] = "Content-Type: application/json";
    $ch = curl_init($base . $path);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $out = curl_exec($ch);
    curl_close($ch);
    return json_decode($out, true)["data"];
}

$body = json_decode(<<<JSON
{
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
}
JSON, true);
print_r(call("POST", "/estimate", $body));
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;

var baseUrl = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var body = @"{""fields"": {""problem"": ""In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB."", ""mode"": ""auto"", ""language"": ""auto"", ""depth"": ""standard""}, ""instruction"": ""Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }.""}";
var content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await http.PostAsync(baseUrl + "/estimate", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());

4 · Run it

POST /run takes the input object directly — not wrapped in {"input": …}. Send an Idempotency-Key header with a hash of your input: a retry after a dropped connection then replays the same job instead of billing a second one.

The reply's output is the model's text, which for this app is the lesson JSON object described in the contract below. Parse it, then render or validate it however you like.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fields": {"problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.", "mode": "auto", "language": "auto", "depth": "standard"}, "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."}'
import os, json, urllib.request

TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
BASE = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    if data: req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]

body = {
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
}
out = call("POST", "/run", body)
print(json.dumps(out, indent=2, ensure_ascii=False))
const TOKEN = "YOUR_TOKEN";           // see step 1
const BASE = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Authorization": "Bearer " + TOKEN,
      ...(body ? { "Content-Type": "application/json" } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.code + ": " + json.error?.message);
  return json.data;
}

const body = {
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
};
const out = await call("POST", "/run", body);
console.log(out);
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

const base = "https://api.skillsafe.ai/v1/app-api"

func call(method, path string, body []byte) ([]byte, error) {
	var r io.Reader
	if body != nil {
		r = bytes.NewReader(body)
	}
	req, _ := http.NewRequest(method, base+path, r)
	token := os.Getenv("SKILLSAFE_TOKEN")
	req.Header.Set("Authorization", "Bearer "+token)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	return io.ReadAll(res.Body)
}

func main() {
	body := []byte(`{"fields": {"problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.", "mode": "auto", "language": "auto", "depth": "standard"}, "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."}`)
	out, err := call("POST", "/run", body)
	if err != nil {
		panic(err)
	}
	var pretty map[string]any
	json.Unmarshal(out, &pretty)
	fmt.Println(pretty["data"])
}
import java.net.URI;
import java.net.http.*;

public class EduLab {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");

  static String call(String method, String path, String body) throws Exception {
    HttpRequest.BodyPublisher pub = body == null
        ? HttpRequest.BodyPublishers.noBody()
        : HttpRequest.BodyPublishers.ofString(body);
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Authorization", "Bearer " + TOKEN)
        .method(method, pub);
    if (body != null) b.header("Content-Type", "application/json");
    return HttpClient.newHttpClient()
        .send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
  }

  public static void main(String[] args) throws Exception {
    String body = """
    {
      "fields": {
        "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
        "mode": "auto",
        "language": "auto",
        "depth": "standard"
      },
      "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
    }
    """;
    System.out.println(call("POST", "/run", body));
  }
}
require 'net/http'
require 'json'

BASE = URI('https://api.skillsafe.ai/v1/app-api')
TOKEN = ENV.fetch('SKILLSAFE_TOKEN', 'YOUR_TOKEN')

def call(method, path, body = nil)
  uri = URI(BASE.to_s + path)
  klass = { 'GET' => Net::HTTP::Get, 'POST' => Net::HTTP::Post }[method]
  req = klass.new(uri)
  req['Authorization'] = "Bearer #{TOKEN}"
  if body
    req['Content-Type'] = 'application/json'
    req.body = JSON.dump(body)
  end
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  JSON.parse(res.body)['data']
end

body = JSON.parse(<<~JSON)
  {
    "fields": {
      "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
      "mode": "auto",
      "language": "auto",
      "depth": "standard"
    },
    "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
  }
JSON
pp call('POST', '/run', body)
<?php
$base  = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";

function call($method, $path, $body = null) {
    global $base, $token;
    $headers = ["Authorization: Bearer $token"];
    if ($body !== null) $headers[] = "Content-Type: application/json";
    $ch = curl_init($base . $path);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $out = curl_exec($ch);
    curl_close($ch);
    return json_decode($out, true)["data"];
}

$body = json_decode(<<<JSON
{
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
}
JSON, true);
print_r(call("POST", "/run", $body));
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;

var baseUrl = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var body = @"{""fields"": {""problem"": ""In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB."", ""mode"": ""auto"", ""language"": ""auto"", ""depth"": ""standard""}, ""instruction"": ""Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }.""}";
var content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await http.PostAsync(baseUrl + "/run", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());

5 · Stream it

POST /run-stream is the same request as /run and returns text/event-stream. Deltas arrive as they are generated, which is how the web app advances its progress card — it watches for the "answer", "scene" and "steps" keys appearing in the buffer. A stream that dies mid-object still leaves you a partial JSON body worth salvaging.

event: delta
data: {"text": "{\"mode\":\"solid\","}

event: done
data: {"charged_credits": 812, "truncated": false}
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fields": {"problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.", "mode": "auto", "language": "auto", "depth": "standard"}, "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."}'
import os, json, urllib.request

TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
BASE = "https://api.skillsafe.ai/v1/app-api"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    if data: req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]

body = {
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
}
out = call("POST", "/run-stream", body)
print(json.dumps(out, indent=2, ensure_ascii=False))
const TOKEN = "YOUR_TOKEN";           // see step 1
const BASE = "https://api.skillsafe.ai/v1/app-api";

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Authorization": "Bearer " + TOKEN,
      ...(body ? { "Content-Type": "application/json" } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.code + ": " + json.error?.message);
  return json.data;
}

const body = {
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
};
const out = await call("POST", "/run-stream", body);
console.log(out);
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

const base = "https://api.skillsafe.ai/v1/app-api"

func call(method, path string, body []byte) ([]byte, error) {
	var r io.Reader
	if body != nil {
		r = bytes.NewReader(body)
	}
	req, _ := http.NewRequest(method, base+path, r)
	token := os.Getenv("SKILLSAFE_TOKEN")
	req.Header.Set("Authorization", "Bearer "+token)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	return io.ReadAll(res.Body)
}

func main() {
	body := []byte(`{"fields": {"problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.", "mode": "auto", "language": "auto", "depth": "standard"}, "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."}`)
	out, err := call("POST", "/run-stream", body)
	if err != nil {
		panic(err)
	}
	var pretty map[string]any
	json.Unmarshal(out, &pretty)
	fmt.Println(pretty["data"])
}
import java.net.URI;
import java.net.http.*;

public class EduLab {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");

  static String call(String method, String path, String body) throws Exception {
    HttpRequest.BodyPublisher pub = body == null
        ? HttpRequest.BodyPublishers.noBody()
        : HttpRequest.BodyPublishers.ofString(body);
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Authorization", "Bearer " + TOKEN)
        .method(method, pub);
    if (body != null) b.header("Content-Type", "application/json");
    return HttpClient.newHttpClient()
        .send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
  }

  public static void main(String[] args) throws Exception {
    String body = """
    {
      "fields": {
        "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
        "mode": "auto",
        "language": "auto",
        "depth": "standard"
      },
      "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
    }
    """;
    System.out.println(call("POST", "/run-stream", body));
  }
}
require 'net/http'
require 'json'

BASE = URI('https://api.skillsafe.ai/v1/app-api')
TOKEN = ENV.fetch('SKILLSAFE_TOKEN', 'YOUR_TOKEN')

def call(method, path, body = nil)
  uri = URI(BASE.to_s + path)
  klass = { 'GET' => Net::HTTP::Get, 'POST' => Net::HTTP::Post }[method]
  req = klass.new(uri)
  req['Authorization'] = "Bearer #{TOKEN}"
  if body
    req['Content-Type'] = 'application/json'
    req.body = JSON.dump(body)
  end
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  JSON.parse(res.body)['data']
end

body = JSON.parse(<<~JSON)
  {
    "fields": {
      "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
      "mode": "auto",
      "language": "auto",
      "depth": "standard"
    },
    "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
  }
JSON
pp call('POST', '/run-stream', body)
<?php
$base  = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";

function call($method, $path, $body = null) {
    global $base, $token;
    $headers = ["Authorization: Bearer $token"];
    if ($body !== null) $headers[] = "Content-Type: application/json";
    $ch = curl_init($base . $path);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $out = curl_exec($ch);
    curl_close($ch);
    return json_decode($out, true)["data"];
}

$body = json_decode(<<<JSON
{
  "fields": {
    "problem": "In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB.",
    "mode": "auto",
    "language": "auto",
    "depth": "standard"
  },
  "instruction": "Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }."
}
JSON, true);
print_r(call("POST", "/run-stream", $body));
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;

var baseUrl = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var body = @"{""fields"": {""problem"": ""In the square pyramid P-ABCD the base is a square of side 2, PA is perpendicular to the base and PA = 2. Find the angle between PC and the plane PAB."", ""mode"": ""auto"", ""language"": ""auto"", ""depth"": ""standard""}, ""instruction"": ""Turn the problem in fields into a lesson object. Follow your system instructions exactly. Reply with ONLY the strict JSON lesson object - the first character of the reply is { and the last is }.""}";
var content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await http.PostAsync(baseUrl + "/run-stream", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());

6 · The lesson object

The model replies with one JSON object. These are the fields the app's renderer actually reads — taken from the parser, not from intent. Unknown keys are dropped; a missing required key degrades that part of the page rather than breaking it.

FieldTypeMeaning
modestringsolid, analytic, chem, or none when the problem cannot be turned into a lesson
languagestringen or zh
titlestringlesson title
problemstringthe problem restated in the answer language
given[]{label, value}known conditions, both LaTeX
answer{latex, value, unit, text}value is the machine-comparable number; null when the answer is an interval or an equation
steps[]{title, content, highlight[], camera, frame}content is prose with $…$ maths; highlight names scene ids; camera is a 3D position; frame is 0–1 reaction progress
sceneobjectmode-specific figure data — points/edges/faces for solid, conic + build ops + readouts for analytic, atoms + bond lists for chem
verifyobjectthe claim a client re-derives from scene alone
notesstringassumptions and conventions the model chose

The verify block

This is the part worth wiring into your own pipeline. It states, in machine-checkable terms, what the lesson claims — so you can confirm the figure supports the answer instead of trusting it:

modeverify.kindre-derive it by
solidline_plane_angle, dihedral_angle, skew_angle, point_plane_distance, length, volumecomputing it from scene.points and comparing with verify.expect
analyticrange, constantsweeping scene.param across its range, evaluating the named readout, comparing the observed min/max
chembalanceparsing every formula, multiplying by coeff, and comparing element totals on both sides — then against the atoms inventory

The web app runs exactly these checks in the browser and shows disagreements on its Check tab. If you build on this API, do the same — it is the cheapest quality gate available and it costs nothing per lesson.

7 · Errors

HTTPcodewhat to do
401unauthorizedtoken missing, expired or for another app — mint a new one on the token page
402insufficient_creditsbalance below min_credits; top up. Between min and hold the run succeeds but is truncated
404not_foundalmost always a wrong path — there is no /apps/{slug}/ segment on these routes
409idempotency_conflictthe same key was reused with a different body; change one or the other
429rate_limitedback off and retry with a delay
503upstream_unavailablethe model is briefly unreachable; retry the same idempotency key

Replaying a lesson without spending anything

A saved lesson object is a complete, self-contained record: drop the .json file onto the problem box in the app and it re-renders — the figure, the steps and the browser-side check — with no run and no charge. That makes the API output archivable: store the object, not a screenshot.