Drive Cue Scroll from your own code
Everything the page asks the coach is available over HTTP. A natural use is a set of talks kept as text - a weekly sermon, a lesson series, a stack of conference talks - where a script sends each one with its target time, gets back where to pause, breathe and lean on a word, applies the cuts it accepts, and after a rehearsal sends only the paragraphs the speaker still rushes or drags.
One thing to be clear about first: the model never times anything and never rewrites the speech. The caller splits the script into paragraphs, counts the words and computes the planned seconds at the chosen pace (pauses and slowed words included), and measures the rehearsal. The model returns marks, cuts and advice; the caller checks every paragraph id, every word of every mark-up and every cut against its own script and recounts the time before applying anything. The page's timing and checking code ships as a plain script you can load in node — see the end of the examples.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"cue-scroll"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.
Errors:
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: coaching is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
One system prompt serves two tasks (lanes). task picks the contract; a missing or unknown task makes the model do mark and say so in the reply's lane_note.
| task | When | Extra fields | Worked example |
|---|---|---|---|
mark | Mark up every paragraph sent in full, name the arrow (the one sentence the audience must remember), write how to open and close, and propose verbatim trims when planned_seconds is over target_seconds. With a rehearsal, also pacing advice for each rushed or dragged paragraph. | Optional rehearsal. | toast (over target, trims), lesson (fits, slides) |
remark | After a rehearsal: re-mark only the targets, one pacing entry per target, no trims unless the plan is still more than 10% over the target, and a one-line open/close reminder unless a target is the first or last paragraph. | targets (required for this task) and rehearsal. | eulogy |
Every run body is one JSON object of scalar fields: strings, numbers and booleans only, so the two structured fields travel as JSON-encoded strings (encode the array, then send the resulting string as the field's value). The page always sends every field below except retry_note.
| Field | What it holds |
|---|---|
task | "mark" or "remark". |
title | The speech's title, up to 120 characters ("Untitled speech" when empty). |
occasion | One of toast, eulogy, keynote, lesson, sermon, presentation, pitch, other. |
target_seconds | Number: the time the speech must hit (the page allows 15 to 7200). |
wpm | Number: the speaking pace, 80 to 220 words per minute. |
slides | Boolean: true when they speak with slides, so the reply places [cue: slide ...] marks where slides change. |
planned_seconds | Number: the whole script at wpm, computed by the caller with existing marks counted (see the timings below). |
paragraphs | JSON-encoded string: [{"id":"p1","words":42,"secs":18.0,"full":true,"text":"..."}]. Ids are p1..pn in order and are the only ids the reply may use. text may already carry marks. Over 16,000 characters of script, the first two, the last two, the targets and the rushed or dragged paragraphs stay full and the rest are cut to their first 30 words, ending […N more words], with "full":false: those get no marks and no trims. |
rehearsal | "", or a JSON-encoded string: [{"id":"p3","planned_secs":20.1,"actual_secs":14.2,"pace":"rush"}]. The page calls a paragraph rush under 0.88 of its planned time and drag over 1.12 (and at least one second off), else ok. |
targets | remark only: comma-separated ids, "p2,p5,p7"; "" for mark. |
clipped | "", or a note on what was shortened before sending. |
retry_note | Sent only on a reformat retry, when the previous reply was not one valid JSON object: says what was wrong. Leave it out otherwise. |
The mark grammar
The only marks the app understands, written into the paragraph's own text as separate tokens between words. The same grammar is accepted in the text you send and comes back in each marked.
| Mark | Meaning | Planned time |
|---|---|---|
/ | A short pause, a beat: and then / she laughed. | +0.6 s |
// | A long pause: around the arrow, after a punchline, before the last line. | +1.6 s |
^ | A breath point inside a long sentence. | +0.4 s |
*words* | Emphasis: lean on one to four words, at most two per paragraph. | none |
[slow] | Slow down from here to the end of the sentence (the arrow, numbers, names, the close). | words about 18% longer |
[cue: text] | A stage or slide cue said by nobody: [cue: raise glass], [cue: slide 4 - the map]. | none |
The reply
The reply text is one JSON object, every key present even when empty:
{ "task": "mark|remark", "lane_note": "",
"arrow": "the one sentence the audience must remember, from the script",
"marks": [{ "id": "p1", "marked": "the paragraph's exact words with marks inserted", "note": "why these marks" }],
"trims": [{ "id": "p4", "cut": "an exact, contiguous run of words from that paragraph", "words_saved": 23, "reason": "why it can go" }],
"pacing": [{ "id": "p3", "issue": "rush|drag|ok", "advice": "one concrete instruction" }],
"open": "how to open", "close": "how to land the close",
"seconds_saved": 31 }
trims are ordered from the least costly cut to the most, and are [] with seconds_saved 0 when the script already fits. A cut never removes the arrow or the last paragraph's final sentence.
Check before you apply. This is what the page does with every reply:
- Ids exist. A mark, trim or pacing entry for an id that is not in the script is dropped; a second mark-up for the same id is ignored; a mark-up for a paragraph sent shortened is not applied; in
remark, a mark-up outsidetargetsis not applied. Missing mark-ups and missing pacing entries for targets are listed. - Wording unchanged. The words of
marked(marks stripped) are aligned with the paragraph's own words. If fewer than 85% of the words survive, or the mark-up is much longer than the paragraph, the model rewrote it and the marks are refused. If a few words differ, the person's wording is kept and the marks are transferred onto it. - Cuts verbatim. Each
cutmust appear in its paragraph word for word (case and punctuation aside), at least three words long and not overlapping another cut. Cuts of more than half a paragraph, or touching the closing line, are flagged. - Words and seconds recounted. The page counts each cut's words and times it at
wpmitself, notes wherewords_savedorseconds_savedwas off, and reports the plan after the trims. A pacingissuethat disagrees with the measured rehearsal is replaced by the measurement.
Worked examples
These are the bundled examples on the page: the exact run body the page builds and a reply produced against the shipped prompt (the page replays the same replies for free). One example per lane: two for mark, one for remark.
mark: a best man's toast that runs 23 seconds over its target
Input (planned_seconds 158 against target_seconds 135, so the reply must trim; no rehearsal yet):
{
"task": "mark",
"title": "Best man toast for Tom and Priya",
"occasion": "toast",
"target_seconds": 135,
"wpm": 140,
"slides": false,
"planned_seconds": 158,
"paragraphs": "(a JSON string - decoded below)",
"rehearsal": "",
"targets": "",
"clipped": ""
}
paragraphs, decoded (send it re-encoded as one string):
[
{
"id": "p1",
"words": 45,
"secs": 19.3,
"full": true,
"text": "Good evening, everyone. For those who don't know me, I'm Sam, and I have had the honour of being Tom's best friend for twenty-two years, which, as Priya pointed out this morning, is longer than some of the wine on these tables has been alive."
},
{
"id": "p2",
"words": 42,
"secs": 18,
"full": true,
"text": "Before I start, I want to thank Priya's parents, Anil and Meera, for this wonderful evening, and Tom's mum, Linda, who has been calling me every day for three weeks to make sure this speech is clean. Linda, it is mostly clean."
},
{
"id": "p3",
"words": 66,
"secs": 28.3,
"full": true,
"text": "Tom and I met when we were ten. We decided to build a raft out of fence panels and a paddling pool and sail it across Miller's pond. It sank in about four seconds. I remember standing in the water up to my chest, and Tom, completely soaked, turned to me and said, very seriously, that the design was fine and the pond was the problem."
},
{
"id": "p4",
"words": 56,
"secs": 24,
"full": true,
"text": "That is Tom. He has never once in his life admitted that a plan was bad. He just finds a different pond. He did it with his first business, he did it with the time he tried to learn the bagpipes, and he did it, I am told, with three separate attempts at a sourdough starter."
},
{
"id": "p5",
"words": 65,
"secs": 27.9,
"full": true,
"text": "And then, four years ago, at a pub quiz in Leeds, he met Priya. Now, Tom is a quiz machine. He has not lost a pub quiz since 2014. But that night, when the question was what is the capital of France, he wrote down Lyon. On purpose. Because Priya's team was one point behind and he wanted an excuse to go over and apologise."
},
{
"id": "p6",
"words": 55,
"secs": 23.6,
"full": true,
"text": "Priya told me later that she knew exactly what he was doing from the very first question. Nobody gets Paris wrong. She just thought it was sweet. And that, I think, is the whole marriage in one sentence: he makes a grand, slightly ridiculous gesture, and she sees straight through it and loves him anyway."
},
{
"id": "p7",
"words": 30,
"secs": 12.9,
"full": true,
"text": "Tom, you have always said the design was fine and the pond was the problem. Well, today you found the right pond. Priya, thank you for seeing straight through him."
},
{
"id": "p8",
"words": 10,
"secs": 4.3,
"full": true,
"text": "So please, everyone, raise your glasses. To Tom and Priya."
}
]
Reply:
{
"task": "mark",
"lane_note": "",
"arrow": "He makes a grand, slightly ridiculous gesture, and she sees straight through it and loves him anyway.",
"marks": [
{
"id": "p1",
"marked": "Good evening, everyone. / For those who don't know me, I'm Sam, and I have had the honour of being Tom's best friend for *twenty-two years*, which, as Priya pointed out this morning, is longer than some of the wine on these tables has been alive.",
"note": "One beat after the greeting so the room settles; lean on the twenty-two years."
},
{
"id": "p2",
"marked": "Before I start, I want to thank Priya's parents, Anil and Meera, for this wonderful evening, and Tom's mum, Linda, who has been calling me every day for three weeks to make sure this speech is clean. / Linda, it is mostly clean.",
"note": "A beat before the line to Linda lets the joke land; look at her as you say it."
},
{
"id": "p3",
"marked": "Tom and I met when we were ten. We decided to build a raft out of fence panels and a paddling pool and sail it across Miller's pond. / It sank in about four seconds. / I remember standing in the water up to my chest, and Tom, completely soaked, turned to me and said, very seriously, ^ that the design was fine and *the pond* was the problem.",
"note": "Pause around the sinking for the laugh, then breathe before Tom's line; it comes back at the end."
}
],
"trims": [
{
"id": "p1",
"cut": "For those who don't know me,",
"words_saved": 6,
"reason": "Throat-clearing; 'I'm Sam' does the same job."
},
{
"id": "p2",
"cut": "for this wonderful evening,",
"words_saved": 4,
"reason": "The thanks stands without the description."
},
{
"id": "p2",
"cut": "every day for three weeks",
"words_saved": 5,
"reason": "The joke works without the detail."
}
],
"pacing": [],
"open": "Stand still, find Tom and Priya, take one breath before speaking, then say 'Good evening, everyone' and pause before giving your name.",
"close": "After 'raise your glasses', raise yours and wait two seconds, then say 'To Tom and Priya' slowly and drink. Add nothing after it.",
"seconds_saved": 35.6
}
Shortened here: 5 more marks (p4, p5, p6, p7, p8); 6 more trims (p3, p5, p1, p3, p4, p6) left out. The page replays the whole reply.
mark: a lesson opening that fits, spoken with slides
Input (slides is true, so the reply puts a [cue: ...] where each slide changes; the script fits, so trims must be empty):
{
"task": "mark",
"title": "Why does the Moon change shape?",
"occasion": "lesson",
"target_seconds": 135,
"wpm": 135,
"slides": true,
"planned_seconds": 119,
"paragraphs": "(a JSON string - decoded below)",
"rehearsal": "",
"targets": "",
"clipped": ""
}
paragraphs, decoded (send it re-encoded as one string):
[
{
"id": "p1",
"words": 46,
"secs": 20.4,
"full": true,
"text": "Hands up if you have ever looked at the Moon and thought it was following you home. Keep them up if you have ever noticed it is a different shape on different nights. Right. Nearly all of you. Today we are going to work out why."
},
{
"id": "p2",
"words": 44,
"secs": 19.6,
"full": true,
"text": "Here is the strange part. The Moon does not make any light of its own. Not one bit. Everything you have ever seen of the Moon is sunlight, bouncing off grey rock and travelling about four hundred thousand kilometres to land in your eye."
},
{
"id": "p3",
"words": 50,
"secs": 22.2,
"full": true,
"text": "So at any moment, half of the Moon is lit by the Sun and half of it is in darkness, exactly like the Earth has a day side and a night side. That never changes. What changes is how much of the lit half we can see from down here."
},
{
"id": "p4",
"words": 74,
"secs": 32.9,
"full": true,
"text": "Look at this picture. The Moon goes around the Earth about once every twenty-nine and a half days. When it sits between us and the Sun, the lit half faces away from us and we see nothing. That is a new moon. When it is on the far side of us, we see the whole lit face. That is a full moon. Everything in between is a crescent, a half or a gibbous moon."
},
{
"id": "p5",
"words": 53,
"secs": 23.6,
"full": true,
"text": "In a minute you are going to prove this yourselves with a lamp, a ball on a stick and your own head. But first, one question to keep in your mind for the whole lesson: if the Moon were the same shape every night, what would that tell us about where it is?"
}
]
Reply:
{
"task": "mark",
"lane_note": "",
"arrow": "What changes is how much of the lit half we can see from down here.",
"marks": [
{
"id": "p1",
"marked": "[cue: slide 1 - the Moon at night] Hands up if you have ever looked at the Moon and thought it was following you home. / Keep them up if you have ever noticed it is a *different shape* on different nights. / Right. Nearly all of you. / Today we are going to work out *why*.",
"note": "Pause after each question so hands can go up, and land 'why' as the promise of the lesson."
},
{
"id": "p2",
"marked": "[cue: slide 2 - sunlight on the Moon] Here is the strange part. / The Moon does not make *any light* of its own. Not one bit. / Everything you have ever seen of the Moon is sunlight, ^ bouncing off grey rock and travelling [slow] about four hundred thousand kilometres to land in your eye.",
"note": "A beat after the surprise, a breath before the long sentence, and slow on the big number."
},
{
"id": "p3",
"marked": "[cue: slide 3 - lit half and dark half] So at any moment, half of the Moon is lit by the Sun and half of it is in darkness, ^ exactly like the Earth has a day side and a night side. / That *never changes*. // [slow] What changes is how much of the lit half *we can see* from down here. //",
"note": "This is the arrow: long pauses on both sides and slow delivery so it sticks."
}
],
"trims": [],
"pacing": [],
"open": "Look around the room, wait for quiet, take a two-second pause, then ask 'Hands up if you have ever looked at the Moon…' with your own hand raised.",
"close": "End on the question 'what would that tell us about where it is?', said slowly after a short pause on 'But first'; then stay silent for a few seconds and do not answer it or add 'okay, so'.",
"seconds_saved": 0
}
Shortened here: 2 more marks (p4, p5) left out. The page replays the whole reply.
remark: a eulogy after a rehearsal, re-marking the three paragraphs still rushed or dragged
Input (rehearsal holds the measured pace per paragraph; targets names the three to re-mark; p8 already carries a // mark):
{
"task": "remark",
"title": "For Grandma Rose",
"occasion": "eulogy",
"target_seconds": 210,
"wpm": 125,
"slides": false,
"planned_seconds": 194,
"paragraphs": "(a JSON string - decoded below)",
"rehearsal": "(a JSON string - decoded below)",
"targets": "p2,p5,p7",
"clipped": ""
}
paragraphs, decoded (send it re-encoded as one string):
[
{
"id": "p1",
"words": 25,
"secs": 12.6,
"full": true,
"text": "Thank you all for being here. / Grandma Rose would have counted every one of you, and then she would have asked whether you had eaten."
},
{
"id": "p2",
"words": 72,
"secs": 34.6,
"full": true,
"text": "She was born in 1931 in a two-room house in Cork, the youngest of seven, and she used to say she learned to talk early because otherwise nobody would have heard her. She came to Manchester at nineteen with one suitcase and the address of a cousin who, it turned out, had moved away the month before. She found work in a bakery that same week, and she stayed for thirty-one years."
},
{
"id": "p3",
"words": 42,
"secs": 20.2,
"full": true,
"text": "Most of you knew her kitchen. The radio was always on, the kettle was always nearly boiling, and there was always a tin with something in it. If you ever told her you were not hungry, she heard that as a challenge."
},
{
"id": "p4",
"words": 89,
"secs": 42.7,
"full": true,
"text": "But what I want to tell you about is a Tuesday in 1998. I was eleven, and I had been sent home from school for fighting. I expected her to be furious. Instead she sat me down, put a scone in front of me, and asked me what the other boy had said. When I told her, she was quiet for a long time. Then she said, you were right to be angry, and you were wrong to hit him, and both of those things are true at once."
},
{
"id": "p5",
"words": 56,
"secs": 26.9,
"full": true,
"text": "I have thought about that sentence more than almost anything anyone has ever said to me. You can be right and wrong at once. You can be angry and still choose what to do with it. She lived that way. She was fierce about fairness and gentle with people, and she never once confused the two."
},
{
"id": "p6",
"words": 60,
"secs": 28.8,
"full": true,
"text": "In the last few years her memory went, a little at a time. Some days she did not know my name. But she always knew whether I had eaten. And on one of her last good days she held my hand and told me I had turned out all right, which from Grandma Rose was the highest praise there is."
},
{
"id": "p7",
"words": 48,
"secs": 23,
"full": true,
"text": "So here is what I am going to try to do, and what I would ask of you. When you are angry, be angry honestly. When you choose what to do next, choose kindly. And if someone tells you they are not hungry, put the kettle on anyway."
},
{
"id": "p8",
"words": 8,
"secs": 5.4,
"full": true,
"text": "Thank you, Grandma. // We turned out all right."
}
]
rehearsal, decoded (send it re-encoded as one string):
[
{
"id": "p1",
"planned_secs": 12.6,
"actual_secs": 12.6,
"pace": "ok"
},
{
"id": "p2",
"planned_secs": 34.6,
"actual_secs": 24.9,
"pace": "rush"
},
{
"id": "p3",
"planned_secs": 20.2,
"actual_secs": 20.2,
"pace": "ok"
},
{
"id": "p4",
"planned_secs": 42.7,
"actual_secs": 42.7,
"pace": "ok"
},
{
"id": "p5",
"planned_secs": 26.9,
"actual_secs": 19.9,
"pace": "rush"
},
{
"id": "p6",
"planned_secs": 28.8,
"actual_secs": 28.8,
"pace": "ok"
},
{
"id": "p7",
"planned_secs": 23,
"actual_secs": 30.4,
"pace": "drag"
},
{
"id": "p8",
"planned_secs": 5.4,
"actual_secs": 5.4,
"pace": "ok"
}
]
Reply:
{
"task": "remark",
"lane_note": "",
"arrow": "You were right to be angry, and you were wrong to hit him, and both of those things are true at once.",
"marks": [
{
"id": "p2",
"marked": "She was born in 1931 / in a two-room house in Cork, ^ the youngest of seven, and she used to say she learned to talk early / because otherwise nobody would have heard her. / She came to Manchester at nineteen / with one suitcase and the address of a cousin who, it turned out, / had moved away the month before. / She found work in a bakery that same week, / and she stayed for [slow] *thirty-one years*.",
"note": "This is her whole early life in three sentences; a beat after each one lets people picture it."
},
{
"id": "p5",
"marked": "I have thought about that sentence / more than almost anything anyone has ever said to me. // [slow] You can be *right and wrong* at once. / You can be angry / and still choose what to do with it. / She lived that way. / She was fierce about fairness / and gentle with people, ^ and she never once confused the two.",
"note": "Her sentence is the heart of the talk, so give it silence before and say it slowly."
},
{
"id": "p7",
"marked": "So here is what I am going to try to do, and what I would ask of you. / When you are angry, be angry honestly. When you choose what to do next, choose kindly. / And if someone tells you they are not hungry, *put the kettle on* anyway.",
"note": "Fewer stops so the three asks run as one line of thought and the kettle line lands warmly."
}
],
"trims": [],
"pacing": [
{
"id": "p2",
"issue": "rush",
"advice": "Stop fully after \"nobody would have heard her\" and after \"the month before\", and say \"thirty-one years\" slowly before moving on."
},
{
"id": "p5",
"issue": "rush",
"advice": "Take the long pause after \"ever said to me\", then say \"You can be right and wrong at once\" at half speed before going on."
},
{
"id": "p7",
"issue": "drag",
"advice": "Say the two \"When you\" sentences together with no stop between them, and keep your one pause for just before \"And if someone tells you\"."
}
],
"open": "Reminder: look at the family, take one breath, then say \"Thank you all for being here.\"",
"close": "Reminder: keep the long pause after \"Thank you, Grandma\", say \"We turned out all right\" slowly, and add nothing after it.",
"seconds_saved": 0
}
The page checks the reply; the API does not
Over the API you get the reply as JSON and nothing has been applied or verified. The page's own code is one plain script you can load in node after global.window = {}: cuekit.js. CueKit.sectionize(text) splits a script into paragraphs p1..pn (a blank line between paragraphs, ## heading lines for sections); CueKit.buildInput(doc, {title, occasion, target, wpm, slides}, {task, targets, rehearsal}) returns {input, full, targets, ...} where input is this exact run body; CueKit.parseResult(text) and CueKit.normalize(obj, {task}) read the reply; CueKit.reconcile(result, doc, settings, {full, targets, rehearsal}) runs the checks above and returns per-mark and per-trim statuses, the recounted savedSecs, plannedAfter and a list of notes; CueKit.applyMarks(doc, recon) and CueKit.applyTrim(doc, id, cut) change the script; CueKit.toMarkdown(doc, settings) exports it.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://cue-scroll.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://cue-scroll.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"]
// cue-scroll.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://cue-scroll.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://cue-scroll.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
}
// CueScroll.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 CueScroll {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://cue-scroll.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://cue-scroll.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://cue-scroll.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://cue-scroll.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":"cue-scroll"}' | 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": "cue-scroll"})["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: "cue-scroll" })).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": "cue-scroll"}, 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", "cue-scroll"), 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: "cue-scroll" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "cue-scroll"])["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 = "cue-scroll" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "mark", "title": "...", "occasion": "toast", "target_seconds": 135, "wpm": 140, "slides": false, "planned_seconds": 158, "paragraphs": "[...]", "rehearsal": "", "targets": "", "clipped": ""}
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": "mark", "title": "...", "occasion": "toast", "target_seconds": 135, "wpm": 140, "slides": false, "planned_seconds": 158, "paragraphs": "[...]", "rehearsal": "", "targets": "", "clipped": ""}
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, title, occasion, target_seconds, wpm, slides, planned_seconds, paragraphs, rehearsal, targets, clipped}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses cue-scroll:<task>:<inputHash>:a<attempt>, and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="cue-scroll:mark:$(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 = "cue-scroll:" + 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("arrow"))
import { createHash } from "node:crypto";
const key = `cue-scroll:${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.arrow);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "cue-scroll:" + 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 = "cue-scroll:" + 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 = "cue-scroll:#{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["arrow"]].inspect
$key = "cue-scroll:" . $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["arrow"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"cue-scroll:{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: cue-scroll:mark:$(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": "mark", "title": "...", "occasion": "toast", "target_seconds": 135, "wpm": 140, "slides": false, "planned_seconds": 158, "paragraphs": "[...]", "rehearsal": "", "targets": "", "clipped": ""}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "cue-scroll:mark:" + 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": `cue-scroll:mark:${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", "cue-scroll:mark:"+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", "cue-scroll:mark:" + 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" => "cue-scroll:mark:#{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: cue-scroll:mark:" . 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", $"cue-scroll:mark:{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);