← Costume Squad /API

Costume Squad 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" } }. /guest, /me and /estimate are free; a run is metered against the caller's SkillSafe credits.

The request body

One flat JSON object, posted as the whole body, with scalar fields only. Do not wrap it in {"input": ...}: a wrapper is accepted with a 200 and hides every field from the model. Costume Squad is a two-lane app, and the lane is chosen by task:

FieldTypeMeaning
taskstring, requiredpitch: three group costume themes, each with casting. build: a full per-person plan for one chosen theme. Send this first; every other field is read in its light.
membersstring, requireda JSON-encoded array (a string, not an array) of {name, budget, owns, limits, notes}. budget is a number in USD or null; owns and limits are arrays of strings. At most 12 people, and names must be unique: casting refers to people by name.
occasionstringe.g. house party, bar or club, costume contest, office, trick-or-treating with kids, school or family event, outdoor event
vibestringtrending | spooky | funny | cute | clever | mythic
seedsstringoptional comma-separated theme ideas the group already likes
themestringbuild only: the chosen group theme, usually a pitches[].theme from an earlier pitch run
castingstringbuild only, optional: JSON-encoded [{member, character}]. Omit it and the model casts
days_leftnumberwhole days until October 31; drives the shopping timeline
prescanstringJSON-encoded facts the web app computes before sending: member_count, budget_total, missing_budget, empty_closets, dark_closets, seeds_that_fit, clipped. Optional for API callers

Encode members twice. Build the array in your language, serialize it to a string, put that string in the body, then serialize the body. On the wire it looks like "members": "[{\"name\":\"Maya\",...}]". Every sample below does this with the language's own JSON encoder; never hand-write the escapes. Check the 12-person cap and name uniqueness on your side before sending.

Example: pitch, three friends

Maya ($30, owns an orange cardigan and chunky glasses), Theo ($20, brown cargo pants and a black hoodie, no face paint) and Sam ($15, a brown fleece onesie, hates masks), going to a house party, 13 days out. This is the exact body on the wire:

{
  "task": "pitch",
  "members": "[{\"name\":\"Maya\",\"budget\":30,\"owns\":[\"orange cardigan\",\"chunky glasses\"],\"limits\":[],\"notes\":\"\"},{\"name\":\"Theo\",\"budget\":20,\"owns\":[\"brown cargo pants\",\"black hoodie\"],\"limits\":[\"no face paint\"],\"notes\":\"\"},{\"name\":\"Sam\",\"budget\":15,\"owns\":[\"brown fleece onesie\"],\"limits\":[\"hates masks\"],\"notes\":\"\"}]",
  "occasion": "house party",
  "vibe": "trending",
  "seeds": "Scooby-Doo gang",
  "days_left": 13
}

The members string, decoded:

[
  {"name": "Maya", "budget": 30, "owns": ["orange cardigan", "chunky glasses"], "limits": [], "notes": ""},
  {"name": "Theo", "budget": 20, "owns": ["brown cargo pants", "black hoodie"], "limits": ["no face paint"], "notes": ""},
  {"name": "Sam", "budget": 15, "owns": ["brown fleece onesie"], "limits": ["hates masks"], "notes": ""}
]

Example: build, a couple

Lena ($35, white slip dress, black trench coat, combat boots, no wigs) and Marcus ($35, black suit, yellow dish gloves, wears glasses) have picked a theme and a casting, heading to a bar:

{
  "task": "build",
  "members": "[{\"name\":\"Lena\",\"budget\":35,\"owns\":[\"white slip dress\",\"black trench coat\",\"combat boots\"],\"limits\":[\"no wigs\"],\"notes\":\"\"},{\"name\":\"Marcus\",\"budget\":35,\"owns\":[\"black suit\",\"yellow dish gloves\"],\"limits\":[\"wears glasses\"],\"notes\":\"\"}]",
  "theme": "Cyclops & Jean Grey (X-Men)",
  "casting": "[{\"member\":\"Lena\",\"character\":\"Jean Grey\"},{\"member\":\"Marcus\",\"character\":\"Cyclops\"}]",
  "occasion": "bar or club",
  "vibe": "spooky",
  "days_left": 13
}

The output contract

The finished job carries output; output.output is the model's reply text, and that text is one JSON object. Parse it a second time. Its lane field tells you which shape you have. Samples are truncated (...).

pitch – exactly three pitches

{
  "lane": "pitch",
  "pitches": [
    {
      "id": "p1",
      "theme": "Mystery Inc. (Scooby-Doo)",
      "hook": "Two of the three costumes are already hanging in a closet.",
      "trend": null,
      "family_read": "Every age in the room names it on sight.",
      "casting": [
        { "member": "Maya", "character": "Velma", "why": "Orange cardigan and chunky glasses are the costume." },
        { "member": "Theo", "character": "Shaggy", "why": "Brown cargo pants plus a green tee; no face paint." },
        { "member": "Sam", "character": "Scooby-Doo", "why": "Brown fleece onesie, ears and a collar; no mask." }
      ],
      "est_cost": [ { "member": "Maya", "usd": 6 }, { "member": "Theo", "usd": 12 }, { "member": "Sam", "usd": 10 } ],
      "difficulty": "easy",
      "risks": [ "Shaggy needs a green tee; Theo owns a black hoodie, not green." ]
    },
    { "id": "p2", ... },
    { "id": "p3", ... }
  ],
  "notes": [ "All three pitches stay inside every budget." ]
}

trend is a short note on why the theme is current, or null. difficulty is easy, medium or hard. Each casting[].member and est_cost[].member is a name from your members. To go deeper on one pitch, send its theme and casting back as a build request.

build – one plan per person

{
  "lane": "build",
  "theme": "Cyclops & Jean Grey (X-Men)",
  "group_read": "Red visor plus a green-and-gold silhouette reads as a pair across a dark bar.",
  "members": [
    {
      "member": "Marcus",
      "character": "Cyclops",
      "silhouette": "Dark suit, ruby visor line across the eyes, yellow hands.",
      "shape": { "head": "flat", "body": "tall", "accent": "mask" },
      "colors": [
        { "name": "suit black", "hex": "#1B1B1F", "role": "primary" },
        { "name": "visor ruby", "hex": "#C8102E", "role": "accent" }
      ],
      "signature": "The visor: a red strip worn over his own glasses.",
      "closet": [ "black suit", "yellow dish gloves" ],
      "buy": [ { "item": "red acrylic sheet for the visor", "usd": 8, "where": "craft store" } ],
      "diy": [ "Cut the visor to clip onto his glasses frame." ],
      "face_hair": "None needed; keep his own glasses on.",
      "reads": { "silhouette": "...", "color": "...", "detail": "..." },
      "dim_light": "Add red glow tape inside the visor edge.",
      "comfort": "Visor clips off for drinks."
    },
    { "member": "Lena", "character": "Jean Grey", ... }
  ],
  "lineup": [ "Lena", "Marcus" ],
  "timeline": [ { "days_before": 10, "task": "Order the acrylic sheet", "who": "Marcus" }, ... ],
  "warnings": [ "No wigs: Jean Grey's red hair is done with a one-night spray tint or skipped." ]
}

shape is a closed vocabulary the app draws figures from: head is round|square|point|flat, body is round|square|triangle|tall, accent is hat|cape|ears|horns|wings|staff|mask|wig|tail|none. colors[].role is primary|secondary|accent. closet lists items the person already owns; buy is the shopping list with usd and where. timeline[].days_before counts back from October 31.

declined

Returned instead of a pitch or build when a request uses a real ethnicity, nationality, religion or culture as a costume:

{
  "lane": "declined",
  "reason": "A real culture is not a costume.",
  "alternative": "A group of fictional sky pirates keeps the bright colors and the props."
}

Handle all three shapes. A declined run is a successful job (status succeeded) and is billed like any other.

Credits

Costs are in credits: 10,000 credits = $1. The model is bound to the gpt-terra tier alias, which /estimate reports as model_alias alongside the concrete model it resolves to today. hold_credits is a reservation taken when a run starts, not the price. The real cost is charged_credits on the finished job, after settlement; the unused part of the hold is released. If the balance ran out mid-reply the job carries truncated: true and the JSON text may be cut short – check it before parsing.

1. Get a token

Every call except /guest needs Authorization: Bearer <token>. For runs, get your token from the token page: sign in there and copy the signed-in token. For the free calls only, POST /guest with {"slug":"costume-squad"} mints a guest token scoped to this app; the slug travels in that body, and no later path names the app. Guest tokens can call /me and /estimate; a run needs a signed-in token with credits. Each tab below defines the small call helper the later steps reuse.

BASE=https://api.skillsafe.ai/v1/app-api

# Free calls only (/me, /estimate): mint a guest token for this app.
# No Authorization header; the slug goes in the BODY.
curl -sS -X POST "$BASE/guest" \
  -H 'Content-Type: application/json' \
  -d '{"slug":"costume-squad"}'
# -> {"ok":true,"data":{"token":"...","guest_id":"..."}}

# Runs need a signed-in token with credits. Copy it from
# https://costume-squad.skillsafe.ai/tokens.html and paste it here:
TOKEN="YOUR_TOKEN"

2. Check who you are

GET /me returns subject_type (user or guest), subject_id and credits. There is no name or email. A 401 here before any token exists is the expected answer.

curl -sS "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","subject_id":"...","credits":48210}}

3. Price it, free

POST /estimate takes the same body as a run and returns hold_credits, min_credits, model, model_alias (gpt-terra) and markup_bps. No charge and no job. It does not validate the body: a 200 proves your token and the model binding, not that members is encoded correctly. These samples build the pitch body that step 4 reuses.

PITCH='{"task":"pitch","members":"[{\"name\":\"Maya\",\"budget\":30,\"owns\":[\"orange cardigan\",\"chunky glasses\"],\"limits\":[],\"notes\":\"\"},{\"name\":\"Theo\",\"budget\":20,\"owns\":[\"brown cargo pants\",\"black hoodie\"],\"limits\":[\"no face paint\"],\"notes\":\"\"},{\"name\":\"Sam\",\"budget\":15,\"owns\":[\"brown fleece onesie\"],\"limits\":[\"hates masks\"],\"notes\":\"\"}]","occasion":"house party","vibe":"trending","seeds":"Scooby-Doo gang","days_left":13}'

curl -sS -X POST "$BASE/estimate" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d "$PITCH"
# -> {"ok":true,"data":{"hold_credits":...,"min_credits":...,"model":"...",
#                      "model_alias":"gpt-terra","markup_bps":...}}

4. Run it, and poll

POST /run returns {"job_id": "job_..."}. Poll GET /jobs/{job_id} until status is succeeded or failed. The finished job carries output (with the reply text in output.output), charged_credits and, if the balance cut the reply short, truncated: true.

Send an Idempotency-Key header on every run. A retry after a timeout must reuse the first attempt's key, or it starts and bills a second job.

# $PITCH comes from step 3. Make the key once; reuse it if you retry the POST.
KEY=$(uuidgen)
JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $KEY" \
  -d "$PITCH" | jq -r .data.job_id)

while :; do
  J=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  S=$(echo "$J" | jq -r .data.status)
  [ "$S" = succeeded ] || [ "$S" = failed ] && break
  sleep 2
done

echo "$J" | jq '.data | {status, charged_credits, truncated}'
echo "$J" | jq -r '.data.output.output' | jq .   # the model reply is a JSON string

5. Or stream it

POST /run-stream takes the same body and answers with text/event-stream. 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 payload:

event: job
data: {"job_id": "job_..."}

event: delta
data: {"text": "{\"lane\":\"build\",\"theme\":..."}

event: tick
data: {}

event: done
data: {"job_id": "job_...", "status": "succeeded", "charged_credits": ..., "output": {"output": "{\"lane\":\"build\",...}"}}

Events are job (the job id), delta (a piece of reply text), tick (a keep-alive with no text; some clients receive only ticks and no deltas), done and error. Treat done as the source of truth: its output.output is the complete JSON text, so parse that rather than the concatenated deltas. These samples stream the build example.

BUILD='{"task":"build","members":"[{\"name\":\"Lena\",\"budget\":35,\"owns\":[\"white slip dress\",\"black trench coat\",\"combat boots\"],\"limits\":[\"no wigs\"],\"notes\":\"\"},{\"name\":\"Marcus\",\"budget\":35,\"owns\":[\"black suit\",\"yellow dish gloves\"],\"limits\":[\"wears glasses\"],\"notes\":\"\"}]","theme":"Cyclops & Jean Grey (X-Men)","casting":"[{\"member\":\"Lena\",\"character\":\"Jean Grey\"},{\"member\":\"Marcus\",\"character\":\"Cyclops\"}]","occasion":"bar or club","vibe":"spooky","days_left":13}'

# -N turns off buffering so frames print as they arrive
curl -N -sS -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: text/event-stream' \
  -d "$BUILD"

Errors

A failure is { "ok": false, "error": { "code", "message" } } with a matching HTTP status. Branch on the status first and error.code second.

HTTPCodeWhat to do
400VALIDATION_ERRORThe body is malformed. Check that members and casting are strings holding JSON, not raw arrays.
401UNAUTHORIZEDNo token, or the app session expired. Mint a new one (guest) or copy a fresh one from the token page.
402INSUFFICIENT_CREDITSBalance below min_credits. Compare /estimate with /me, or top up. Guest tokens hit this on any run.
404–Wrong path or an unknown job_id. Paths sit directly under /v1/app-api; there is no /apps/{slug}/ segment.
409–Idempotency conflict: the same Idempotency-Key was sent with a different body, or while the first request is still in flight. Reuse a key only for a byte-identical retry.
429RATE_LIMITEDBack off and retry with the same idempotency key. Do not tight-loop.
5xx–Platform-side failure. Retry a run with the same key so it cannot bill twice.

A job can also end with status: "failed" inside an ok: true envelope; read error on the job.