Driving Tarot Reading from your own code
Base URL https://api.skillsafe.ai/v1/app-api. Every response is an envelope:
{"ok":true,"data":{…}} on success, {"ok":false,"error":{"code":…,"message":…}}
on failure. Authorise with Authorization: Bearer <token>. Get a token from the
tokens page without touching a developer console.
The one thing to understand before you write any of this: the model does not draw the cards. It is handed a finished draw and asked what it says. If you call this API, you are the one holding the deck, and the quality of your shuffle is now your responsibility — step 4 shows an unbiased one in each language.
That also means you can and should reconcile: every card_name
and orientation the reading returns must match a card you dealt. A mismatch is
the reading being wrong, and it is checkable precisely because you kept the draw.
What this app is for
Entertainment and structured reflection. The reading is instructed never to state or imply a
future event, a probability, a timeframe or an outcome, and it declines two classes of question
outright: anything a professional is licensed, regulated, qualified or insured to answer, and
anything about another person's private life or fate. Those refusals come back as a populated
refusal field with positions empty — handle that branch, it is a
normal response and it is billed like any other run.
The first of those is deliberately not a list of three subjects. Medical, legal and financial are the common cases; the rule is who owes the answer. A veterinary treatment decision, a structural-safety question and a tax position are all refused for the same reason, and an earlier build that enumerated three subjects was beaten in one move by a question about a vet. If you are wrapping this API, do not reimplement the boundary as a keyword list — that is the exact shape that failed.
Errors
| Code | HTTP | What it means |
|---|---|---|
unauthorized | 401 | Missing, malformed, expired or revoked token. A cold 401 from /me before any token exists is normal — mint a guest token and retry. |
payment_required | 402 | Balance below min_credits. Call /estimate and compare against /me first; this should never surprise you. |
forbidden | 403 | The token is valid but not for this app, or the operation is not allowed for a guest subject. |
not_found | 404 | Unknown path, or a job_id that does not belong to this subject. |
validation_error | 400 | The body did not match the input contract. error.details names the field. |
rate_limited | 429 | Back off and retry with a delay. Never tight-loop. |
internal | 5xx | Retry once with the same Idempotency-Key, which is what makes the retry safe. |
The input contract
One JSON object, posted as the body of /estimate, /run and
/run-stream. Single lane — there is no task field, because the app is
one contract with a spread parameter.
| Field | Type | Notes |
|---|---|---|
question | string | Required. Up to 600 characters; the overflow belongs in context. |
context | string | Optional background. Up to 3 000 characters. |
spread | string | single (1 card), three (3), celtic (10). |
frame | string | Three-card only: arc, stance or tension. Empty otherwise. |
reversals_enabled | boolean | Whether reversals were in play for this draw. |
drawn_at | string | ISO-8601 with a Z suffix. |
cards | array | The draw. One entry per position, in position order. Each carries slot, position_key, position_label, position_meaning, card_id, card_name, arcana, suit, suit_name, element, rank, rank_name, court, reversed, orientation, keywords, upright_keywords, reversed_keywords and image. |
pattern_facts | object | Arithmetic over the draw: card_count, major_count, minor_count, reversed_count, court_count, ace_count, suit_counts, element_counts, dominant_suit_name, dominant_element, absent_suits, repeated_ranks. |
retry_note | string | Optional. Send only when re-asking after a reply failed to parse. |
The 78 card rows — ids, names, suits, elements, ranks, keywords and image lines — are served
verbatim at /deck.js. That file is the corpus; copy it rather than
typing card names by hand, because card_id is what reconciliation keys on.
The output contract
data.output.output is a JSON string. Parse it, and you get:
| Field | Type | Notes |
|---|---|---|
question | string | The question, lightly tidied. |
refusal | string | Empty on a normal reading. Non-empty means the question crossed a boundary, and positions is then []. |
opening | string | Sets the spread up for this question. |
positions | array | One entry per dealt card, in draw order. Each has slot, position_key, position_label, card_id, card_name, orientation, suit_name, traditionally and in_position. |
pattern | object | major_count, reversed_count, dominant_suit_name, note. Compare against your own pattern_facts. |
together | string | How the cards speak to each other as one spread. |
tension | string | The sharpest pull between two named cards. |
to_sit_with | string[] | Concrete things to think about. |
not_saying | string | What the reading is explicitly not claiming. |
1. A tiny client helper
Everything below assumes this. One function, the envelope unwrapped, errors raised.
# Every call is Bearer-authorised and returns a {"ok":…,"data":…} / {"ok":false,"error":…} envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
call() { # call <method> <path> [body]
curl -sS -X "$1" "$BASE$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
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)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
env = json.load(r)
if not env.get("ok"):
raise RuntimeError(env.get("error"))
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (!env.ok) throw new Error(env.error?.message || res.statusText);
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, body any) (json.RawMessage, error) {
var rdr *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, errors.New(env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class TarotClient {
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 body) throws Exception {
HttpRequest.BodyPublisher pub = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub)
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // a {"ok":…,"data":…} envelope
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body = nil)
uri = URI(BASE + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise env.dig("error", "message").to_s unless env["ok"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $method, string $path, ?array $body = null) {
$ch = curl_init(BASE . $path);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($env["ok"])) {
throw new RuntimeException($env["error"]["message"] ?? "request failed");
}
return $env["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class Tarot
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN";
static readonly HttpClient Http = new HttpClient();
public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean())
throw new Exception(env.GetProperty("error").GetProperty("message").GetString());
return env.GetProperty("data");
}
}
2. Get a token
A guest token costs nothing and is enough for /me and /estimate. Reading a spread is metered and needs a personal token from the tokens page.
# A guest token is minted with no credentials. It is enough for /me and /estimate,
# and for nothing that costs money.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"tarot-reading"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest","subject_id":"…"}}
# For a PERSONAL token (required to read a spread), sign in at
# https://tarot-reading.skillsafe.ai/tokens.html and copy it from there.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "tarot-reading"}).encode(),
headers={"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
print(TOKEN[:8] + "…")
# A personal token comes from https://tarot-reading.skillsafe.ai/tokens.html
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "tarot-reading" })
});
const { data } = await res.json();
const TOKEN = data.token; // guest: free calls only
// A personal token comes from https://tarot-reading.skillsafe.ai/tokens.html
body := bytes.NewBufferString(`{"slug":"tarot-reading"}`)
res, err := http.Post("https://api.skillsafe.ai/v1/app-api/guest",
"application/json", body)
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
fmt.Println(env.Data.Token[:8] + "…")
HttpRequest req = HttpRequest.newBuilder(
URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"tarot-reading\"}"))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
// res.body() -> {"ok":true,"data":{"token":"…","subject_type":"guest"}}
System.out.println(res.body());
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ slug: "tarot-reading" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body).dig("data", "token")
puts TOKEN[0, 8] + "…"
<?php
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "tarot-reading"]));
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
$token = $env["data"]["token"]; // guest: free calls only
var guestBody = new StringContent("{\"slug\":\"tarot-reading\"}",
Encoding.UTF8, "application/json");
var guestRes = await Http.PostAsync(
"https://api.skillsafe.ai/v1/app-api/guest", guestBody);
var guestEnv = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement;
string token = guestEnv.GetProperty("data").GetProperty("token").GetString()!;
Console.WriteLine(token[..8] + "…");
3. Who am I, and what is the balance
/me returns exactly three fields. Compare credits against the hold from step 5 before you ever call /run.
call GET /me
# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_…","credits":48210}}
#
# Those THREE fields are the whole response. There is no username, email, name
# or id, so the signed-in test is subject_type == "user" and nothing else.
me = call("GET", "/me")
print(me["subject_type"], me["credits"])
signed_in = me["subject_type"] == "user"
# /me returns exactly subject_type, subject_id and credits - nothing else.
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
const signedIn = me.subject_type === "user";
// /me returns exactly subject_type, subject_id and credits - nothing else.
raw, err := call("GET", "/me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
String me = call("GET", "/me", null);
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_…","credits":48210}}
// Exactly three fields. subject_type == "user" is the signed-in test.
me = call("GET", "/me")
puts "#{me['subject_type']} #{me['credits']}"
signed_in = me["subject_type"] == "user"
# /me returns exactly subject_type, subject_id and credits - nothing else.
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
$signedIn = $me["subject_type"] === "user";
// /me returns exactly subject_type, subject_id and credits - nothing else.
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
bool signedIn = me.GetProperty("subject_type").GetString() == "user";
4. Draw the cards yourself
This step has no HTTP in it, and it is the most important one on the page. Use a CSPRNG and a Fisher-Yates pass, and draw each index with rejection sampling — x % 78 is biased because 78 does not divide 232, and the bias is invisible forever. Then decide each card's orientation with one unbiased bit.
# THE MODEL DOES NOT DRAW. You supply the cards; it reads them.
#
# The browser app shuffles with crypto.getRandomValues and a rejection sampler.
# From a shell, the same job is done by a CSPRNG - never $RANDOM, which is a
# 15-bit LCG, and never `% 78`, which is biased.
#
# Deal three cards from a 78-card deck, unbiased, using openssl:
draw_one() { # draw_one <bound>
local n=$1 limit=$(( (4294967296 / n) * n )) x
while :; do
x=$(( 0x$(openssl rand -hex 4) ))
(( x < limit )) && { echo $(( x % n )); return; }
done
}
# Then remove the drawn index from the deck and repeat - that is a partial
# Fisher-Yates. Copy the card objects from /deck.js for the ids and keywords.
import secrets, json
# The 78 card ids and their keyword rows are in https://tarot-reading.skillsafe.ai/deck.js
DECK = [...] # 78 dicts: card_id, card_name, arcana, suit, suit_name, element, rank, ...
POSITIONS = [
{"key": "situation", "label": "The situation"},
{"key": "bring", "label": "What you bring"},
{"key": "asks", "label": "What it asks of you"},
]
deck = list(DECK)
for i in range(len(deck) - 1, 0, -1): # Fisher-Yates
j = secrets.randbelow(i + 1) # uniform, no modulo bias
deck[i], deck[j] = deck[j], deck[i]
cards = []
for slot, pos in enumerate(POSITIONS, start=1):
c = dict(deck[slot - 1])
reversed_ = secrets.randbelow(2) == 1
c.update(slot=slot, position_key=pos["key"], position_label=pos["label"],
reversed=reversed_, orientation="reversed" if reversed_ else "upright",
keywords=c["reversed"] if reversed_ else c["upright"])
cards.append(c)
// The 78 card rows are in https://tarot-reading.skillsafe.ai/deck.js
const DECK = [/* 78 objects: card_id, card_name, arcana, suit, element, rank, … */];
const POSITIONS = [
{ key: "situation", label: "The situation" },
{ key: "bring", label: "What you bring" },
{ key: "asks", label: "What it asks of you" }
];
// Uniform integer in [0,n) - rejection sampled, because x % n is biased
// whenever n does not divide 2**32.
function randomInt(n) {
const limit = Math.floor(4294967296 / n) * n;
for (;;) {
const x = crypto.getRandomValues(new Uint32Array(1))[0] >>> 0;
if (x < limit) return x % n;
}
}
const deck = DECK.slice();
for (let i = deck.length - 1; i > 0; i--) {
const j = randomInt(i + 1);
[deck[i], deck[j]] = [deck[j], deck[i]];
}
const cards = POSITIONS.map((pos, i) => {
const reversed = randomInt(2) === 1;
return { ...deck[i], slot: i + 1, position_key: pos.key,
position_label: pos.label, reversed,
orientation: reversed ? "reversed" : "upright" };
});
import "crypto/rand"
import "math/big"
// randomInt is uniform in [0,n) - crypto/rand.Int already rejection-samples.
func randomInt(n int64) int64 {
v, err := rand.Int(rand.Reader, big.NewInt(n))
if err != nil {
panic(err)
}
return v.Int64()
}
// deck holds the 78 rows from https://tarot-reading.skillsafe.ai/deck.js
deck := append([]Card(nil), Deck...)
for i := len(deck) - 1; i > 0; i-- { // Fisher-Yates
j := randomInt(int64(i + 1))
deck[i], deck[j] = deck[j], deck[i]
}
cards := make([]Card, 3)
for i := range cards {
c := deck[i]
c.Slot = i + 1
c.Reversed = randomInt(2) == 1
if c.Reversed {
c.Orientation = "reversed"
} else {
c.Orientation = "upright"
}
cards[i] = c
}
import java.security.SecureRandom;
import java.util.*;
SecureRandom rng = new SecureRandom();
// DECK holds the 78 rows from https://tarot-reading.skillsafe.ai/deck.js
List<Card> deck = new ArrayList<>(DECK);
for (int i = deck.size() - 1; i > 0; i--) { // Fisher-Yates
int j = rng.nextInt(i + 1); // uniform, rejection sampled
Collections.swap(deck, i, j);
}
String[] keys = { "situation", "bring", "asks" };
String[] labels = { "The situation", "What you bring", "What it asks of you" };
List<Card> cards = new ArrayList<>();
for (int i = 0; i < keys.length; i++) {
Card c = deck.get(i).copy();
c.slot = i + 1;
c.positionKey = keys[i];
c.positionLabel = labels[i];
c.reversed = rng.nextInt(2) == 1;
c.orientation = c.reversed ? "reversed" : "upright";
cards.add(c);
}
require "securerandom"
# DECK holds the 78 rows from https://tarot-reading.skillsafe.ai/deck.js
deck = DECK.dup
(deck.length - 1).downto(1) do |i|
j = SecureRandom.random_number(i + 1) # uniform, no modulo bias
deck[i], deck[j] = deck[j], deck[i]
end
positions = [
{ key: "situation", label: "The situation" },
{ key: "bring", label: "What you bring" },
{ key: "asks", label: "What it asks of you" }
]
cards = positions.each_with_index.map do |pos, i|
rev = SecureRandom.random_number(2) == 1
deck[i].merge(
slot: i + 1, position_key: pos[:key], position_label: pos[:label],
reversed: rev, orientation: rev ? "reversed" : "upright")
end
<?php
// $DECK holds the 78 rows from https://tarot-reading.skillsafe.ai/deck.js
$deck = $DECK;
for ($i = count($deck) - 1; $i > 0; $i--) { // Fisher-Yates
$j = random_int(0, $i); // CSPRNG, uniform
[$deck[$i], $deck[$j]] = [$deck[$j], $deck[$i]];
}
$positions = [
["key" => "situation", "label" => "The situation"],
["key" => "bring", "label" => "What you bring"],
["key" => "asks", "label" => "What it asks of you"],
];
$cards = [];
foreach ($positions as $i => $pos) {
$c = $deck[$i];
$rev = random_int(0, 1) === 1;
$c["slot"] = $i + 1;
$c["position_key"] = $pos["key"];
$c["position_label"] = $pos["label"];
$c["reversed"] = $rev;
$c["orientation"] = $rev ? "reversed" : "upright";
$cards[] = $c;
}
using System.Security.Cryptography;
// Deck holds the 78 rows from https://tarot-reading.skillsafe.ai/deck.js
var deck = new List<Card>(Deck);
for (int i = deck.Count - 1; i > 0; i--)
{
int j = RandomNumberGenerator.GetInt32(i + 1); // uniform, rejection sampled
(deck[i], deck[j]) = (deck[j], deck[i]);
}
var keys = new[] { "situation", "bring", "asks" };
var labels = new[] { "The situation", "What you bring", "What it asks of you" };
var cards = new List<Card>();
for (int i = 0; i < keys.Length; i++)
{
var c = deck[i] with { Slot = i + 1, PositionKey = keys[i], PositionLabel = labels[i] };
c.Reversed = RandomNumberGenerator.GetInt32(2) == 1;
c.Orientation = c.Reversed ? "reversed" : "upright";
cards.Add(c);
}
5. Price it — free, no job, no charge
/estimate takes the exact body you are about to run and returns hold_credits, min_credits, model, model_alias and markup_bps. The hold prices the full output cap; the actual charge is usually far lower.
# Free. No job, no charge. It prices the exact body you are about to run.
call POST /estimate '{
"question": "I keep putting off a conversation with my sister and I want to understand what I am protecting by not having it.",
"context": "About eight months. I have drafted the message twice and deleted it both times.",
"spread": "three",
"frame": "stance",
"reversals_enabled": true,
"drawn_at": "2026-08-26T18:40:12.004Z",
"cards": [ /* the three card objects from step 4 */ ],
"pattern_facts": { "card_count": 3, "major_count": 2, "reversed_count": 1,
"dominant_suit_name": "" }
}'
# {"ok":true,"data":{"hold_credits":…,"min_credits":…,"model":"gpt-5.6-terra",
# "model_alias":"gpt-terra","markup_bps":1000}}
body = {
"question": "I keep putting off a conversation with my sister…",
"context": "About eight months.",
"spread": "three",
"frame": "stance",
"reversals_enabled": True,
"drawn_at": "2026-08-26T18:40:12.004Z",
"cards": cards, # from step 4 - YOU drew these
"pattern_facts": facts,
}
est = call("POST", "/estimate", body)
print(est["hold_credits"], est["model_alias"])
me = call("GET", "/me")
if me["credits"] < est["hold_credits"]:
raise SystemExit("top up first - a run would 402")
const body = {
question: "I keep putting off a conversation with my sister…",
context: "About eight months.",
spread: "three",
frame: "stance",
reversals_enabled: true,
drawn_at: new Date().toISOString(),
cards, // from step 4 - YOU drew these
pattern_facts: facts
};
const est = await call("POST", "/estimate", body);
console.log(est.hold_credits, est.model_alias);
const me = await call("GET", "/me");
if (me.credits < est.hold_credits) throw new Error("top up first - a run would 402");
body := map[string]any{
"question": "I keep putting off a conversation with my sister…",
"context": "About eight months.",
"spread": "three",
"frame": "stance",
"reversals_enabled": true,
"drawn_at": time.Now().UTC().Format(time.RFC3339),
"cards": cards,
"pattern_facts": facts,
}
raw, err := call("POST", "/estimate", body)
if err != nil {
panic(err)
}
var est struct {
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
ModelAlias string `json:"model_alias"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.HoldCredits, est.ModelAlias)
String body = """
{
"question": "I keep putting off a conversation with my sister…",
"context": "About eight months.",
"spread": "three",
"frame": "stance",
"reversals_enabled": true,
"drawn_at": "2026-08-26T18:40:12.004Z",
"cards": %s,
"pattern_facts": %s
}
""".formatted(cardsJson, factsJson);
String est = call("POST", "/estimate", body);
// {"ok":true,"data":{"hold_credits":…,"model_alias":"gpt-terra","markup_bps":1000}}
System.out.println(est);
body = {
question: "I keep putting off a conversation with my sister…",
context: "About eight months.",
spread: "three",
frame: "stance",
reversals_enabled: true,
drawn_at: Time.now.utc.iso8601,
cards: cards, # from step 4 - YOU drew these
pattern_facts: facts
}
est = call("POST", "/estimate", body)
puts "#{est['hold_credits']} #{est['model_alias']}"
me = call("GET", "/me")
abort "top up first - a run would 402" if me["credits"] < est["hold_credits"]
<?php
$body = [
"question" => "I keep putting off a conversation with my sister…",
"context" => "About eight months.",
"spread" => "three",
"frame" => "stance",
"reversals_enabled" => true,
"drawn_at" => gmdate("Y-m-d\\TH:i:s\\Z"),
"cards" => $cards, // from step 4 - YOU drew these
"pattern_facts" => $facts,
];
$est = call("POST", "/estimate", $body);
echo $est["hold_credits"], " ", $est["model_alias"], "\n";
$me = call("GET", "/me");
if ($me["credits"] < $est["hold_credits"]) {
exit("top up first - a run would 402\n");
}
var body = new
{
question = "I keep putting off a conversation with my sister…",
context = "About eight months.",
spread = "three",
frame = "stance",
reversals_enabled = true,
drawn_at = DateTime.UtcNow.ToString("o"),
cards, // from step 4 - YOU drew these
pattern_facts = facts
};
var est = await Call(HttpMethod.Post, "/estimate", body);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("model_alias").GetString());
6. Run it and poll
Metered. Poll /jobs/{job_id} until status is succeeded or failed. If the balance sits between min_credits and hold_credits the run still executes with a reduced output cap and returns truncated: true — render what parsed rather than presenting a clipped reading as complete.
# Metered. Always send an Idempotency-Key derived from the input, so a retry
# after a network blip cannot bill you twice.
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: tarot-reading:three:9x4kp:a1" \
--data @body.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call GET "/jobs/$JOB")
ST=$(echo "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["status"])')
[ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
sleep 1
done
echo "$OUT"
import time
job = call("POST", "/run", body) # add the Idempotency-Key header in call()
job_id = job["job_id"]
while True:
j = call("GET", "/jobs/" + job_id)
if j["status"] in ("succeeded", "failed"):
break
time.sleep(1)
reading = json.loads(j["output"]["output"])
for p in reading["positions"]:
print(p["position_label"], "-", p["card_name"], p["orientation"])
# Reconcile before you trust it: every card_name and orientation MUST match the
# cards you drew in step 4. A mismatch is the reading being wrong, not the deck.
const job = await call("POST", "/run", body); // send Idempotency-Key too
let j;
do {
await new Promise(r => setTimeout(r, 1000));
j = await call("GET", `/jobs/${job.job_id}`);
} while (j.status !== "succeeded" && j.status !== "failed");
const reading = JSON.parse(j.output.output);
// Reconcile before you trust it.
const drawn = new Map(cards.map(c => [c.card_id, c]));
for (const p of reading.positions) {
const d = drawn.get(p.card_id);
if (!d) throw new Error(`reading names ${p.card_name}, which was not drawn`);
if (d.orientation !== p.orientation) throw new Error(`${d.card_name}: orientation flipped`);
}
raw, err := call("POST", "/run", body) // send Idempotency-Key too
if err != nil {
panic(err)
}
var job struct {
JobID string `json:"job_id"`
}
json.Unmarshal(raw, &job)
var j struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
raw, _ = call("GET", "/jobs/"+job.JobID, nil)
json.Unmarshal(raw, &j)
if j.Status == "succeeded" || j.Status == "failed" {
break
}
time.Sleep(time.Second)
}
fmt.Println(j.Output.Output)
String job = call("POST", "/run", body); // send Idempotency-Key too
String jobId = extractJobId(job);
String j;
while (true) {
j = call("GET", "/jobs/" + jobId, null);
if (j.contains("\"succeeded\"") || j.contains("\"failed\"")) break;
Thread.sleep(1000);
}
// data.output.output is the reading, as a JSON string. Parse it, then check
// every card_name and orientation against the cards you drew in step 4.
System.out.println(j);
job = call("POST", "/run", body) # send Idempotency-Key too
loop do
@j = call("GET", "/jobs/#{job['job_id']}")
break if %w[succeeded failed].include?(@j["status"])
sleep 1
end
reading = JSON.parse(@j.dig("output", "output"))
reading["positions"].each do |p|
puts "#{p['position_label']} - #{p['card_name']} #{p['orientation']}"
end
# Reconcile: every card_name and orientation must match your step-4 draw.
<?php
$job = call("POST", "/run", $body); // send Idempotency-Key too
do {
sleep(1);
$j = call("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"], true));
$reading = json_decode($j["output"]["output"], true);
foreach ($reading["positions"] as $p) {
echo $p["position_label"], " - ", $p["card_name"], " ", $p["orientation"], "\n";
}
// Reconcile: every card_name and orientation must match your step-4 draw.
var job = await Call(HttpMethod.Post, "/run", body); // send Idempotency-Key too
string jobId = job.GetProperty("job_id").GetString()!;
JsonElement j;
string status;
do
{
await Task.Delay(1000);
j = await Call(HttpMethod.Get, "/jobs/" + jobId);
status = j.GetProperty("status").GetString()!;
} while (status != "succeeded" && status != "failed");
var reading = JsonDocument.Parse(
j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
// Reconcile: every card_name and orientation must match your step-4 draw.
7. Or stream it
Same body, same billing, same idempotency rules. Concatenating every delta.text yields exactly what /run would have returned, so you can show progress without a second contract.
# Server-sent events. Same body, same Idempotency-Key rules, same billing.
curl -sSN -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "Idempotency-Key: tarot-reading:three:9x4kp:a1" \
--data @body.json
# event: delta data: {"text":"{\"question\":\"…"}
# event: done data: {"charged_credits":…,"truncated":false}
#
# Concatenating every delta.text yields the same JSON object /run returns.
import urllib.request, json
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(body).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("Idempotency-Key", "tarot-reading:three:9x4kp:a1")
buf = []
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
payload = json.loads(line[5:].strip())
if "text" in payload:
buf.append(payload["text"])
reading = json.loads("".join(buf))
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Idempotency-Key": "tarot-reading:three:9x4kp:a1"
},
body: JSON.stringify(body)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let raw = "", buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const p = JSON.parse(line.slice(5).trim());
if (p.text) raw += p.text;
}
}
const reading = JSON.parse(raw);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(bodyJSON))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", "tarot-reading:three:9x4kp:a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var sb strings.Builder
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var p struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &p)
sb.WriteString(p.Text)
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Idempotency-Key", "tarot-reading:three:9x4kp:a1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
StringBuilder raw = new StringBuilder();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> raw.append(textOf(l.substring(5).trim())));
// raw is the same JSON object /run returns.
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Idempotency-Key"] = "tarot-reading:three:9x4kp:a1"
req.body = JSON.dump(body)
raw = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
payload = JSON.parse(line[5..].strip) rescue next
raw << payload["text"].to_s
end
end
end
end
reading = JSON.parse(raw)
<?php
$raw = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Accept: text/event-stream",
"Idempotency-Key: tarot-reading:three:9x4kp:a1",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data:")) {
$p = json_decode(trim(substr($line, 5)), true);
$raw .= $p["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$reading = json_decode($raw, true);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", "tarot-reading:three:9x4kp:a1");
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
while (await reader.ReadLineAsync() is string line)
{
if (!line.StartsWith("data:")) continue;
var p = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (p.TryGetProperty("text", out var t)) raw.Append(t.GetString());
}
var reading = JsonDocument.Parse(raw.ToString()).RootElement;
Rate limits and good manners
/estimate and /me are free; there is no reason to run without pricing
first. Data endpoints share 120 requests a minute, and vector similarity is tighter at 30 a
minute — debounce anything user-driven. On a 429, back off; never tight-loop.
Send an Idempotency-Key on every /run and /run-stream.
Derive it from the input — the app itself uses
tarot-reading:<spread>:<hash of question + context + drawn card ids>:a<attempt>.
A reformat retry must reuse the key derived from the same input with a bumped attempt suffix,
so a malformed first reply cannot bill twice.
One more time, because it is the whole design
You draw. The model reads. Then you check the reading against the draw. If you skip the third step you have given up the only guarantee this app offers — that the cards were not chosen to suit the answer.