Rhyme Finder API
The word lookups and couplet mode run entirely in the browser on the CMU Pronouncing Dictionary, so they have no
API: there is nothing to call. What you can call is the Next line lane: send the lines of a poem or lyric
and get five candidate next lines back, each aimed at a rhyme word and a syllable count. It uses a language model
(gpt-terra), needs a signed-in SkillSafe token and is billed in credits.
Over the API you get the model's reply as written. The web page then re-checks every line with its own dictionary (rhyme type of the last word, syllable count) and moves failures to the bottom. Your code should do the same check before trusting a line.
Base URL, tokens and the response shape
Base URL: https://api.skillsafe.ai/v1/app-api. There is no app segment in the path: the app is bound
to the token when it is minted. Every response is {"ok": true, "data": {...}} or
{"ok": false, "error": {"code", "message"}}.
- A guest token (
POST /guestwith{"slug":"rhyme-finder"}) is enough for/meand/estimate. It cannot run Next line. - A personal token runs Next line on your own balance. Get it from the token page after signing in there; it is the same token the app uses in that browser.
| Status | Code | What to do |
|---|---|---|
| 401 | unauthorized | The token is missing, stale or revoked. Sign in again on the token page. |
| 402 | insufficient_credits | Your balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | forbidden | A guest token tried to run. Use a personal token. |
| 429 | rate_limited | Wait and retry with the same Idempotency-Key. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never bills twice. |
The input
The body of /estimate and /run is the input object itself (not wrapped in
{"input": ...}).
| Field | Type | Meaning |
|---|---|---|
task | string | Always "next_line". |
lines | string[] (1–4) | The lines so far, oldest first. The page keeps the last four and the last 200 characters of each. |
scheme | string | "AABB": the new line rhymes with the last line. "ABAB": with the line before the last. |
rhyme_with | string | The word the new line must rhyme with (the last word of the line the scheme picks). |
target_syllables | integer 2–24 | Syllables the new line should have; the page uses line 1's count unless the user sets one. |
perfect_rhymes | string[] | Common perfect rhymes for rhyme_with, most common first (the page sends up to 40). |
near_rhymes | string[] | Common near rhymes (the page sends up to 24). |
theme | string, optional | What the next line should be about (up to 160 characters). |
tone | string, optional | One of tender, playful, melancholy, hopeful, wry, bold, dark. |
avoid | string[], optional | Lines already shown; the model writes five new ones with new end words. |
retry_note | string, optional | Only on a reformat retry: why the previous reply could not be read. |
{"task":"next_line",
"lines":["We waited by the harbour wall","the gulls were wheeling overhead"],
"scheme":"ABAB","rhyme_with":"wall","target_syllables":8,
"perfect_rhymes":["all","call","small","fall","ball","hall","tall","recall"],
"near_rhymes":["called","fault","falls","salt","doll"],
"theme":"a boat that is late coming home","tone":"hopeful"}
Leaving out perfect_rhymes and near_rhymes works, but the lines rhyme less reliably:
the lists are what keep the model on true rhymes.
The reply
The job's output.output is a string holding one JSON object:
{"task":"next_line","rhyme_with":"wall","target_syllables":8,"suggestions":[
{"line":"and then a sail rose, white and tall","end_word":"tall","syllables":8,
"rhyme":"perfect","why":"the late boat finally appears"}
],"note":""}
suggestionsholds five objects, best first.lineends onend_word.syllablesandrhymeare the model's own claims. The web page re-counts and re-checks both and reports where they disagree; treat them as hints.- If
truncatedis true on the job, your balance cut the reply short: parse what arrived and drop a half-written last suggestion.
Steps: token, /me, /estimate, /run
Save the input above as input.json. /estimate is free and creates no job: it shows
the credits a run reserves (hold_credits), the minimum balance (min_credits) and the
model. /run is billed: send an Idempotency-Key built from the input and an attempt
number, so a resend after a network blip never bills twice. The page uses
rhyme-finder:next_line:<inputHash>:a<attempt>; its one automatic reformat retry is
attempt 2 and is shown in the page as one extra run.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN="YOUR_TOKEN" # personal token from https://rhyme-finder.skillsafe.ai/tokens.html
api() { curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" "${@:3}"; }
api GET /me | jq '.data | {subject_type, credits}'
api POST /estimate --data @input.json | jq '.data | {hold_credits, min_credits, model}'
KEY="rhyme-finder:next_line:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(api POST /run -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 '.data | {charged_credits, truncated}'
echo "$J" | jq -r '.data.output.output' | jq -r '.suggestions[] | "\(.line) [\(.end_word), \(.syllables)]"'
import hashlib, json, time, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # personal token from https://rhyme-finder.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, **(headers or {})}
data = json.dumps(body).encode() if body is not None else None
with urllib.request.urlopen(urllib.request.Request(BASE + path, data=data, headers=h, method=method)) as r:
return json.load(r)["data"]
inp = json.load(open("input.json"))
print(api("GET", "/me")["credits"])
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"])
key = "rhyme-finder:next_line:" + 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"])
print(j["charged_credits"], j.get("truncated"))
for s in reply["suggestions"]:
print(s["line"], "|", s["end_word"], s["syllables"])
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // personal token from https://rhyme-finder.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, body: body ? JSON.stringify(body) : undefined,
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, ...headers } });
const j = await r.json();
if (!j.ok) throw new Error(r.status + " " + j.error.code + ": " + j.error.message);
return j.data;
}
const input = JSON.parse(readFileSync("input.json", "utf8"));
console.log((await api("GET", "/me")).credits);
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model);
const key = `rhyme-finder:next_line:${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);
for (const s of reply.suggestions) console.log(s.line, "|", s.end_word, s.syllables);
To mint a guest token for /me and /estimate only:
curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"rhyme-finder"}' | jq -r .data.token.