Drive Bottle a Memory from your own code
The one metered task, bottle, is available over HTTP. It takes a tone (tender, playful or grand) and the memory, three to eight lines as n | detail. It returns one palette note for every line with its kind and a reason, the top, heart and base counts, the verdict, one swap when the balance is off and the verdict after it, three candidate names each scored on five gates, the keeper, a label line and a sign-off.
The division of labour first: the page numbers the lines and lists, for each line, the palette notes whose key word it contains; the model picks every note, judges near or mood where nothing is named, counts the tiers, gives the verdict, makes the swap and writes the names and words; the page then recounts the tiers from its own palette, works the verdict, the swap line and tier, the verdict after the swap, every name's gates and the keeper again, draws the organ and the bottle, and re-checks the reply. The strength on the label (eau de cologne, toilette or parfum) is the page's own and is never sent.
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":"bottle-a-memory"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, tone, lines, page_facts) is missing. |
| 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: bottling a memory 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 and the status
Every run sends task: bottle (the one task is bottle); a missing or different one is still answered as bottle. The status field holds one of two ids, checked in this order: declined when a line names a specific real person, asks for a real recipe, formula, amounts or dilutions, how to make, mix, copy or wear a perfume, or a copy of a commercial perfume or product, is about violence, weapons, crime, drugs, self-harm or anything sexual or hateful, or is a smell that means danger; otherwise ok.
For ok, every line gets one note from the fixed palette of 43 (the same table as the app's prompt and ScentKit.PALETTE), no note twice. Working through the lines in order: when a line contains a note's key word (or the key word plus s or es) as a whole word and that note is not taken yet, the first such note is the line's note and its kind is named; otherwise near (the line names something with a smell of its own) or mood (a sound, a sight, a feeling or a person). The verdict is the first that applies: fleeting with no base note; heavy with no top note; balanced when there is a heart note and no tier holds more than half the notes; otherwise lopsided.
Every verdict but balanced gets one swap. The target tier is base for fleeting, top for heavy, and for lopsided the tier with the fewest notes (ties: base, top, heart). The line comes from the lines whose note is outside the target tier, in a tier holding 2 or more notes (for lopsided, only the tier with the most notes; ties: heart, top, base): a mood line first, then near, then named, the highest line number among equals. A name passes a gate for being 1 to 3 words and at most 18 letters, for sharing a word of 4 or more letters with the lines, for using no key word of a note in the bottle, for plain letters, and for not copying a line whole; the keeper passes the most gates, the earliest on a tie.
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always bottle. |
tone | yes | tender, playful or grand. |
lines | yes | 3 to 8 lines as n | detail, numbered from 1. Lines are cut at 90 characters, repeated lines are dropped, leading bullets and numbers are removed and pipes become slashes. |
page_facts | yes | One key | value per line: lines, named_notes (for each line, the palette ids whose key word it contains, in order of appearance), then the hints cue_hints, harm_words, recipe_words and capitalised_words. |
clip_note | no | What the page left out, if anything. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "bottle", "status": "ok", "reason": "",
"notes": [{"line": 1, "note": "lemon", "kind": "named", "why": "..."}, ...],
"counts": {"top": 1, "heart": 3, "base": 1}, "verdict": "lopsided", "verdict_reason": "...",
"swap": {"line": 3, "from": "lavender", "to": "cedarwood", "why": "..."}, "verdict_after": "balanced",
"names": [{"name": "...", "line": 1, "gates": 5, "angle": "..."}, ...], "keeper": 1,
"label": "...", "closing": "..."}
| Field | What it holds |
|---|---|
status, reason | The status by the order above; for declined, at most 30 words saying why (empty for ok). |
notes | One per input line, in order ([] when declined): line, the palette id in note, kind (named, near or mood) and why (at most 14 words on which words of the line the note stands for). |
counts, verdict, verdict_reason | The notes per tier, the verdict id, and at most 30 words on the counts and how the scent would open and fade. |
swap, verdict_after | The line whose note is swapped, the note it had (from), the palette id brought in (to, from the target tier) and at most 16 words of why; then the verdict with that swap made. For balanced: line 0, empty strings, and an empty verdict_after. |
names, keeper | Exactly 3 names, each from a different line, 1 to 3 capitalised words, with the number of gates passed and an angle of at most 12 words; the number of the keeper. |
label, closing | The bottle's label line (at most 24 words, the lines' own nouns, present tense) and a sign-off of at most 16 words. |
The page then checks the reply (ScentKit.check): one note back for each line, no note twice, every named line given its first untaken named note, the counts recounted from the palette, the verdict, the swap line, its shelf and the note it replaces, the verdict after the swap, every name's gates and line, the keeper, the word limits, no number the input never gives, no he or she, and no internal field id in the prose. Anything that disagrees is listed under the reply; nothing is silently corrected.
Worked examples
Each example is the exact input the page sends for one of its five samples, and the saved reply it replays for free.
Gran's kitchen - balanced
Input:
{
"task": "bottle",
"tone": "tender",
"lines": "1 | lemon cake cooling on the rack\n2 | mint from the back step in a jug\n3 | black tea stewing in a brown pot\n4 | the cedar chest at the foot of the bed\n5 | gran's wool cardigan on the hook\n6 | the radio murmuring in the corner",
"page_facts": "lines | 6\nnamed_notes | 1: lemon; 2: mint; 3: black-tea; 4: cedarwood; 5: none; 6: none\ncue_hints | 1: vanilla; 2: none; 3: none; 4: none; 5: soft-musk; 6: none\nharm_words | none\nrecipe_words | none\ncapitalised_words | none"
}
Reply:
{
"task": "bottle",
"status": "ok",
"reason": "",
"notes": [
{
"line": 1,
"note": "lemon",
"kind": "named",
"why": "Lemon, for the lemon cake cooling on the rack."
},
{
"line": 2,
"note": "mint",
"kind": "named",
"why": "Mint, for the mint from the back step in a jug."
},
{
"line": 3,
"note": "black-tea",
"kind": "named",
"why": "Black tea, for the black tea stewing in a brown pot."
},
{
"line": 4,
"note": "cedarwood",
"kind": "named",
"why": "Cedarwood, for the cedar chest at the foot of the bed."
},
{
"line": 5,
"note": "soft-musk",
"kind": "near",
"why": "Soft musk, for gran's wool cardigan on the hook."
},
{
"line": 6,
"note": "old-paper",
"kind": "mood",
"why": "No scent here; old paper matches the feeling of the radio murmuring."
}
],
"counts": {
"top": 2,
"heart": 1,
"base": 3
},
"verdict": "balanced",
"verdict_reason": "Two top notes, one heart note and three base notes: this scent opens bright, settles in the middle and leaves something behind.",
"swap": {
"line": 0,
"from": "",
"to": "",
"why": ""
},
"verdict_after": "",
"names": [
{
"name": "Cooling Rack",
"line": 1,
"gates": 5,
"angle": "Picks up the cake as it cools on the rack."
},
{
"name": "At The Foot",
"line": 4,
"gates": 5,
"angle": "Picks up the chest at the foot of the bed."
},
{
"name": "The Back Step",
"line": 2,
"gates": 5,
"angle": "Picks up the back step and the jug of mint."
}
],
"keeper": 1,
"label": "Lemon cake, mint, black tea, a cedar chest, a wool cardigan, and a murmuring radio share the bottle.",
"closing": "Top, heart, and base all have their say in your bottle."
}
The first swim of summer - fleeting
Input:
{
"task": "bottle",
"tone": "playful",
"lines": "1 | cold sea up to the knees\n2 | ginger and lemon sherbets in a paper bag\n3 | a mint ice lolly dripping down the stick\n4 | wild roses along the cliff path",
"page_facts": "lines | 4\nnamed_notes | 1: sea-salt; 2: ginger, lemon, old-paper; 3: mint; 4: rose\ncue_hints | 1: none; 2: none; 3: none; 4: rose\nharm_words | none\nrecipe_words | none\ncapitalised_words | none"
}
Reply:
{
"task": "bottle",
"status": "ok",
"reason": "",
"notes": [
{
"line": 1,
"note": "sea-salt",
"kind": "named",
"why": "The word sea makes sea-salt, cold up to the knees."
},
{
"line": 2,
"note": "ginger",
"kind": "named",
"why": "Ginger is the name, straight from the ginger sherbets in a paper bag."
},
{
"line": 3,
"note": "mint",
"kind": "named",
"why": "The word mint, from the mint ice lolly dripping down the stick."
},
{
"line": 4,
"note": "rose",
"kind": "named",
"why": "Rose, from the wild roses along the cliff path."
}
],
"counts": {
"top": 3,
"heart": 1,
"base": 0
},
"verdict": "fleeting",
"verdict_reason": "Three top notes, one heart note and no base note: it opens bright but fades with nothing to hold it.",
"swap": {
"line": 3,
"from": "mint",
"to": "cedarwood",
"why": "Cedarwood for the stick the lolly drips down."
},
"verdict_after": "balanced",
"names": [
{
"name": "Cold Knees",
"line": 1,
"gates": 5,
"angle": "The cold, right up to the knees."
},
{
"name": "Paper Bag",
"line": 2,
"gates": 5,
"angle": "The paper bag holding the sherbets."
},
{
"name": "Cliff Path",
"line": 4,
"gates": 5,
"angle": "The cliff path, with wild roses along it."
}
],
"keeper": 1,
"label": "Cold sea at the knees, ginger lemon sherbets in a paper bag, a mint lolly drips, wild roses line the cliff path.",
"closing": "A fleeting bottle for now; the swap at line 3 gives it something to hold."
}
A winter cabin - heavy
Input:
{
"task": "bottle",
"tone": "grand",
"lines": "1 | smoke from the stove curling under the door\n2 | an old leather armchair by the window\n3 | vanilla custard on the hob\n4 | cinnamon sticks in a jar on the shelf\n5 | honey stirred into the porridge",
"page_facts": "lines | 5\nnamed_notes | 1: woodsmoke; 2: leather; 3: vanilla; 4: cinnamon; 5: honey\ncue_hints | 1: woodsmoke; 2: none; 3: vanilla; 4: none; 5: none\nharm_words | none\nrecipe_words | none\ncapitalised_words | none"
}
Reply:
{
"task": "bottle",
"status": "ok",
"reason": "",
"notes": [
{
"line": 1,
"note": "woodsmoke",
"kind": "named",
"why": "Woodsmoke stands for the smoke curling under the door from the stove."
},
{
"line": 2,
"note": "leather",
"kind": "named",
"why": "Leather stands for the old armchair itself, by the window."
},
{
"line": 3,
"note": "vanilla",
"kind": "named",
"why": "Vanilla stands for the custard sitting on the hob."
},
{
"line": 4,
"note": "cinnamon",
"kind": "named",
"why": "Cinnamon stands for the sticks in a jar on the shelf."
},
{
"line": 5,
"note": "honey",
"kind": "named",
"why": "Honey stands for what gets stirred into the porridge."
}
],
"counts": {
"top": 0,
"heart": 2,
"base": 3
},
"verdict": "heavy",
"verdict_reason": "Three base notes, two heart notes and no top notes: no bright first breath, it starts low and dense.",
"swap": {
"line": 5,
"from": "honey",
"to": "pear",
"why": "Pear stands for what was stirred into the porridge."
},
"verdict_after": "lopsided",
"names": [
{
"name": "Under The Door",
"line": 1,
"gates": 5,
"angle": "Picks up the door the smoke curls under."
},
{
"name": "The Custard Hob",
"line": 3,
"gates": 5,
"angle": "Picks up the custard on the hob."
},
{
"name": "Into The Porridge",
"line": 5,
"gates": 5,
"angle": "Follows what is stirred into the porridge."
}
],
"keeper": 1,
"label": "Smoke curls under the door, an old armchair waits by the window, custard rests on the hob, and honey stirs into the porridge.",
"closing": "A top note could give this bottle the bright start it is missing."
}
A garden party - lopsided
Input:
{
"task": "bottle",
"tone": "playful",
"lines": "1 | roses climbing the trellis\n2 | jasmine over the gate\n3 | lavender bags on every chair\n4 | lemon slices floating in the jugs\n5 | beeswax candles in jam jars",
"page_facts": "lines | 5\nnamed_notes | 1: rose; 2: jasmine; 3: lavender; 4: lemon; 5: beeswax\ncue_hints | 1: rose; 2: none; 3: none; 4: none; 5: beeswax\nharm_words | none\nrecipe_words | none\ncapitalised_words | none"
}
Reply:
{
"task": "bottle",
"status": "ok",
"reason": "",
"notes": [
{
"line": 1,
"note": "rose",
"kind": "named",
"why": "Roses climbing the trellis earn the rose note straight off."
},
{
"line": 2,
"note": "jasmine",
"kind": "named",
"why": "Jasmine over the gate names the jasmine note straight away."
},
{
"line": 3,
"note": "lavender",
"kind": "named",
"why": "Lavender bags on every chair bring the lavender note along."
},
{
"line": 4,
"note": "lemon",
"kind": "named",
"why": "Lemon slices in the jugs name the lemon note directly."
},
{
"line": 5,
"note": "beeswax",
"kind": "named",
"why": "Beeswax candles in jam jars bring the beeswax note forward."
}
],
"counts": {
"top": 1,
"heart": 3,
"base": 1
},
"verdict": "lopsided",
"verdict_reason": "One top, three heart and one base note: the heart tier crowds out the top and the base.",
"swap": {
"line": 3,
"from": "lavender",
"to": "leather",
"why": "This base note matches every chair in this memory."
},
"verdict_after": "balanced",
"names": [
{
"name": "The Trellis",
"line": 1,
"gates": 5,
"angle": "Picks up the trellis the roses are climbing."
},
{
"name": "Every Chair",
"line": 3,
"gates": 5,
"angle": "Picks up the bags on every chair."
},
{
"name": "Jam Jars",
"line": 5,
"gates": 5,
"angle": "Picks up the jam jars and the candles."
}
],
"keeper": 1,
"label": "Roses climbing the trellis, jasmine over the gate, lavender bags on every chair, lemon slices in the jugs, beeswax candles in jam jars.",
"closing": "Lopsided for now - the swap below would settle it into balance."
}
Copy a shop perfume at home - declined
Input:
{
"task": "bottle",
"tone": "tender",
"lines": "1 | the perfume I wore to my first job\n2 | tell me the exact formula so I can mix a copy at home\n3 | how many drops of each oil to use",
"page_facts": "lines | 3\nnamed_notes | 1: none; 2: none; 3: none\ncue_hints | 1: none; 2: none; 3: none\nharm_words | none\nrecipe_words | formula\ncapitalised_words | none"
}
Reply:
{
"task": "bottle",
"status": "declined",
"reason": "This place only turns everyday memories into an imaginary scent, never real recipes or amounts. Share new details of a memory, and I'll begin.",
"notes": [],
"counts": {
"top": 0,
"heart": 0,
"base": 0
},
"verdict": "",
"verdict_reason": "",
"swap": {
"line": 0,
"from": "",
"to": "",
"why": ""
},
"verdict_after": "",
"names": [],
"keeper": 0,
"label": "",
"closing": "Whenever you share a memory's details, I'll happily turn them into notes."
}
Build the input yourself
Load the engine in node (global.window = global; require("./scentkit.js"); require("./organdraw.js")), then:
const K = ScentKit, D = OrganDraw;
const built = K.buildInput({ tone: "tender", strength: "everyday",
lines: "lemon cake cooling on the rack\nblack tea stewing in a brown pot\nthe cedar chest at the foot of the bed" });
built.parsed.lines; // [{n: 1, text: "lemon cake cooling on the rack"}, ...]
built.prescan.named; // [["lemon"], ["black-tea"], ["cedarwood"]]
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.check(reply, built).plan // the counts, verdict, swap line and tier, gates and keeper, worked by the page
// K.gates("Brown Pot Hours", built.parsed.lines, K.picksOf(reply)) // one name on the five gates
// D.svg(K.bottleModel(K.picksOf(reply), { name: "Brown Pot Hours" })) // the organ and the bottle as SVG
// K.md(built, reply, "Brown Pot Hours", "everyday") // the bottle card as Markdown
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://bottle-a-memory.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://bottle-a-memory.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"]
// bottle-a-memory.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://bottle-a-memory.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://bottle-a-memory.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
}
// BottleAMemory.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 BottleAMemory {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://bottle-a-memory.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://bottle-a-memory.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://bottle-a-memory.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://bottle-a-memory.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":"bottle-a-memory"}' | 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": "bottle-a-memory"})["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: "bottle-a-memory" })).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": "bottle-a-memory"}, 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", "bottle-a-memory"), 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: "bottle-a-memory" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "bottle-a-memory"])["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 = "bottle-a-memory" }))!["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": "bottle", "tone": "tender", "lines": "1 | detail\n2 | detail\n3 | detail", "page_facts": "lines | 3\n..."} - see the field table
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": "bottle", "tone": "tender", "lines": "1 | detail\n2 | detail\n3 | detail", "page_facts": "lines | 3\n..."} - see the field table
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: "bottle", tone, lines, page_facts} (see the field table)
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 bottle-a-memory:<task>:<inputHash>:a<attempt> (the one task is bottle), 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="bottle-a-memory:bottle:$(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 = "bottle-a-memory:" + 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 reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("status"))
import { createHash } from "node:crypto";
const key = `bottle-a-memory:${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.status);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "bottle-a-memory:" + 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 = "bottle-a-memory:" + 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 = "bottle-a-memory:#{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["status"]].inspect
$key = "bottle-a-memory:" . $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["status"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"bottle-a-memory:{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: bottle-a-memory:bottle:$(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": "bottle", "tone": "tender", "lines": "1 | detail\n2 | detail\n3 | detail", "page_facts": "lines | 3\n..."} - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "bottle-a-memory:bottle:" + 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": `bottle-a-memory:bottle:${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", "bottle-a-memory:bottle:"+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", "bottle-a-memory:bottle:" + 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" => "bottle-a-memory:bottle:#{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: bottle-a-memory:bottle:" . 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", $"bottle-a-memory:bottle:{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);