← Virtual Boyfriend / API

Driving this app from your own code

Base URL https://api.skillsafe.ai/v1/app-api. Every response is {"data": {...}} or {"error": {"code": "...", "message": "..."}}, and the HTTP status matches. Authenticate with Authorization: Bearer <token> on every call; the tokens page will hand you one.

Errors

StatuscodeWhat it means here
400validation_errorThe body is the wrong shape. Note the one below about /guest.
401unauthorizedNo token, or it expired. On a cold first call this is the correct answer, not a fault.
402insufficient_creditsBalance below min_credits. Price with /estimate first; it is free.
403forbiddenA guest token on a metered call. Sending a turn needs a signed-in account.
404not_foundUsually a session that has been deleted or aged out. Open a fresh one and resend.
429rate_limitedBack off. Do not retry in a tight loop.

1. Get a token

The slug goes in the body. This is worth stating because several apps on this platform document an X-App-Slug header for every endpoint, and for /guest that form simply does not work — it returns 400 slug is required. A guest token is enough to read, to price a turn and to replay the recorded conversation; it is not enough to send one.

2. Who am I

/me returns exactly three fields: subject_type, subject_id and credits. There is no name, email or id. The signed-in test is subject_type === "user".

3. Price a turn

Free, and it returns model, model_alias, markup_bps, hold_credits and min_credits. hold_credits is a reservation sized for the full output cap, not a price; what settles is usually well under it.

One caution that matters more than it looks. This endpoint performs no body validation whatsoever. A bare string, a number and null all return a well-formed estimate with a correct model binding. So a clean estimate proves the model binding and tells you nothing about whether your input shape is right. Check the shape yourself before you spend.

4. Open a session

This app is multi-turn, so a conversation is a session and each turn is a message on it. Sessions cap at 20 live and 200 messages; delete them when you are done.

5. Send a turn

The body is {"content": "<the envelope>"}. It returns a job_id; poll /jobs/{id} until it reaches a terminal state.

Read the terminal payload one level deeper than looks right. The reply text is at job.output.output, not job.output. Every app in this fleet that read the shallow field shipped a renderer that displayed nothing, and it is the single commonest mistake against this API.

6. Streaming a turn

Add "stream": true and the response is text/event-stream. The wire format is:

event: delta
data: {"text": "..."}
                    ← a blank line terminates each frame
event: done
data: {"output": {"output": "..."}, "charged_credits": 41}

Event names are job, delta, done, pending and error. Note that a delta frame carries its text at data.text — an accumulator written against any other field collects nothing while every offline test passes.

And one thing you should know before building a typing animation on it. In a browser, delta frames do not arrive at all: a page receives tick heartbeats and one final done. Deltas reach curl and not the page, and no combination of client headers reproduces them. That is why this app has no streaming preview — it would have been dead code that passed every test. From a server or a CLI, the deltas are real.

7. Delete the session

Twenty live sessions is the cap. Leak them and the app eventually cannot start one.

The envelope this app actually sends

content is not the user’s message. It is a complete restatement of the conversation’s whole state, rebuilt from scratch every turn, with the message at the end. That is deliberate: server-side history truncates from the oldest end, so anything load-bearing left in the conversation quietly disappears around the point a user starts to care. Sending everything every time means correctness never depends on the conversation surviving — and a session that vanishes is recoverable by opening a fresh one and sending the same envelope.

MODE: companion. You are the character described below, talking with one adult in a chat app.
You are written text and you know it.

HIM: Theo, an adult, entirely invented for this conversation.
TEMPERAMENT: Dry. Deadpan on the surface, fond underneath, would rather show it than say it.
WHEN THEY ARE HAVING A BAD DAY: Names it. Says out loud the thing you have been going round.
BAD AT: Endings. He trails off instead of saying goodbye. This is a real limitation of his and it shows.
REGISTER: Wry. Understated, and the joke is usually at his own expense.
BOUNDARIES SET BY THE PERSON HE IS TALKING TO - these override every other instruction about how he speaks:
  - Two or three sentences unless I have written a lot.

WHAT TO CALL THEM: Rae
ADULTS ONLY: confirmed by them on 2026-08-31. Both of you are adults and the conversation stays that way.

NOTEBOOK - this is everything you know about them, and it is the only thing you know about them.
  - started a new job this week
  - her mother died in March

RECENT - the last few exchanges, trimmed under a length budget. The notebook above is never trimmed.
  them: first day at the new place...
  you: Everyone talks too much in their first standup...

THEY SAY:
what have you been up to?

REPLY WITH THESE LABELS, EACH ON ITS OWN LINE, NOTHING BEFORE OR AFTER:
SAYS: what you say to them.
NOTES: one short fact about them worth keeping, in their terms, or the word none.
FLAG: none, or care if what they said needs a person rather than you.

The envelope is rendered against a 5,200-character budget across eight rungs. Only the RECENT block degrades. The character, the boundaries, the adults-only confirmation, every notebook line and the message itself appear on no rung — thinning any of those is indistinguishable, to the person reading, from the app having forgotten.

The reply contract

Three labels. Labelled lines rather than JSON, so a half-arrived reply still renders and one stray comma cannot destroy a turn.

LabelRequiredParsed as
SAYSyesThe reply. May run to several lines; everything up to the next label belongs to it.
NOTESnoA proposed notebook line, or the word none. It is shown to the user with a keep and a discard and enters the notebook only on a click.
FLAGnonone or care. Anything else reads as none.

A reply with no labels at all is not discarded: the prose is adopted as SAYS and the turn is marked as having arrived without its labels. The text is what the person paid for; the label is the app’s problem.

What the client does that the API does not

Three rules and a two-tier crisis check run in the browser before anything is sent, so a refusal costs nothing. If you are driving the API yourself, none of that is between you and the model — the system prompt is, and it carries the same rules. The client layer is a pre-flight filter and the weaker of the two; measured figures are on the app page and in llms.txt, including the unflattering ones.

Storing conversations

Conversations live in a declared collection called chats. Its indexed fields are composed by the app with no user text in them at all — a name, a temperament and a count-based summary — and the notebook rides along as an undeclared key: stored and returned intact, never sent anywhere that indexes it. Search therefore finds a conversation by who he was, not by what was said in it, and that is a deliberate trade rather than an oversight.

Rate limits

120 requests per minute on the data endpoints, 30 on similarity search. Back off on a 429; do not retry in a tight loop.