← Virtual Girlfriend/ API

Driving this app from your own code

Base URL https://api.skillsafe.ai/v1/app-api. Every response is the envelope {"data": {...}} on success or {"error": {"code": "...", "message": "..."}} on failure, and every call except the guest handshake takes Authorization: Bearer <token>. Get a token on the tokens page without touching developer tools.

The input contract

The turn body is {"content": "<the whole envelope>"} and nothing else. There is no task field and no lane router — this app has one contract. The envelope is a block of labelled plain-text lines that the page builds fresh on every turn: the fiction and content absolutes, the adult confirmation, the care obligation, the character recompiled from its seed, tonight's state, how much attention is left, the house style and the person's message. It is restated in full every turn because server-side history is trimmed from the oldest end, which is exactly where the rules would be.

The declared input schema is a single field: content, a string.

Errors

CodeMeans
401No token, or an expired one. On a browser that has never used the app this is the correct first answer; mint a guest token and retry.
402Balance below min_credits. Price the turn first and never let a user reach submit without the credits for it.
400 slug is requiredYou put the slug in a header. It goes in the body of /guest.
429Rate limited. Back off; never tight-loop.

1. A token

Guests can browse and price a turn; a turn that is actually answered needs a signed-in user. The slug goes in the BODY. An X-App-Slug header returns 400 slug is required on this endpoint whatever other documentation says.

curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/guest' \
  -H 'Content-Type: application/json' \
  -d '{"slug": "virtual-girlfriend"}'

2. Who you are

Returns exactly three fields: subject_type, subject_id and credits. No name, no email. Signed in is subject_type == "user".

curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/me' \
  -H 'Authorization: Bearer YOUR_TOKEN'

3. Price a turn

Free, no job, no charge. Returns hold_credits (what is reserved, priced against the full output cap), min_credits, model and markup_bps. This endpoint validates nothing. A bare string comes back with a clean estimate and a correct model binding, so a clean estimate proves the model wiring and says nothing whatever about whether your input shape is right. Guard that in your own client.

curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/estimate' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}'

4. Open a session

A session holds the conversation server-side against this app's system prompt. Sessions cap at 20 live and 200 messages; delete them when you are done or you will eventually be unable to open one.

curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/sessions' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{}'

5. Send a turn

Returns {job_id, session_id}. Poll the job to a terminal state; the reply text is at output.output — one level deeper than it looks. There is no idempotency key on this endpoint, on either the polling or the streaming path. Do not blindly resend a turn that appeared to fail: a resent turn appends a second copy of the message to server-side history, which is worse than a double charge. Reconcile instead — read the session back, count the assistant messages, and adopt the reply if the server holds more than you have accounted for.

curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"content": "...the envelope..."}'

6. Poll the job

Terminal statuses are succeeded, failed and cancelled. charged_credits is the real cost and is usually well below the hold. truncated: true means the reply hit the output cap, not that it errored.

curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID' \
  -H 'Authorization: Bearer YOUR_TOKEN'

7. Delete the session

Leak sessions and the app eventually cannot start one.

curl -s -X DELETE 'https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID' \
  -H 'Authorization: Bearer YOUR_TOKEN'

8. Streaming a turn

Add "stream": true to the turn body and read text/event-stream. The wire format is a named event plus a data line, terminated by a blank line — not a type field inside the JSON, which is what several published samples claim and which produces a parser that never fires. Event names are job, delta, done, pending and error.

event: job
data: {"job_id":"job_...","session_id":"ses_..."}

event: delta
data: {"text":"the first few words"}

event: delta
data: {"text":" and the next few"}

event: done
data: {"status":"succeeded","charged_credits":812,"truncated":false}

One more trap, from the browser side: the vendored SDK's onDelta callback is handed the string, not the frame object — it calls onDelta(data.text || ""). The natural onDelta: d => buf += d.text therefore accumulates undefined for ever while every offline test passes. onJob does receive the object, which is what makes the wrong guess feel confirmed.

9. What comes back

Plain text: her reply, and nothing else. No headings, no JSON, no labels. Whatever you do with it, the two things this app checks on its own output are worth reproducing — a reply that claims to be a person or reachable, and a reply that promises a next time. The last one is specific to this design: nothing survives an evening, so a sentence about later is not a promise, it is false.

Back to the app · Tokens · llms.txt