Back to Dating Profile Review
Get a token

Drive Dating Profile Review from your own code

Everything the web app does goes through one public surface. Base URL:

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

Every request carries Authorization: Bearer <token> and Content-Type: application/json. Every response is a JSON envelope: {"data": ...} on success and {"error": {"code", "message", "status", "details"}} on failure. Read error.code, not the HTTP status alone.

The runs are served by gpt-terra, which resolves to gpt-5.6-terra, at markup_bps 1000 and price_credits 0 — there is no per-call surcharge on top of what the run itself costs. /estimate reports all of that back to you before you spend anything.

There is no X-App-Slug header, and the run body is not wrapped. The body of /estimate, /run and /run-stream is the input object itself. Wrapping it as {"input": {...}} does not fail loudly — it returns 200 with a job that runs, because the wrapper becomes a single opaque field and every one of your fields, task included, is hidden from the model. You get a fluent answer to a question you did not ask. Send the object flat.

The task field: two lanes, and it is required

Read this before anything else on the page. Dating Profile Review is not one contract with optional fields; it is two lanes behind one endpoint, and task is what selects one. It decides which other fields are required, what the reply contains, and what the run is priced at. Omit it and the lane router has to guess from the rest of the body, which is a coin toss you are paying for.

taskWhose writingAlso requiredReply carries
"profile" The caller's own dating profile. profile, or about_me when they have not written one yet. reads_as, gap, lines, hooks, rewrite, facts_used, cut
"opener" Text pasted from somebody else's profile. their_profile, and a count of how many openers to write. reads_as, openers, avoid

Both lanes take tone (one of plain, warm, dry, playful), a scan object your client measures before sending, and a shape string. Both replies carry lane and route_to_help. Either lane can answer with the refusal shape instead of its normal reply, and that is a success, not an error.

task: "profile" — coach the caller's own profile

The lane that reads what a stranger receives. The reply quotes the caller's own lines back at them with a verdict on each, and the rewrite is built only from facts that were in the input. Nothing in it is allowed to be invented, which is why facts_used exists: it is the list your client checks the rewrite against.

FieldTypeRequiredWhat it is
taskstringyesExactly "profile".
profilestringyes, unless about_meThe caller's current profile text, verbatim, newlines and all. May be empty when they have not written one.
about_mestringwhen profile is emptyFree text about themselves. This is what the lane works from when there is no profile yet, and it is the difference between a useful first draft and a generic one.
platformstringno"hinge", "bumble", "tinder", "other" or "".
seekingstringnoWhat they say they are looking for, in their words, or "".
tonestringyes"plain", "warm", "dry" or "playful".
scanobjectyesWhat your client measured over profile before sending. See below.
shapestringyes"profile_full", "profile_sparse" or "clipped".

A complete profile request body. This is the whole body — nothing wraps it.

{
  "task": "profile",
  "profile": "Just a guy who loves to laugh and is living life to the fullest.\nFoodie, gym rat, avid traveller.\nLooking for my partner in crime.\nI make a decent lamb ragu and I have been rereading Gilead since February.\nSwipe left if you are negative.",
  "about_me": "",
  "platform": "hinge",
  "seeking": "something that turns into a Sunday habit",
  "tone": "dry",
  "scan": {
    "summary": {
      "lines": 5,
      "words": 45,
      "specific_line_ratio": 0.2,
      "claim_lines": 3,
      "empty_lines": 0,
      "demand_lines": 1,
      "worn_phrases": 6,
      "hooks": 2
    },
    "cliches": [
      {"phrase": "loves to laugh", "tier": 3, "instead": "what makes you laugh that other people find odd?"},
      {"phrase": "living life to the fullest", "tier": 3, "instead": "name one week this year that was full"},
      {"phrase": "foodie", "tier": 3, "instead": "name the dish and where you had it"},
      {"phrase": "gym rat", "tier": 2, "instead": "name the lift or the class or the time you go"},
      {"phrase": "avid traveller", "tier": 3, "instead": "name the last one"},
      {"phrase": "partner in crime", "tier": 3, "instead": "what would you actually be doing together on a Saturday?"}
    ],
    "hooks": [
      {"detail": "lamb ragu", "pred": "make", "context": "I make a decent lamb ragu"},
      {"detail": "Gilead", "pred": "read", "context": "I have been rereading Gilead since February"}
    ]
  },
  "shape": "profile_full"
}

And the reply it returns. One JSON object, no prose around it, no code fence. lines[].verdict is one of working, generic, claim, risky, empty, and fix is null when the advice is to cut the line rather than rewrite it.

{
  "lane": "profile",
  "reads_as": "A friendly, unobjectionable stranger who has said he is enthusiastic without saying what about. Four of the five lines would sit on anybody else's profile without anyone noticing. The fifth is a different person entirely: someone who cooks a particular thing and is slow through a particular book.",
  "gap": "You meant easy-going and open. What arrives is someone who has not committed to anything a reader could answer, followed by one line proving you could have.",
  "lines": [
    {
      "quote": "Just a guy who loves to laugh and is living life to the fullest.",
      "verdict": "generic",
      "why": "Two of the most common phrases on the platform in one sentence; nobody can disagree with it, so nobody can reply to it.",
      "fix": null
    },
    {
      "quote": "Foodie, gym rat, avid traveller.",
      "verdict": "claim",
      "why": "Three categories with no instance in any of them, and the ragu line proves you have the instances.",
      "fix": "Three dinners a week end in the same pan. The fourth is ambitious and occasionally a mistake."
    },
    {
      "quote": "Looking for my partner in crime.",
      "verdict": "generic",
      "why": "It names a role rather than a Saturday, so there is nothing to picture and nothing to say yes to.",
      "fix": null
    },
    {
      "quote": "I make a decent lamb ragu and I have been rereading Gilead since February.",
      "verdict": "working",
      "why": "Two checkable details and an admission of slowness; this is the only line a stranger can answer.",
      "fix": null
    },
    {
      "quote": "Swipe left if you are negative.",
      "verdict": "risky",
      "why": "A rule aimed at people who are not reading it, and it spends the closing line on what you do not want.",
      "fix": null
    }
  ],
  "hooks": [
    {
      "detail": "lamb ragu",
      "why_it_works": "A reader who cooks has an immediate move: ask whether it is a two-hour ragu or a five-hour one."
    },
    {
      "detail": "rereading Gilead since February",
      "why_it_works": "The date is the hook, not the book. Anyone slow through something themselves recognises the confession."
    }
  ],
  "rewrite": "Three dinners a week end in the same pan; the fourth is ambitious and occasionally a mistake. The lamb ragu is the one I would cook for someone.\nI have been rereading Gilead since February, which tells you my pace.\nI would like this to turn into a Sunday habit.",
  "facts_used": ["lamb ragu", "rereading Gilead since February", "wants a Sunday habit"],
  "cut": [
    {
      "quote": "Just a guy who loves to laugh and is living life to the fullest.",
      "why": "It is the opening line and it is the least informative sentence in the profile."
    },
    {
      "quote": "Swipe left if you are negative.",
      "why": "It filters nobody and sets the last note on a complaint."
    }
  ],
  "route_to_help": false
}

task: "opener" — write openers from someone else's profile

The other direction. The input is text the caller pasted from a profile they are looking at, and the reply is count messages, each one naming in hook the exact words of that profile it answers. Every hook must appear verbatim in their_profile — that is the check that separates an opener from a template with a citation stapled on, and your client should run it rather than trusting it.

FieldTypeRequiredWhat it is
taskstringyesExactly "opener".
their_profilestringyesThe pasted text, verbatim. It is the only source a hook may be drawn from, so do not normalise or re-wrap it before sending — your verbatim check afterwards has to match this exact string.
my_notestringnoThe caller's own angle, in their words, or "". It is what stops three openers being three questions about the other person.
countnumberyesHow many openers to write.
tonestringyes"plain", "warm", "dry" or "playful".
scanobjectyesThe same shape as the profile lane, but measured over their_profile.
shapestringyes"opener_full", "opener_sparse" or "clipped".

A complete opener request body.

{
  "task": "opener",
  "their_profile": "Nine months into learning to weld, mostly badly.\nI will talk about the Tokyo subway map for longer than you want.\nCurrently boycotting my own sourdough starter.",
  "my_note": "I fix bicycles and I have opinions about maps",
  "count": 3,
  "tone": "dry",
  "scan": {
    "summary": {
      "lines": 3,
      "words": 39,
      "specific_line_ratio": 1.0,
      "claim_lines": 0,
      "empty_lines": 0,
      "demand_lines": 0,
      "worn_phrases": 0,
      "hooks": 3
    },
    "cliches": [],
    "hooks": [
      {"detail": "welding", "pred": "learn", "context": "Nine months into learning to weld, mostly badly"},
      {"detail": "Tokyo subway map", "pred": "talk about", "context": "I will talk about the Tokyo subway map for longer than you want"},
      {"detail": "sourdough starter", "pred": "boycott", "context": "Currently boycotting my own sourdough starter"}
    ]
  },
  "shape": "opener_full"
}

And the reply. frame names the move in a few words, and the moves are required to differ — three questions about three nouns is one opener written three times. risk is a string when the message could land badly and null when it could not.

{
  "lane": "opener",
  "reads_as": "Three lines, three concrete things, no adjectives about themselves anywhere. They are putting forward being mid-competence at things on purpose, which is a confident move and sets the register you have to meet.",
  "openers": [
    {
      "text": "Which line on the Tokyo map is the one you defend when people call it overrated?",
      "hook": "the Tokyo subway map",
      "frame": "invite a defended opinion",
      "why": "They said out loud that they will overtalk this, so the generous move is to ask for the long version rather than acknowledge the interest.",
      "risk": null
    },
    {
      "text": "Nine months and mostly badly is the honest amount of welding to have done. I am about four years into truing bicycle wheels and still take two goes.",
      "hook": "Nine months into learning to weld, mostly badly",
      "frame": "match the admission",
      "why": "It answers a confession with a confession and puts something of yours on the table instead of asking for more of theirs.",
      "risk": "It is a statement with no question in it, so it depends on them wanting to carry the next turn."
    },
    {
      "text": "A boycott implies the starter did something first. I am prepared to hear the case for the starter.",
      "hook": "boycotting my own sourdough starter",
      "frame": "take the other side",
      "why": "The joke is already theirs; extending it is a lower-risk move than making a new one, and it does not ask them to explain themselves.",
      "risk": "Playing along with a bit only works if they meant it as one, and a flat reading is possible."
    }
  ],
  "avoid": [
    {
      "move": "Asking what they are welding.",
      "why": "It is the obvious question, it will be the fourth one they have had this week, and the profile already told you the answer is nothing good yet."
    },
    {
      "move": "Complimenting how interesting the three things are.",
      "why": "It responds to the profile as a whole rather than to anything in it, which is the one thing a profile this specific makes unnecessary."
    }
  ],
  "route_to_help": false
}

The scan object

Both lanes take one, and it is not decoration. It carries counts your client measured over the exact text it is sending, and the reply is expected to reason from those numbers rather than forming a competing impression. If specific_line_ratio is low, that is the finding. If worn_phrases is above zero, the reply is expected to address those phrases in particular — and since your client measured them, your client can check which ones came back.

PathTypeWhat it is
scan.summary.linesnumberNon-empty lines in the text.
scan.summary.wordsnumberWord count over the same text.
scan.summary.specific_line_rationumber0 to 1: the fraction of lines carrying at least one concrete, checkable detail. The single most load-bearing number in the object.
scan.summary.claim_linesnumberLines that assert an adjective about the writer rather than showing the occasion behind it.
scan.summary.empty_linesnumberLines carrying no information at all.
scan.summary.demand_linesnumberLines stating a requirement of the reader rather than offering something.
scan.summary.worn_phrasesnumberHow many entries scan.cliches holds.
scan.summary.hooksnumberHow many distinct concrete details were found.
scan.cliches[]array{"phrase", "tier", "instead"}. tier is 3 (present on a large fraction of all profiles, carries no information), 2 (worn, and usually standing in for the specific thing underneath) or 1 (a frame rather than a phrase: not wrong, just doing no work). instead is the question whose answer would replace it, and it can be "" when there is nothing to salvage.
scan.hooks[]array{"detail", "pred", "context"}. The concrete things already present, the verb that attaches each one to the writer, and the surrounding words it was found in.

Sending an empty cliches array with a worn_phrases of 6 is not an error the API will catch. Derive the summary counts from the arrays rather than filling both in by hand, or the reply will be reasoning from a profile you did not send.

The shape field

A string your client sets, declaring what kind of input this is. It is required on both lanes.

shapeLaneWhat it declares
"profile_full"profileA real, complete profile arrived in profile.
"profile_sparse"profileThere was little or no profile text; the run is working mostly from about_me.
"opener_full"openerA substantial paste arrived in their_profile.
"opener_sparse"openerThe paste was thin — a line or two — so there is very little to hook.
"clipped"eitherThe text was too long and your client cut it before sending. Say so with this rather than sending a truncation silently.

The refusal shape: route_to_help

Either lane can answer with this instead of its normal reply, when the request crosses a content boundary. It is a successful, charged run — the job status is succeeded and there is no error envelope. Check route_to_help before you touch lines or openers, because on a refusal neither key is there.

{
  "lane": "opener",
  "route_to_help": true,
  "route": "boundary",
  "reason": "This writes openers from a profile somebody put in front of you. It does not help work out who somebody is, or reach them somewhere they did not offer.",
  "instead": "If you have a profile you are looking at and want three ways in, paste the text and I will work from that."
}

route is "boundary" (the request is outside what this app does), "safety" (it would help somebody do harm) or "crisis" (the person writing may not be safe). instead is a string when there is a version of the request that can be answered, and null when there is not. If you are wrapping this API in an interface of your own, surface reason as written and do not retry the run with the boundary sanded off.

Errors

A failure is {"error": {"code", "message", "status", "details"}}. The code is the stable part; details is where a validation failure says which field.

Statuserror.codeMeaningWhat to do
400invalid_inputThe body failed validation at the run boundary.Read error.details. The usual causes are a missing or misspelled task, an {"input": ...} wrapper, a tone outside the four allowed values, or an opener body with no their_profile.
401unauthorizedMissing, expired, or a token minted for a different app.Mint a guest token or sign in again. A cold 401 from /me before any token exists is the normal first response, not a fault to report.
402insufficient_creditsBalance below min_credits for this run.Top up. Call /estimate first — it is free, and it returns both the hold and the minimum.
404not_foundThe job id in /jobs/{id} does not exist, or belongs to another subject.Re-read data.job_id from the /run reply. A job is scoped to the token that created it, so a fresh guest token cannot poll a previous one's job.
409conflictAn Idempotency-Key was reused with a different body.The key identifies the run, so changing the body under a key it already used is a conflict, not a retry. Bump the attempt counter for a genuine retry; keep the key identical to replay.
429rate_limitedToo many requests from this subject.Back off and retry. Do not tight-loop the job poller; two seconds between polls is what the web app uses.
500internalThe run started and did not complete.Retry with the same Idempotency-Key so a partial charge is not doubled. If a stream died mid-object, keep the bytes: a truncated reply is often nearly complete and worth repairing rather than discarding.
503unavailableThe upstream model is not taking work right now.Retry with backoff and the same key. This one is transient; nothing about your body caused it.

1. A tiny client

Three things, once, so the rest of the page can be short: the base URL, the two headers every request carries, and the envelope unwrap. Check error.code before you touch data — a failure is a shaped object, not an exception your HTTP library will raise for you. Every snippet below wants a token; the tokens page shows the one this browser already holds and will copy a ready-made shell export, so you never have to open a developer console to find it.

2. Get a token

Every call needs one. A guest token is minted on demand and is enough for /me and /estimate; a run is metered and needs a personal token, which comes from signing in. The tokens page shows the token this browser already holds, reveals it, copies it, copies a ready-made shell export, and mints a fresh guest token — no developer console needed. Replace YOUR_TOKEN above with what it gives you.

To mint a guest token from code instead, POST /guest with the app slug. It takes no Authorization header, and it returns token and guest_id. Keep the guest_id: it is what lets a later sign-in migrate the guest wallet rather than stranding its balance.

3. Check the session and the balance

GET /me returns exactly three fields: subject_type, subject_id and credits. There is no email and no display name, so the signed-in test is subject_type === "user" — anything else is a guest. A cold unauthorized here, before any token has been minted, is the normal first response and not a fault to report.

4. Price it before you run it

POST /estimate is free, starts no job and charges nothing. The body is the run input object itself — the same flat object you would send to /run. It returns hold_credits, min_credits, model, model_alias and markup_bps. hold_credits is a reservation, not the price — it is computed against the full output cap, and what is actually charged is usually well below it and is reported after the run. Compare your balance against min_credits, not against the hold.

Estimate the lane you are about to run. A profile body and an opener body do not hold the same amount: the profile lane returns a per-line verdict plus a full rewrite, and the opener lane returns count short messages, so the output caps differ and so do the holds. Pricing one lane and running the other is a real way to hit insufficient_credits after a green estimate.

/estimate does not validate your body. A body with no task, a misspelled task, or an {"input": ...} wrapper comes back with a plausible hold and a correct model binding. There is no failure signal at all — no throw, no 4xx. A green estimate therefore proves nothing about your input shape, and the only place that can be checked is your side of the wire. Assert task and the lane's required field before every spend, and unit-test the assertion by sabotage rather than by code review.

5. Run it and poll

POST /run submits and returns a job. Always send an Idempotency-Key: a retried request with the same key is the same run, so a network blip cannot bill you twice. The web app's key is:

dating-profile-review:<task>:<input-hash>:a<attempt>

The lane has to be in the key. The same paste can legitimately go to both lanes — text you are reading is a their_profile for opener and, pasted by the person who wrote it, a profile for profile. A key built only from a hash of the text would make the second of those a replay of the first and hand back the wrong lane's reply from cache. Put task in the key. The attempt counter is what lets a deliberate retry through while a duplicate submit is absorbed; reusing a key with a changed body is a conflict, not a retry.

Poll GET /jobs/{id} every couple of seconds until status is succeeded or failed. The reply is the JSON string at output.output, so it needs a second parse — see step 7.

6. Or stream it

POST /run-stream is the same run, the same flat body and the same Idempotency-Key, delivered as server-sent events. A frame is a named event line, a data line, and a blank line that terminates it — exactly this:

event: job
data: {"job_id":"...","status":"running"}

event: delta
data: {"text":"{\"lane\":\"profile\",\"reads_as\":\"A friendly,"}

event: delta
data: {"text":" unobjectionable stranger who has"}

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

Accumulate the text of every delta frame; that concatenation is the reply object, and it is the same string you would have found at output.output after polling. The done frame carries charged_credits — the real price, which is normally well below the hold — and output.output again, for callers that did not buffer. An error frame is terminal and carries code, message and job_id. A pending frame in place of done means the run is continuing out of band; poll the job id.

Reset the current event name on every blank line. A parser that remembers delta across a frame boundary will happily append the contents of the done frame to the reply and then fail to parse the result, which reads like a truncated model output and is not one.

On an idempotent replay the server may answer with plain JSON instead of text/event-stream. Check the content-type before you start reading lines, and fall back to the envelope path if it is not an event stream.

7. Parse the reply, and check it against what you sent

The contract is one bare JSON object, and it mostly arrives that way. Write the parser for the two cases where it does not — a code fence around it, or a sentence of preamble before it — by finding the first balanced object rather than trimming by hand.

Then branch, in this order:

  1. route_to_help first. When it is true there is no lines key and no openers key; there is route, reason and instead. The run succeeded and was charged.
  2. Then lane. Trust the field, not the task you sent — if task was ever ambiguous the router picks the closer lane and names its choice here, and a client that assumed will read the wrong keys.
  3. Then verify verbatim. On profile, every lines[].quote and every cut[].quote is supposed to be copied exactly from what you sent. On opener, every openers[].hook is supposed to appear in their_profile. These are cheap substring checks and they are the difference between a grounded reply and a fluent one. Mark what fails; do not discard the reply over it.

Where to go next

The tokens page is the fastest way to get a working token out of this browser and into a shell. If you are building on top of this API rather than calling it once, the two things worth carrying across from the web app are the client-side scan (the reply is only as grounded as the counts you send it) and the verbatim checks in step 7.