← Bottle a Memory
API

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, tone, lines, page_facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: bottling a memory is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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)

FieldRequiredWhat it holds
taskyesAlways bottle.
toneyestender, playful or grand.
linesyes3 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_factsyesOne 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_notenoWhat the page left out, if anything.
retry_notenoOnly 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": "..."}
FieldWhat it holds
status, reasonThe status by the order above; for declined, at most 30 words saying why (empty for ok).
notesOne 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_reasonThe notes per tier, the verdict id, and at most 30 words on the counts and how the scent would open and fade.
swap, verdict_afterThe 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, keeperExactly 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, closingThe 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"}
}

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')

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}' 

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}' 

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}' 

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