← Wedding Vows /API

Wedding Vows over HTTP

Base URL https://api.skillsafe.ai/v1/app-api. Every response is the envelope {"ok":true,"data":{...}} or {"ok":false,"error":{"code","message","details"}}. A run is metered against the caller's SkillSafe credits; /estimate and /me are free.

The request body

{
  "mode": "write",
  "kind": "vows",
  "speaker": {
    "name": "Rosa",
    "role": "partner",
    "relation_note": ""
  },
  "couple": {
    "one": "Rosa",
    "two": "Dan"
  },
  "about": "They met at the launderette on Cardigan Road in 2011. He fixed her bike chain with a teaspoon in the car park. She has called him Teaspoon ever since.",
  "about_clipped": 0,
  "particulars": [
    {
      "kind": "name",
      "text": "Rosa"
    },
    {
      "kind": "name",
      "text": "Dan"
    },
    {
      "kind": "place",
      "text": "at the launderette"
    },
    {
      "kind": "place",
      "text": "on Cardigan Road"
    },
    {
      "kind": "time",
      "text": "2011"
    },
    {
      "kind": "act",
      "text": "fixed her bike chain"
    },
    {
      "kind": "said",
      "text": "Teaspoon"
    }
  ],
  "must_include": [
    "A promise about the letters"
  ],
  "avoid": [
    "No jokes about the chip shop smell"
  ],
  "tone": "plain",
  "length": "medium",
  "word_budget": 170,
  "occasion": "Sixty people in a rugby club function room in November.",
  "prescan": {
    "particular_count": 7,
    "thin": false,
    "notes": [
      "one object that belongs to the two of them"
    ]
  }
}

One JSON object, posted as the whole body. There is no task field and no wrapper: an input wrapper returns 200 while hiding the request from the model.

FieldTypeMeaning
modestringwrite or revise
kindstringvows, toast, reading or opening
speakerobject{name, role, relation_note}. role is one of partner, best-person, maid-of-honour, parent, sibling, friend, officiant, other
coupleobject{one, two} - how to refer to each of them
aboutstringthe description of the two of them, in the requester's words. This is the material everything else is built from
about_clippednumbercharacters removed from the middle if the description exceeded 6000; 0 otherwise
particularsarray{kind, text} objects extracted in the browser. kind is one of name, said, time, place, object, act
must_includearraystrings, max 8
avoidarraystrings, max 8
tonestringplain, warm, funny, formal or lyrical
lengthstringshort, medium or long
word_budgetnumbertarget word count for piece, derived from kind and length
occasionstringthe shape of the day
prescanobject{particular_count, thin, notes}. thin being true changes what the model does - see the thin-input section of the prompt
previousobjectrevise only: {piece, change}

The output contract

The model returns one JSON object and nothing else. This is what the renderer parses; take it from here rather than from intent.

{
  "kind": "vows",
  "title": "The teaspoon",
  "piece": [
    "First line or paragraph.",
    "Second line or paragraph."
  ],
  "delivery": [
    "Two to four notes on saying it out loud."
  ],
  "drew_on": [
    "a detail from the request, in the requester's own words"
  ],
  "left_out": [],
  "alternates": [
    {
      "replaces": "a line exactly as it stands in piece",
      "with": "a different line",
      "why": "one clause"
    }
  ],
  "from_the_writer": "One short paragraph to whoever will speak this."
}

A refusal returns a different shape, and the renderer has its own path for it:

{
  "kind": "declined",
  "title": "short label",
  "declined_why": "one or two plain sentences",
  "declined_offer": "what the desk can write instead"
}

Two constraints worth knowing before you parse it. Every alternates[].replaces value is meant to appear verbatim in one of the piece entries - if it does not, treat the alternate as unusable rather than trying to fuzzy-match it. And left_out is legitimately empty; an empty array means everything supplied was used, not that the field is missing.

1. Get a token

Every call needs Authorization: Bearer <token>. The easiest way to get one is the token page, which will mint a guest token or hand you your signed-in one, with a copyable shell export. Guest tokens can call /me and /estimate; a run needs a signed-in token with credits.

2. Check who you are

Returns exactly three fields: subject_type, subject_id and credits. There is no username, email or name. The signed-in test is subject_type === "user"; a guest token returns "guest", and a 401 here on a cold start simply means no token has been minted yet.

TOKEN="YOUR_TOKEN"
curl -sS -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json'

3. Price it, free

Returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. No charge and no job.

This endpoint performs no validation of the body whatsoever. A bare string, a number, null and [] all return ok:true with a well-formed estimate and a correct model binding. So a successful estimate proves your token and the app's model binding, and proves nothing at all about whether your request body is the right shape. Validate the body on your own side; there is no server-side signal, ever.

TOKEN="YOUR_TOKEN"
curl -sS -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"mode": "write", "kind": "vows", "speaker": {"name": "Rosa", "role": "partner", "relation_note": ""}, "couple": {"one": "Rosa", "two": "Dan"}, "about": "They met at the launderette on Cardigan Road in 2011. He fixed her bike chain with a teaspoon in the car park. She has called him Teaspoon ever since.", "about_clipped": 0, "particulars": [{"kind": "name", "text": "Rosa"}, {"kind": "name", "text": "Dan"}, {"kind": "place", "text": "at the launderette"}, {"kind": "place", "text": "on Cardigan Road"}, {"kind": "time", "text": "2011"}, {"kind": "act", "text": "fixed her bike chain"}, {"kind": "said", "text": "Teaspoon"}], "must_include": ["A promise about the letters"], "avoid": ["No jokes about the chip shop smell"], "tone": "plain", "length": "medium", "word_budget": 170, "occasion": "Sixty people in a rugby club function room in November.", "prescan": {"particular_count": 7, "thin": false, "notes": ["one object that belongs to the two of them"]}}'

4. Run it, and poll

Returns {"job_id": "job_..."}. Poll GET /jobs/{job_id} until status is succeeded or failed; the finished job carries output, charged_credits and, if the balance covered only part of a run this long, truncated: true.

Send Idempotency-Key on every run. A retry after a network blip must reuse the first attempt's key or it bills twice.

TOKEN="YOUR_TOKEN"
curl -sS -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"mode": "write", "kind": "vows", "speaker": {"name": "Rosa", "role": "partner", "relation_note": ""}, "couple": {"one": "Rosa", "two": "Dan"}, "about": "They met at the launderette on Cardigan Road in 2011. He fixed her bike chain with a teaspoon in the car park. She has called him Teaspoon ever since.", "about_clipped": 0, "particulars": [{"kind": "name", "text": "Rosa"}, {"kind": "name", "text": "Dan"}, {"kind": "place", "text": "at the launderette"}, {"kind": "place", "text": "on Cardigan Road"}, {"kind": "time", "text": "2011"}, {"kind": "act", "text": "fixed her bike chain"}, {"kind": "said", "text": "Teaspoon"}], "must_include": ["A promise about the letters"], "avoid": ["No jokes about the chip shop smell"], "tone": "plain", "length": "medium", "word_budget": 170, "occasion": "Sixty people in a rugby club function room in November.", "prescan": {"particular_count": 7, "thin": false, "notes": ["one object that belongs to the two of them"]}}'

# the reply is {"data":{"job_id":"job_..."}} - poll it:
curl -sS https://api.skillsafe.ai/v1/app-api/jobs/job_xxx -H "Authorization: Bearer $TOKEN"

5. Or stream it

POST /run-stream returns text/event-stream.

Get the wire format right. Frames are separated by a blank line. The event NAME is on its own event: line and the JSON payload on a data: line - there is no type field inside the data object, and a parser written to dispatch on one will never fire:

event: delta
data: {"text": "..."}

event: done
data: {"job_id": "job_...", "status": "succeeded", "charged_credits": 812, "output": "{...}"}

Event names are job, delta, done, pending and error. A stream can stop mid-object, so keep the accumulated text and repair it rather than discarding it - closing the open brackets over the last complete member recovers a usable object from almost any cut point.

TOKEN="YOUR_TOKEN"
curl -sS -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: text/event-stream' \
  -d '{"mode": "write", "kind": "vows", "speaker": {"name": "Rosa", "role": "partner", "relation_note": ""}, "couple": {"one": "Rosa", "two": "Dan"}, "about": "They met at the launderette on Cardigan Road in 2011. He fixed her bike chain with a teaspoon in the car park. She has called him Teaspoon ever since.", "about_clipped": 0, "particulars": [{"kind": "name", "text": "Rosa"}, {"kind": "name", "text": "Dan"}, {"kind": "place", "text": "at the launderette"}, {"kind": "place", "text": "on Cardigan Road"}, {"kind": "time", "text": "2011"}, {"kind": "act", "text": "fixed her bike chain"}, {"kind": "said", "text": "Teaspoon"}], "must_include": ["A promise about the letters"], "avoid": ["No jokes about the chip shop smell"], "tone": "plain", "length": "medium", "word_budget": 170, "occasion": "Sixty people in a rugby club function room in November.", "prescan": {"particular_count": 7, "thin": false, "notes": ["one object that belongs to the two of them"]}}'

Errors

CodeHTTPWhat to do
UNAUTHORIZED401No token, or it expired. Mint a new one. On a first-ever call this is the expected answer.
FORBIDDEN403The token belongs to another app. Tokens are scoped per app slug.
INSUFFICIENT_CREDITS402Balance below min_credits. Estimate first and compare against /me.
VALIDATION_ERROR400Malformed JSON body. Note this does not fire on /estimate, which validates nothing.
RATE_LIMITED429Back off and retry. Do not tight-loop.
JOB_FAILED200The envelope is ok but the job's status is failed; read error on the job.

What the app does with the reply

The browser reconciles the model's output against the request before showing it, and you may want to do the same: check every drew_on entry against your own about text, and check every capitalised name in piece against the request. A name in the finished piece that appears nowhere in the request is the failure that matters here - it is a person saying something untrue at the front of a room.