Drive Upper Lip from your own code
The metered reply is available over HTTP. It takes one moustache-month plan - the cause, your reason, the goal and the typical gift, growth speed, style, channel, the circles of people you can ask, and the page's figures - and returns one JSON object: a verdict, a campaign angle, a page story, one ask per circle, five updates timed to the growth stages, a style note, a goal note and a thank-you.
The division of labour first: the page owns the arithmetic and the rule; the model owns the words; the page re-checks the reply. The goal arithmetic, the growth estimate and the verdict rule come from lipkit.js, which you can load in node to build the input exactly as the page does (see Build the input yourself).
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
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":"upper-lip"}), 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 (cause, goal, gift, circles, updates, figures) 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: the reply 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 verdict rule
There is one task, so no task field is sent. The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:
rethink- no circles were listed (peopleis 0), orneeded_pctis greater thanrethink_above(50).stretch-needed_pctis greater thanstretch_above(25).on_track- everything else.
needed_pct is the sponsors needed (the goal divided by the typical gift, rounded up) out of everyone across the circles, rounded up. The growth figures are rough estimates (slow about 0.25 mm a day, steady or patchy about 0.35, fast about 0.45). The declared input schema carries scalars only, so the array- and object-valued fields are JSON-encoded strings.
Input fields
| Field | Type | Notes |
|---|---|---|
month | string | Always November. |
cause | string | What the money is for, in your words (up to 200 characters). Required. |
why | string | Your own reason, or empty; past 500 characters the opening and closing are kept with the middle marked [...]. |
currency, goal, gift | string | The money sign ($, £ or €), the goal and the typical gift as whole numbers. Required. |
growth, growth_label | string | slow, steady, fast or patchy, and its plain label with the estimated speed. |
style, style_label | string | pencil, chevron, horseshoe, walrus or handlebar, and its plain label. |
channel, channel_label | string | chat, email, feed or board, and its plain label. |
circles | string (JSON) | Up to 6 groups you can ask, in order: [{name, size}]. May be []. Required. |
updates | string (JSON) | The five update days with their growth stage: [{day, stage}], days 1, 8, 15, 22 and 30. Required. |
figures | string (JSON) | {donors_needed, people, needed_pct, stretch_above, rethink_above, reach_day, shape_by, style_fit, fallback_style}, every value a string; style_fit is yes, late or no. Required. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: verdict (on_track | stretch | rethink), title, summary, hook, story (the fundraising page text, first person), asks (one {circle, message} per circle, in order, the name copied exactly), updates (five {day, caption, photo} for days 1, 8, 15, 22 and 30), style_note, goal_note and thank_you. The page re-checks every reply: the verdict against the rule, one ask per circle and one update per day, no number that is not in the input, no he/she or gendered word for you, no health facts, no first person outside the story, asks, captions and thank-you, and no internal field names in the prose.
Worked examples
Replies produced against the app's prompt for the four sample plans (made up; the full replies ship in example-result.js). Together they cover every verdict and every style fit.
Talking groups - on track, chevron ready in time
Input:
{
"month": "November",
"cause": "a local charity that runs free talking groups for people going through a hard time",
"why": "A friend found one of their groups when things were tough, and it helped.",
"currency": "$",
"goal": "400",
"gift": "20",
"growth": "steady",
"growth_label": "steady (about 0.35 mm a day)",
"style": "chevron",
"style_label": "a chevron (thick, covering the top lip)",
"channel": "chat",
"channel_label": "a group chat",
"circles": "[{\"name\":\"Work team\",\"size\":40},{\"name\":\"Five-a-side football\",\"size\":14},{\"name\":\"Family\",\"size\":18},{\"name\":\"Old uni friends\",\"size\":25}]",
"updates": "[{\"day\":1,\"stage\":\"clean-shaven start\"},{\"day\":8,\"stage\":\"itchy, patchy shadow\"},{\"day\":15,\"stage\":\"the awkward middle\"},{\"day\":22,\"stage\":\"the shape arriving\"},{\"day\":30,\"stage\":\"the finished moustache\"}]",
"figures": "{\"donors_needed\":\"20\",\"people\":\"97\",\"needed_pct\":\"21\",\"stretch_above\":\"25\",\"rethink_above\":\"50\",\"reach_day\":\"23\",\"shape_by\":\"24\",\"style_fit\":\"yes\",\"fallback_style\":\"\"}"
}
Reply:
{
"verdict": "on_track",
"title": "A chevron for the cause",
"summary": "You're growing a chevron moustache this November to raise money for a local charity that runs free talking groups for people going through a hard time. The goal fits well with the people you can ask, so things are looking good.",
"hook": "This November, your upper lip works for a good cause.",
"story": "This November I'm growing a moustache to raise money for a local charity that runs free talking groups for people going through a hard time. A friend found one of their groups when things were tough, and it helped. My goal is $400 - any amount helps, or just come laugh at my patchy upper lip along the way.",
"asks": [
{
"circle": "Work team",
"message": "I'm growing a moustache this November for a local charity that runs free talking groups for people going through a hard time. If the team wants to back me, $20 is a typical gift - but any amount, or just sharing this, helps just as much. Totally fine either way!"
},
{
"circle": "Five-a-side football",
"message": "I'm growing a moustache this November for a local charity that runs free talking groups for people going through a hard time. If the five-a-side group wants to sponsor me, $20 is a typical gift - but any amount, or just sharing this, helps just as much. No worries if not!"
},
{
"circle": "Family",
"message": "I'm growing a moustache this November for a local charity that runs free talking groups for people going through a hard time. If family wants to sponsor me, $20 is a typical gift - but any amount, or just sharing this, helps just as much. Either way, thank you!"
},
{
"circle": "Old uni friends",
"message": "I'm growing a moustache this November for a local charity that runs free talking groups for people going through a hard time. If any old uni friends want to sponsor me, $20 is a typical gift - but any amount, or just sharing this, helps just as much. No pressure!"
}
],
"updates": [
{
"day": 1,
"caption": "Clean shave, fresh start - the moustache era begins today! If you're thinking about sponsoring me this month, now's a great time to jump in.",
"photo": "Snap a clean-shaven selfie before it all begins."
},
{
"day": 8,
"caption": "It's itchy and patchy right now, but it's growing! If you can spare something for the cause, I'd really appreciate it.",
"photo": "Capture the itchy, patchy shadow stage."
},
{
"day": 15,
"caption": "Hitting the awkward middle stage now - equal parts fuzz and faith. A little sponsorship would help push through to the finish.",
"photo": "Show off the awkward in-between stage."
},
{
"day": 22,
"caption": "The shape is finally arriving - the chevron is starting to take hold! If you haven't yet, this is a great moment to pitch in.",
"photo": "Frame the chevron as it takes shape."
},
{
"day": 30,
"caption": "The moustache is finished - thank you so much for everything this month, whether you gave, shared, or just put up with the chevron! Now I get to decide if it stays or goes.",
"photo": "Show the finished chevron in full glory."
}
],
"style_note": "You're aiming for a chevron, and at this steady growth it should be taking shape by around day 23 - just before you need it. These are estimates, so the exact day may shift a little.",
"goal_note": "Your goal fits comfortably with the people you can ask this month. A good place to start would be your biggest circle, Work team, since reaching them first could get you a long way toward the goal quickly.",
"thank_you": "Thank you so much for sponsoring me, sharing this with others, and putting up with my moustache all through November. Whether you gave, shared, or just cheered me on, it meant a lot to have you along for this slightly fuzzy ride. I'm really grateful for every bit of support."
}
Climbing fund - a stretch, handlebar not this month (horseshoe instead)
Input:
{
"month": "November",
"cause": "new climbing shoes and coaching for the youth club's free climbing nights",
"why": "",
"currency": "$",
"goal": "1000",
"gift": "25",
"growth": "fast",
"growth_label": "fast (about 0.45 mm a day)",
"style": "handlebar",
"style_label": "a handlebar (long enough to curl the ends)",
"channel": "feed",
"channel_label": "a social feed",
"circles": "[{\"name\":\"Office\",\"size\":60},{\"name\":\"Climbing gym\",\"size\":35},{\"name\":\"Family\",\"size\":25}]",
"updates": "[{\"day\":1,\"stage\":\"clean-shaven start\"},{\"day\":8,\"stage\":\"itchy, patchy shadow\"},{\"day\":15,\"stage\":\"the awkward middle\"},{\"day\":22,\"stage\":\"the shape arriving\"},{\"day\":30,\"stage\":\"the finished moustache\"}]",
"figures": "{\"donors_needed\":\"40\",\"people\":\"120\",\"needed_pct\":\"34\",\"stretch_above\":\"25\",\"rethink_above\":\"50\",\"reach_day\":\"34\",\"shape_by\":\"24\",\"style_fit\":\"no\",\"fallback_style\":\"a horseshoe (down past the corners of the mouth)\"}"
}
Reply:
{
"verdict": "stretch",
"title": "A handlebar moustache for climbing nights",
"summary": "You're growing a handlebar this November to raise money for new climbing shoes and coaching for the youth club's free climbing nights. The goal is reachable, but most of your circles will need a nudge to get there.",
"hook": "November's moustache marathon: can your top lip keep up?",
"story": "This November I'm growing a handlebar moustache to raise money for new climbing shoes and coaching for the youth club's free climbing nights. My goal is $1000. If you can spare anything - or just want to watch my upper lip go through it - I'd love your support this month.",
"asks": [
{
"circle": "Office",
"message": "This November I'm growing a handlebar moustache for new climbing shoes and coaching at the youth club's free climbing nights. If you'd like to sponsor me, $25 is the suggested amount, but any amount - or just sharing this with the team - helps. Thanks for considering it!"
},
{
"circle": "Climbing gym",
"message": "This November I'm growing a handlebar moustache to raise money for new climbing shoes and coaching for the youth club's free climbing nights. I'd love your support - $25 is the suggested gift, but any amount or simply sharing helps just as much. Thank you!"
},
{
"circle": "Family",
"message": "This November I'm growing a handlebar moustache to raise money for new climbing shoes and coaching for the youth club's free climbing nights. If you can sponsor me, $25 is the suggested amount, but any amount, or just sharing this with family, helps. Thanks so much for thinking about it."
}
],
"updates": [
{
"day": 1,
"caption": "Clean slate! Today I start a month of growing a moustache for new climbing shoes and coaching at the youth club's free climbing nights. If you'd like to help, every bit counts.",
"photo": "Show a clean-shaven face as the month starts."
},
{
"day": 8,
"caption": "A week in and it's itchy and patchy - the glamorous stage of growing a moustache for the youth club's free climbing nights. Sponsoring me would make the itch worth it!",
"photo": "Capture the itchy, patchy shadow stage up close."
},
{
"day": 15,
"caption": "Officially in the awkward middle stage - I'm not quite anything yet, but still growing for the youth club's free climbing nights. A little support would help right now.",
"photo": "Document the awkward in-between stage with a grin."
},
{
"day": 22,
"caption": "The moustache is filling in, so these days I'm picturing a horseshoe shape rather than my original plan. Chipping in now helps even more.",
"photo": "Frame the moustache taking on its new shape."
},
{
"day": 30,
"caption": "My moustache is done, and it's decision time - keep it or shave it off tomorrow. Thank you for following along, and for anything you give to the youth club's free climbing nights.",
"photo": "Capture the finished moustache in its full glory."
}
],
"style_note": "The handlebar you picked likely won't fully take shape this month, even with fast growth, so a horseshoe is a more realistic goal to aim for. These are only estimates.",
"goal_note": "The goal is within reach, but it would help to ask more circles than these, so consider adding another circle or sending a follow-up ask later this month.",
"thank_you": "Thank you so much for sponsoring me, sharing this with others, and putting up with a whole month of moustache updates. It meant a lot to have your support, and I'm grateful to everyone who gave anything at all, or just cheered the moustache on from the sidelines."
}
Hospice appeal - rethink the goal, patchy growth (chevron instead)
Input:
{
"month": "November",
"cause": "the November appeal of the hospice in our town",
"why": "They looked after my grandad last spring and were kind to all of us.",
"currency": "£",
"goal": "3000",
"gift": "20",
"growth": "patchy",
"growth_label": "patchy (about 0.35 mm a day, uneven)",
"style": "walrus",
"style_label": "a walrus (long and bushy, over the lip)",
"channel": "chat",
"channel_label": "a group chat",
"circles": "[{\"name\":\"Book club\",\"size\":8},{\"name\":\"Neighbours\",\"size\":12}]",
"updates": "[{\"day\":1,\"stage\":\"clean-shaven start\"},{\"day\":8,\"stage\":\"itchy, patchy shadow\"},{\"day\":15,\"stage\":\"the awkward middle\"},{\"day\":22,\"stage\":\"the shape arriving\"},{\"day\":30,\"stage\":\"the finished moustache\"}]",
"figures": "{\"donors_needed\":\"150\",\"people\":\"20\",\"needed_pct\":\"750\",\"stretch_above\":\"25\",\"rethink_above\":\"50\",\"reach_day\":\"35\",\"shape_by\":\"24\",\"style_fit\":\"no\",\"fallback_style\":\"a chevron (thick, covering the top lip)\"}"
}
Reply:
{
"verdict": "rethink",
"title": "A moustache for the hospice appeal",
"summary": "You're growing a moustache this November for the hospice appeal in your town. The goal is far beyond the people you've listed, so it's worth rethinking the plan first.",
"hook": "A patchy moustache, a steady appeal this November.",
"story": "This November, I'm growing a moustache for the November appeal of the hospice in our town. They looked after my grandad last spring and were kind to all of us, so I wanted to do something back. I'm aiming to raise £3000, and any support, or even just sharing this, would mean a lot. Thank you for reading.",
"asks": [
{
"circle": "Book club",
"message": "This November, I'm growing a moustache for the November appeal of the hospice in our town. I'd love it if Book club could chip in, £20 is a typical gift, but any amount, or just sharing this, helps just as much. No pressure at all, fine to say no."
},
{
"circle": "Neighbours",
"message": "This November, I'm growing a moustache for the November appeal of the hospice in our town. If any of my Neighbours would like to sponsor me, £20 is the kind of amount I'm hoping for, though anything at all, or simply sharing this, makes a difference. No worries if it's not for you right now."
}
],
"updates": [
{
"day": 1,
"caption": "Starting from a totally clean lip, no moustache in sight yet! This November I'm doing it for the hospice appeal, so if you fancy sponsoring me, now's a great time to start.",
"photo": "Snap a clean-shaven selfie before the moustache journey begins."
},
{
"day": 8,
"caption": "I'm itchy and properly patchy today, but the moustache is coming along. If you can sponsor the hospice appeal now, even a small amount makes a real difference.",
"photo": "Photograph the patchy growth for a laugh."
},
{
"day": 15,
"caption": "I'm deep in the awkward middle now, somewhere between fuzz and a real moustache, and honestly a bit of a mess. If you've been meaning to sponsor the hospice appeal, this is a perfect excuse.",
"photo": "Capture the patchy, in-between moustache stage."
},
{
"day": 22,
"caption": "I'm getting a proper moustache now, and a chevron looks like the realistic shape I'll land on. If you can sponsor the hospice appeal before the final stretch, it would mean a lot to me.",
"photo": "Show off the moustache next to a chevron photo for reference."
},
{
"day": 30,
"caption": "That's a wrap, my moustache is done for November. Thank you for sponsoring the hospice appeal and putting up with it all month. It can go or stay.",
"photo": "Pose with the finished moustache before deciding its fate."
}
],
"style_note": "Your walrus probably won't take full shape this November, so aiming for a chevron instead looks realistic. With patchy growth, a shorter, tidier style usually reads better anyway. These are just estimates, of course.",
"goal_note": "The goal is far beyond the people you've listed so far, and Book club and Neighbours alone won't get you there. Try adding more circles to ask, or consider a smaller goal that better matches who you can reach.",
"thank_you": "Thank you so much for sponsoring me, sharing this with others, and putting up with my moustache all November. Whatever you gave, whether time, money or just a laugh at my patchy progress, means a lot to me. It's been a strange-looking month for a good cause, and I'm grateful you were part of it."
}
Animal shelter by email - on track exactly at the cut-off, horseshoe only near the end
Input:
{
"month": "November",
"cause": "the animal rescue shelter where I volunteer on weekends",
"why": "",
"currency": "€",
"goal": "300",
"gift": "15",
"growth": "steady",
"growth_label": "steady (about 0.35 mm a day)",
"style": "horseshoe",
"style_label": "a horseshoe (down past the corners of the mouth)",
"channel": "email",
"channel_label": "email",
"circles": "[{\"name\":\"Shelter volunteers\",\"size\":30},{\"name\":\"Choir\",\"size\":22},{\"name\":\"Family\",\"size\":30}]",
"updates": "[{\"day\":1,\"stage\":\"clean-shaven start\"},{\"day\":8,\"stage\":\"itchy, patchy shadow\"},{\"day\":15,\"stage\":\"the awkward middle\"},{\"day\":22,\"stage\":\"the shape arriving\"},{\"day\":30,\"stage\":\"the finished moustache\"}]",
"figures": "{\"donors_needed\":\"20\",\"people\":\"82\",\"needed_pct\":\"25\",\"stretch_above\":\"25\",\"rethink_above\":\"50\",\"reach_day\":\"29\",\"shape_by\":\"24\",\"style_fit\":\"late\",\"fallback_style\":\"a chevron (thick, covering the top lip)\"}"
}
Reply:
{
"verdict": "on_track",
"title": "A horseshoe for a good cause",
"summary": "This November you're growing a horseshoe moustache to raise money for the animal rescue shelter where you volunteer on weekends. The people you can ask comfortably cover the goal, so you're well placed to reach it.",
"hook": "A horseshoe moustache for a good November cause.",
"story": "This November, I'm growing a horseshoe moustache to raise money for the animal rescue shelter where I volunteer on weekends. I'm aiming for €300. If you can spare something to sponsor my moustache this month, I'd be grateful — and if not, following along and cheering me on means just as much.",
"asks": [
{
"circle": "Shelter volunteers",
"message": "Hi all, I'm growing a horseshoe moustache this November to raise money for the animal rescue shelter where I volunteer on weekends. If you'd like to sponsor me, €15 is a nice amount to start with, but any amount — or just sharing this with someone else — would mean a lot. Thank you!"
},
{
"circle": "Choir",
"message": "Hi everyone, this November I'm growing a horseshoe moustache to raise money for the animal rescue shelter where I volunteer on weekends. If you'd like to sponsor a few notes toward it, €15 is a nice starting amount, but any amount, or just sharing this along, helps just as much. Thank you for reading!"
},
{
"circle": "Family",
"message": "Hi everyone, this November I'm growing a horseshoe moustache to raise money for the animal rescue shelter where I volunteer on weekends. If you'd like to sponsor me, €15 is a nice amount to start with, but any amount, or simply cheering me on, would mean a lot. Thanks for reading!"
}
],
"updates": [
{
"day": 1,
"caption": "Clean-shaven and starting fresh — I'm right at the beginning. If you'd like to help the shelter before the moustache even gets going, now's a great time to chip in.",
"photo": "Show a clean-shaven face before the growing starts."
},
{
"day": 8,
"caption": "The itchy, patchy shadow stage has arrived, and I will not lie, it is not photogenic yet. If you want to sponsor the awkward phase of this moustache, now's as good a time as any.",
"photo": "Capture the patchy, itchy shadow in progress."
},
{
"day": 15,
"caption": "I'm officially in the awkward middle now, somewhere between shadow and shape. If you haven't sponsored yet, this in-between stage is a good moment to pitch in for the shelter.",
"photo": "Capture the awkward in-between stage up close."
},
{
"day": 22,
"caption": "The horseshoe is starting to take shape, even if it's not quite there yet. I'm hoping it comes together fully before the month is out, and any support now helps the shelter either way.",
"photo": "Show the moustache edges starting to curve downward."
},
{
"day": 30,
"caption": "This is it — the finished horseshoe, fully grown at last. Thank you so much for every bit of support this month; now I just have to decide if it stays or goes.",
"photo": "Show the finished horseshoe in full, ready for its photo."
}
],
"style_note": "Your horseshoe will likely only take its full shape near the very end of the month, so expect the earlier photos to be more about the journey than the final look. These are only estimates.",
"goal_note": "Your goal fits comfortably within the circles you've listed. A good next step is reaching out to your biggest circles first — Shelter volunteers and Family — before moving on to the rest.",
"thank_you": "Thank you so much for sponsoring, sharing and putting up with my moustache updates this November. Whether you gave, forwarded this along, or just cheered me on, I noticed and appreciated every bit of it. The shelter and I are both grateful, and the moustache can now stay or go."
}
Build the input yourself
Load the script in node (global.window = global; require("./lipkit.js")), then:
const K = LipKit;
const form = K.form({ cause: "the winter appeal of our community centre", why: "", currency: "$", goal: "500", gift: "20",
growth: "steady", style: "chevron", channel: "chat", circles: "Work team, 30\nFamily, 12\nRunning club, 40" });
const r = K.read(form); // r.fig: donors_needed, people, needed_pct, reach_day, style_fit, fallback, verdict
const built = K.buildInput(form);
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.csv(built, reply), K.ics(built, reply) // the update calendar and the five reminders
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://upper-lip.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://upper-lip.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"]
// upper-lip.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://upper-lip.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://upper-lip.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
}
// UpperLip.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 UpperLip {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://upper-lip.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://upper-lip.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://upper-lip.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://upper-lip.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":"upper-lip"}' | 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": "upper-lip"})["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: "upper-lip" })).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": "upper-lip"}, 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", "upper-lip"), 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: "upper-lip" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "upper-lip"])["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 = "upper-lip" }))!["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 - {"month": "November", "cause": "a local charity that runs free talking groups for people going through a hard time", "why": "A friend found one of their groups when things were tough, and it helped.", "currency": "$", "goal": "400", "gift": "20", "growth": "steady", "growth_label": "steady (about 0.35 mm a day)", "style": "chevron", "style_label": "a chevron (thick, covering the top lip)", "channel": "chat", "channel_label": "a group chat", "circles": "[...]", "updates": "[...]", "figures": "{...}"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"month": "November", "cause": "a local charity that runs free talking groups for people going through a hard time", "why": "A friend found one of their groups when things were tough, and it helped.", "currency": "$", "goal": "400", "gift": "20", "growth": "steady", "growth_label": "steady (about 0.35 mm a day)", "style": "chevron", "style_label": "a chevron (thick, covering the top lip)", "channel": "chat", "channel_label": "a group chat", "circles": "[...]", "updates": "[...]", "figures": "{...}"}
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")); // {month, cause, why, currency, goal, gift, growth, growth_label, style, style_label, channel, channel_label, circles, updates, figures, ... (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 input: a retry with the same key never bills twice. The page uses upper-lip:plan:<inputHash>:a<attempt> (the one task is plan), 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="upper-lip:plan:$(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 = "upper-lip:plan:" + 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("title"))
import { createHash } from "node:crypto";
const key = `upper-lip:plan:${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.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "upper-lip:plan:" + 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 = "upper-lip:plan:" + 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 = "upper-lip:plan:#{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["title"]].inspect
$key = "upper-lip:plan:" . 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["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"upper-lip:plan:{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: upper-lip:plan:$(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: {"month": "November", "cause": "a local charity that runs free talking groups for people going through a hard time", "why": "A friend found one of their groups when things were tough, and it helped.", "currency": "$", "goal": "400", "gift": "20", "growth": "steady", "growth_label": "steady (about 0.35 mm a day)", "style": "chevron", "style_label": "a chevron (thick, covering the top lip)", "channel": "chat", "channel_label": "a group chat", "circles": "[...]", "updates": "[...]", "figures": "{...}"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "upper-lip:plan:" + 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": `upper-lip:plan:${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", "upper-lip:plan:"+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", "upper-lip:plan:" + 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" => "upper-lip:plan:#{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: upper-lip:plan:" . 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", $"upper-lip:plan:{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);