Notes to Quiz API
The app numbers your notes in the browser (L1: ...) and sends those lines with the counts you want. The API takes the same input and returns the deck as JSON Lines: a header line, one line per item, and an end line. Every item names the line numbers it comes from and quotes one of them word for word, so you can check it.
Base URL: https://api.skillsafe.ai/v1/app-api. Every response is {"data": ...} on success or {"error": {"code", "message"}} on failure. The request body is the input object - do not wrap it in {"input": ...}.
Input
| Field | Type | Notes |
|---|---|---|
notes | string, required | The notes, one numbered line per row: L12: text. Mark cut stretches with a row like [... lines 80-140 not sent ...]. Keep it under about 24,000 characters. |
n_mcq, n_short, n_cloze, n_cards | number, required | How many multiple-choice, short-answer, fill-the-blank and flashcard items to write (0 for none). The app caps them at 20, 15, 15 and 30. |
difficulty | string, required | recall, mixed (default) or deep (relationships, causes, applying a rule). |
source_name | string | File or title the notes came from. Context only. |
focus_lines | string | Optional ranges such as L12-L20, L41; two thirds of the items then come from those lines (the app's "drill my weak spots"). |
avoid | string | Optional. Questions you already have, one per line; the deck will not repeat them (the app's "make more"). |
retry_note | string | Optional. The app sets it only on its one automatic reformat retry. |
Output
The finished job's output.output is JSON Lines, one object per line:
{"kind":"deck","title":"Cell energy","topics":["Cell energy"],"note":""}
{"kind":"mcq","topic":"Cell energy","q":"Where does glycolysis take place?","choices":["Mitochondria","Cytoplasm","Nucleus","Cell membrane"],"answer":1,"why":"...","lines":[3],"quote":"Glycolysis happens in the cytoplasm"}
{"kind":"short","topic":"Cell energy","q":"How many ATP does glycolysis yield per glucose?","answer":"2 ATP","accept":["2","two"],"why":"...","lines":[3],"quote":"yields 2 ATP per glucose"}
{"kind":"cloze","topic":"Cell energy","text":"Mitochondria make ATP by {{aerobic respiration}}.","answer":"aerobic respiration","why":"...","lines":[2],"quote":"..."}
{"kind":"card","topic":"Cell energy","front":"What do mitochondria make?","back":"ATP, by aerobic respiration.","lines":[2],"quote":"..."}
{"kind":"end","count":4}
answer on a multiple-choice item is the 0-based index of the right choice. A reply without the end line was cut short; every complete line before it is still a usable item. Check each quote against its line before you trust an item - the app marks each one verified, close or not found.
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object. |
| 401 | UNAUTHORIZED | Missing or expired token. |
| 402 | INSUFFICIENT_CREDITS | Balance below the run's minimum. Top up at skillsafe.ai. |
| 403 | FORBIDDEN | A guest token tried to run. Sign in for a personal token. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry. |
1. A tiny client
# Every call: Authorization: Bearer $TOKEN, JSON in, {"data": ...} or {"error": ...} out.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(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={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json", **(headers or {})})
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error ? json.error.code + ": " + json.error.message : res.status);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
func call(method, path string, body any, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct{ Data json.RawMessage `json:"data"` }
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
return json.Unmarshal(env.Data, out)
}
import java.net.URI;
import java.net.http.*;
public class Api {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient http = HttpClient.newHttpClient();
static String call(String method, String path, String json) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
b = json == null ? b.method(method, HttpRequest.BodyPublishers.noBody())
: b.method(method, HttpRequest.BodyPublishers.ofString(json));
return http.send(b.build(), HttpResponse.BodyHandlers.ofString()).body(); // {"data": ...}
}
}
require "net/http"
require "json"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
headers.each { |k, v| req[k] = v }
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call($method, $path, $body = null, $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge(["Authorization: Bearer " . TOKEN, "Content-Type: application/json"], $headers),
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$out = json_decode(curl_exec($ch), true);
return $out["data"] ?? $out;
}
using System.Net.Http;
using System.Text;
using System.Text.Json;
var http = new HttpClient { BaseAddress = new Uri("https://api.skillsafe.ai/v1/app-api/") };
http.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_TOKEN");
async Task<JsonElement> Call(HttpMethod m, string path, object? body = null) {
var req = new HttpRequestMessage(m, path.TrimStart('/'));
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("data").Clone();
}
2. Get a token
Guests can price a run; running needs a personal token. The easiest way to get one is the token page.
# A guest token (enough for /me and /estimate). A run needs a personal token:
# sign in on https://notes-to-quiz.skillsafe.ai/tokens.html and copy it from there.
curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"notes-to-quiz"}'
guest = json.load(urllib.request.urlopen(urllib.request.Request(
BASE + "/guest", data=json.dumps({"slug": "notes-to-quiz"}).encode(),
headers={"Content-Type": "application/json"})))["data"]
print(guest["token"]) # guest: /me and /estimate only. Personal token: tokens.html
const g = await fetch(BASE + "/guest", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "notes-to-quiz" }),
}).then((r) => r.json());
console.log(g.data.token); // personal token for runs: tokens.html
// POST {"slug":"notes-to-quiz"} to base+"/guest" without a token; read data.token.
// For runs, copy a personal token from https://notes-to-quiz.skillsafe.ai/tokens.html
// POST {"slug":"notes-to-quiz"} to BASE + "/guest" (no Authorization header) and read data.token.
// Runs need a personal token from https://notes-to-quiz.skillsafe.ai/tokens.html
uri = URI(BASE + "/guest")
res = Net::HTTP.post(uri, { slug: "notes-to-quiz" }.to_json, "Content-Type" => "application/json")
puts JSON.parse(res.body)["data"]["token"]
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode(["slug" => "notes-to-quiz"])]);
echo json_decode(curl_exec($ch), true)["data"]["token"];
var g = await new HttpClient().PostAsync("https://api.skillsafe.ai/v1/app-api/guest",
new StringContent("{\"slug\":\"notes-to-quiz\"}", Encoding.UTF8, "application/json"));
Console.WriteLine(await g.Content.ReadAsStringAsync()); // data.token
3. Who am I, and my balance
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
var me map[string]any
call("GET", "/me", nil, &me)
System.out.println(Api.call("GET", "/me", null));
me = call("GET", "/me")
puts me["credits"]
$me = call("GET", "/me");
echo $me["credits"];
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me);
4. Price a deck (free)
hold_credits is the most a run can cost, reserved up front; you are charged only what it uses. 10,000 credits = $1.
curl -s -X POST "$BASE/estimate" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"notes": "L1: ## Cell energy\nL2: Mitochondria make ATP by aerobic respiration.\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.", "source_name": "biology-notes.md", "n_mcq": 1, "n_short": 1, "n_cloze": 1, "n_cards": 1, "difficulty": "mixed"}'
body = {
"notes": "L1: ## Cell energy\nL2: Mitochondria make ATP by aerobic respiration.\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.",
"source_name": "biology-notes.md",
"n_mcq": 1,
"n_short": 1,
"n_cloze": 1,
"n_cards": 1,
"difficulty": "mixed"
}
est = call("POST", "/estimate", body)
print(est["hold_credits"], est["model_alias"], est.get("warnings"))
const body = {
"notes": "L1: ## Cell energy\nL2: Mitochondria make ATP by aerobic respiration.\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.",
"source_name": "biology-notes.md",
"n_mcq": 1,
"n_short": 1,
"n_cloze": 1,
"n_cards": 1,
"difficulty": "mixed"
};
const est = await call("POST", "/estimate", body);
console.log(est.hold_credits, est.model_alias, est.warnings);
body := map[string]any{
"notes": "L1: ## Cell energy\nL2: Mitochondria make ATP by aerobic respiration.\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.",
"source_name": "biology-notes.md",
"n_mcq": 1, "n_short": 1, "n_cloze": 1, "n_cards": 1,
"difficulty": "mixed",
}
var est map[string]any
call("POST", "/estimate", body, &est)
String body = "{\"notes\": \"L1: ## Cell energy\\nL2: Mitochondria make ATP by aerobic respiration.\\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.\", \"source_name\": \"biology-notes.md\", \"n_mcq\": 1, \"n_short\": 1, \"n_cloze\": 1, \"n_cards\": 1, \"difficulty\": \"mixed\"}";
System.out.println(Api.call("POST", "/estimate", body));
body = JSON.parse("{\"notes\": \"L1: ## Cell energy\\nL2: Mitochondria make ATP by aerobic respiration.\\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.\", \"source_name\": \"biology-notes.md\", \"n_mcq\": 1, \"n_short\": 1, \"n_cloze\": 1, \"n_cards\": 1, \"difficulty\": \"mixed\"}")
est = call("POST", "/estimate", body)
puts est["hold_credits"]
$body = json_decode("{\"notes\": \"L1: ## Cell energy\\nL2: Mitochondria make ATP by aerobic respiration.\\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.\", \"source_name\": \"biology-notes.md\", \"n_mcq\": 1, \"n_short\": 1, \"n_cloze\": 1, \"n_cards\": 1, \"difficulty\": \"mixed\"}", true);
$est = call("POST", "/estimate", $body);
echo $est["hold_credits"];
var body = JsonSerializer.Deserialize<JsonElement>("{\"notes\": \"L1: ## Cell energy\\nL2: Mitochondria make ATP by aerobic respiration.\\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.\", \"source_name\": \"biology-notes.md\", \"n_mcq\": 1, \"n_short\": 1, \"n_cloze\": 1, \"n_cards\": 1, \"difficulty\": \"mixed\"}");
var est = await Call(HttpMethod.Post, "/estimate", body);
5. Run it and poll
Always send an Idempotency-Key: a retry with the same key never bills twice.
curl -s -X POST "$BASE/run" -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: my-deck-001" \
-H "Content-Type: application/json" -d '{"notes": "L1: ## Cell energy\nL2: Mitochondria make ATP by aerobic respiration.\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.", "source_name": "biology-notes.md", "n_mcq": 1, "n_short": 1, "n_cloze": 1, "n_cards": 1, "difficulty": "mixed"}'
# -> data.job_id; then poll until status is succeeded or failed:
curl -s "$BASE/jobs/JOB_ID" -H "Authorization: Bearer $TOKEN"
import time
job = call("POST", "/run", body, {"Idempotency-Key": "my-deck-001"})
while job.get("status") not in ("succeeded", "failed"):
time.sleep(1)
job = call("GET", "/jobs/" + job["job_id"])
print(job["output"]["output"]) # JSON Lines: deck, items, end
let job = await call("POST", "/run", body, { "Idempotency-Key": "my-deck-001" });
while (!["succeeded", "failed"].includes(job.status)) {
await new Promise((r) => setTimeout(r, 1000));
job = await call("GET", "/jobs/" + job.job_id);
}
console.log(job.output.output);
var job map[string]any
call("POST", "/run", body, &job) // set Idempotency-Key on the request too
for job["status"] != "succeeded" && job["status"] != "failed" {
time.Sleep(time.Second)
call("GET", "/jobs/"+job["job_id"].(string), nil, &job)
}
String job = Api.call("POST", "/run", body); // add header Idempotency-Key
// read data.job_id, then GET /jobs/{job_id} every second until status is succeeded or failed
job = call("POST", "/run", body, "Idempotency-Key" => "my-deck-001")
until %w[succeeded failed].include?(job["status"])
sleep 1
job = call("GET", "/jobs/#{job['job_id']}")
end
puts job["output"]["output"]
$job = call("POST", "/run", $body, ["Idempotency-Key: my-deck-001"]);
while (!in_array($job["status"], ["succeeded", "failed"])) {
sleep(1);
$job = call("GET", "/jobs/" . $job["job_id"]);
}
echo $job["output"]["output"];
var job = await Call(HttpMethod.Post, "/run", body); // add Idempotency-Key header
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed")) {
await Task.Delay(1000);
job = await Call(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
}
6. Or stream it
curl -N -X POST "$BASE/run-stream" -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: my-deck-002" \
-H "Content-Type: application/json" -d '{"notes": "L1: ## Cell energy\nL2: Mitochondria make ATP by aerobic respiration.\nL3: Glycolysis happens in the cytoplasm and yields 2 ATP per glucose.", "source_name": "biology-notes.md", "n_mcq": 1, "n_short": 1, "n_cloze": 1, "n_cards": 1, "difficulty": "mixed"}'
# event: job / event: delta {"text":...} / event: done {charged_credits, truncated, output}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(body).encode(),
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json", "Idempotency-Key": "my-deck-002"})
with urllib.request.urlopen(req) as r:
for line in r:
print(line.decode().rstrip()) # event: / data: lines
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json", "Idempotency-Key": "my-deck-002" },
body: JSON.stringify(body),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
console.log(dec.decode(value)); // frames split on a blank line
}
// POST to base+"/run-stream" with the same headers plus Idempotency-Key,
// then read res.Body with bufio.Scanner: lines are "event: ..." and "data: {...}".
// POST to BASE + "/run-stream" and read HttpResponse.BodyHandlers.ofLines():
// "event: delta" lines carry data: {"text": ...}; "event: done" carries the final job.
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json", "Idempotency-Key" => "my-deck-002")
req.body = body.to_json
Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) { |res| res.read_body { |c| print c } } }
// Use CURLOPT_WRITEFUNCTION on a POST to BASE . "/run-stream" to receive SSE frames as they arrive.
var req = new HttpRequestMessage(HttpMethod.Post, "run-stream") {
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") };
req.Headers.Add("Idempotency-Key", "my-deck-002");
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await sr.ReadLineAsync() is string line) Console.WriteLine(line);