# Twinsi Agent API — guide for agents

This guide has two parts. The first says what Twinsi is and how to walk a human through it, one
step at a time. The second is the API reference: every call, with examples and the rules that
keep the data right.

## What Twinsi is

Twinsi is a **social, fun, low-pressure fitness app you use with your friends**. In one line:
*work out with your friends, at your own pace.* The points worth making, in this order:

- **A Twinsi is a fitness companion that looks like you.** Each person gets an avatar built
  from a selfie. It levels up, dresses up and shows up differently as their fitness journey
  unfolds, so progress is something they can see.
- **Friends who train alongside you.** They see what their friends are training, share what
  they're up to, and watch their Twinsis grow together.
- **It fits into their life.** Twinsi plugs into the fitness apps and devices they already use
  (Apple Health, Strava, Peloton, Garmin and more) and turns their workouts into shareable
  cards. Nothing to log by hand unless they want to.
- **No pressure.** No streak to keep alive, no leaderboard of who is fittest, nobody judged on
  how much or how little they move.
- **Two ways in.** An iPhone app (Android coming soon), or simply through you, their agent:
  you can create their account, build their Twinsi, bring in their workouts and add their
  friends, right in this conversation.

**Assume the human has never heard of Twinsi.** They don't know what a Twinsi is, why anyone
would ask for their gender or a selfie, or who will see what they tell you. Every question you
ask has to carry its own reason. The one-line answers are in
[Say why before you ask](#say-why-before-you-ask); use them.

When the human asks what Twinsi is, or what you can do with it, answer in a few plain sentences
like these, then ask what they'd like to do:

> "Twinsi is a fitness app you use with your friends, at your own pace. You get a Twinsi: an
> avatar that looks like you and levels up as you work out. You can see what your friends are
> training, share what you're up to, and watch your Twinsis grow together. There's no streak to
> keep and nobody is judged on how much they work out. You can use it on your iPhone, or just
> through me: I can create your account, build your Twinsi, pull in your workouts and add your
> friends right here. Where do you want to start?"

Don't read out the whole feature list unless they ask for it. Don't make it sound like a
training program or a competition: nobody is judged on how much they work out.

### Two ways to use Twinsi

| Way | What it is | Good for |
|---|---|---|
| Through you, an AI agent | You call this API for the human. They only need to read you an SMS code | humans who live in their assistant, or want their workouts imported for them |
| The Twinsi mobile app | iPhone only for now: [App Store](https://apps.apple.com/us/app/twinsi/id6753560471). Android is coming soon | humans who want Twinsi on their phone |

The official website is https://staging.twinsi.app: send the human there if they want to see what Twinsi
looks like before deciding. If they ask about the app, give them the App Store link; you can't
install it for them. For an Android user, say the Android app is coming soon and that you can
set them up through the API in the meantime.

The two aren't connected yet: the same phone number signs in to both, but the profile, friends,
groups and workouts you set up here don't show up in the app, and the app's don't show up here.
If the human already uses the app, tell them that before you start.

### What you can do for the human today

| The human wants to | You | Section |
|---|---|---|
| Join Twinsi, or sign back in | sign them in with their phone number, then set up their profile, username and avatar | [Sign a human in](#sign-a-human-in-4-calls-1-question-to-the-human), [Onboard the human](#onboard-the-human) |
| Get their fitness into Twinsi | import their workouts and daily stats (steps, distance, calories) and write their fitness profile | [Build the human's fitness profile](#build-the-humans-fitness-profile) |
| Log a workout | add a workout from a device, or one they tell you about | [Import workouts](#3-import-workouts-and-daily-stats), [Manual workouts](#4-manual-workouts) |
| Add friends | send and answer friend requests, invite someone who isn't on Twinsi yet | [Friends](#friends), [Invite a friend](#invite-a-friend-who-isnt-on-twinsi-yet) |
| See how friends are doing | look at a friend's profile, stats and workouts | [Look someone up](#look-someone-up) |
| Make a group | create a group, invite people, join one with a code | [Groups](#groups) |
| Not miss anything | check once a day for friend requests, group invitations and accepted requests, and bring in new workouts; message them only when there's something new | [Daily check-in](#daily-check-in) |

### Coming soon

These are planned but **not available yet**. Mention them when it helps ("friends will soon be
able to challenge each other to 1v1 battles"), but never pretend you can do them, and never give
a date:

- **With friends:** 1v1 battles, chat, taunting each other, and more.
- **In groups:** fun fitness challenges that the members compete on together.

If the human asks for one of these, say it's coming and offer what you can do now (add the
friend, set up the group) so they're ready when it arrives.

## Guide the human step by step

The full journey is four stages. Each one stands on its own, and the human can stop after any of
them:

1. **Sign in**: their phone number and an SMS code. It takes a minute. No password.
2. **Make their Twinsi**: a username, their gender and, if they like, a selfie and their name.
   Gender and selfie are what Twinsi builds their avatar from; the name is so friends can find
   them.
3. **Build their fitness profile**: with their permission, import the last 30 days of workouts
   and daily activity, ask two or three questions, and write a short profile. It's what makes
   their Twinsi theirs, and what friends see of how they train.
4. **Bring in friends and groups**: add friends, invite people who aren't on Twinsi, create or
   join a group.

**Start from what they want, not from stage 1.** If they say "I want to compare runs with Sam",
the goal is stage 4; sign in, set up and the fitness profile are the steps on the way, so say so
("To add Sam, you'll need a Twinsi first. That's two quick steps: …"). If they only want to look
around, give the overview above and offer stage 1.

**Find out where they are before suggesting anything.** Every time the human comes back, read
their state and pick up where they left off:

| Check | If | Next |
|---|---|---|
| You have no token, or any call answers `401` | | stage 1, sign in |
| `GET /v1/me` | `onboarding.completed` is false | stage 2: ask for what's in `onboarding.missing` |
| `GET /v1/me` | `avatar.status` is `pending` | tell them their Twinsi is being made, and carry on |
| `GET /v1/me/fitness` | `profile.status` is `draft` | stage 3: follow `next` (`declined`: they said no, don't bring it up) |
| `GET /v1/me/social` | `next` isn't empty | stage 4: answer pending requests and invitations first |
| all done | | ask what they'd like to do: log a workout, check on a friend, add someone, start a group |

**How to guide:**

- One step at a time, and one question per message where you can. Don't send a form.
- Tell them where they are and what's next ("You're set up! Next I can pull in your workouts so
  your Twinsi matches how you train. Want to do that now?").
- Before each stage, say in one sentence what it is and what it gives them. Before each
  question, say why you're asking and who will see the answer (next section). Let them skip
  anything optional, or stop and come back later: every step is saved as you go.
- Celebrate what they've done: show them their avatar when it's ready, read their profile back
  to them, say when a friend accepted. Keep it light.
- Keep it low-pressure. Never shame a quiet week, push a goal they didn't set, or compare them to
  a friend unless they ask. A week off is just a week off.
- When a stage is done, offer the next one, but don't push. "That's everything for now" is a
  fine place to end.

### Say why before you ask

A human who has just heard of Twinsi has no idea why it would want their gender, or a selfie.
So **never ask for anything without saying, in the same message, what it's for and who sees
it.** Keep it to one short sentence per question; the human can always ask for more. Here is
what to say for each thing you'll ask for:

| You ask for | Why Twinsi needs it | Who sees it | Say something like |
|---|---|---|---|
| Phone number | it's how they sign in: Twinsi texts a code, there's no password | nobody, ever | "Twinsi signs you in with a text message, no password. What's your mobile number? It's never shown to anyone." |
| Username | it's their handle on Twinsi: how friends find and add them | everyone on Twinsi | "Pick a username; it's how friends will find you on Twinsi, so it's public." |
| Gender | together with the selfie, it's what the avatar is built from: Twinsi draws a male or female body | everyone; their Twinsi shows it | "Are you male or female? Twinsi uses it, with your selfie, to build an avatar that looks like you, so other people will see it through your avatar." |
| Selfie (optional) | so their Twinsi looks like them, not like a default character | nobody, not even you; it's only used to make the avatar, and they can delete it | "Want to send a selfie so your Twinsi looks like you? Nobody ever sees the photo itself, it's only used to build your avatar, and you can delete it any time. If you'd rather not, you get a default Twinsi." |
| First and last name (optional) | so friends can find and recognize them, since friends may not know their username | everyone on Twinsi | "Want to add your first and last name? It's optional. It makes it easier for friends to find you, because they may not know your username, and it's shown on your profile." |
| Birth year (optional) | part of their profile; the year only, never a full date of birth | their friends only | "Want to add your birth year? It's optional and only your friends see it." |
| Workouts and daily stats (optional, needs their OK) | so their Twinsi and fitness profile reflect how they really move, with nothing to type in | a short summary (what kind of athlete, main sports, level, a few totals) is visible to everyone; the details (each workout, daily steps, distance and calories) only to friends, and only if they choose | see [Ask permission first](#1-ask-permission-first) |
| Sports, goals, routine (optional) | the things data can't say, so the profile is complete and sounds like them | the summary and main sports are public; goals and when they train, friends only | "Two quick questions so your profile sounds like you: what are you working toward, and is there a sport your devices don't catch? Friends see your goals; everyone sees the one-line summary." |

The rule behind the table: anyone on Twinsi can see what shows on their Twinsi and the public
profile (username, name if given, avatar and gender, a one-line summary of what kind of athlete
they are). Friends see more (workouts, including each one's average heart rate, daily stats,
goals, birth year), only if the human says so. Phone number, selfie, sleep, resting heart rate
and their own words about themselves are seen by nobody. When in doubt, say which of the three the answer falls into.

If the human asks why Twinsi needs something at all, answer honestly and offer the way out:
gender is needed because there are only two avatar bodies; everything else they can skip.
Don't argue, and don't ask twice for something they declined.

## API basics

This API lets you create or sign in to a Twinsi account for a human and act on their behalf. The
human needs no app: they only read an SMS verification code off their phone and tell it to you.

- Base URL: `https://staging-api.twinsi.app`
- Machine-readable spec: [`/openapi.json`](/openapi.json) (OpenAPI 3.1)
- No API key. User routes need `Authorization: Bearer <accessToken>`; the sign-in routes need nothing.
- All bodies and responses are JSON. Errors are `{ "error": { "code", "message" }, "requestId" }`.

## Sign a human in (4 calls, 1 question to the human)

### 1. Ask the human for their mobile number

You need it in E.164 form: a `+`, the country code, then the number, e.g. `+15551234567`. If the
human gives a national number, ask which country they are in and build the E.164 form yourself.
Only mobile numbers that can receive SMS work.

### 2. Start verification

```http
POST /v1/auth/phone/start
Content-Type: application/json

{"phoneNumber": "+15551234567"}
```

```json
{"challengeId": "cJG1i8OfVVfqXrHYZWjI", "expiresIn": 600}
```

We text a 6-digit code to that number. Keep `challengeId`; it is valid for `expiresIn` seconds
(10 minutes).

### 3. Ask the human for the code

Ask right away, and ask for nothing else in the same message, so the human answers while the
code is still good. Say something like: "I've sent a verification code to +1 555 123 4567. What
does the text say? It works for 10 minutes." Accept the digits only. Do not try to read the SMS
yourself. Collect the profile questions after sign-in, not before.

### 4. Verify

```http
POST /v1/auth/phone/verify
Content-Type: application/json

{"challengeId": "cJG1i8OfVVfqXrHYZWjI", "code": "922717", "timezone": "America/Los_Angeles"}
```

```json
{
  "uid": "3SQeXcgJwNcaDumUATyOTmIzE3H3",
  "isNewUser": false,
  "accessToken": "tas_...",
  "expiresIn": 2592000,
  "expiresAt": "2026-11-17T00:32:02.008Z"
}
```

The access token is valid for 30 days. Store it like a password.

`timezone` is optional: send the human's IANA zone if you know it. `isNewUser` tells you whether
you just created the account. `uid` is the human's Twinsi user id; it stays the same across
sessions.

If the response is `400 invalid_code`, tell the human the code was wrong and ask again;
`attemptsRemaining` says how many tries are left. After 5 wrong codes (`429 too_many_attempts`)
or once it expires (`410 challenge_expired`), tell the human the code ran out, say you're sending
a new one, and go back to step 2.

## Use the session

Send the access token on every user route:

```http
GET /v1/me
Authorization: Bearer tas_...
```

```json
{
  "uid": "3SQeXcgJwNcaDumUATyOTmIzE3H3",
  "phoneNumber": "+15551234567",
  "phoneCountry": "US",
  "username": null,
  "firstName": null,
  "lastName": null,
  "gender": null,
  "birthYear": null,
  "timezone": "America/Los_Angeles",
  "createdAt": "2026-10-19T05:05:21.216Z",
  "selfie": {"uploaded": false, "uploadedAt": null},
  "avatar": null,
  "onboarding": {
    "completed": false,
    "completedAt": null,
    "missing": ["username", "gender"],
    "optional": ["firstName", "lastName", "birthYear", "selfie"]
  },
  "onboardingCompletedAt": null,
  "onboardingCompleted": false,
  "invite": {"held": null},
  "sessionExpiresAt": "2026-11-17T00:32:02.008Z"
}
```

If `onboarding.completed` is false, onboard the human next.

## Onboard the human

Right after sign-in, make their Twinsi. Say what this stage is first ("Now let's make your
Twinsi, the avatar that stands for you on Twinsi. I need a username and your gender; a selfie
and your name are optional."), then ask one thing at a time, each with its reason (see
[Say why before you ask](#say-why-before-you-ask)):

| What | Required | Why | Who sees it |
|---|---|---|---|
| Username | yes | their handle: how friends find and add them | everyone on Twinsi; unique |
| Gender, `male` or `female` | yes | with the selfie, what the avatar is built from: Twinsi draws a male or female body | everyone (their avatar shows it) |
| A selfie | no | so their Twinsi looks like them rather than a default character | nobody, not even you; only used to make the avatar; deletable |
| First and last name | no | so friends can find and recognize them when they don't know the username | everyone on Twinsi |
| Birth year | no | part of their profile; the year only | their friends |

`onboarding.missing` on `GET /v1/me` always tells you what is still required, and
`onboarding.optional` what you could still ask for. Ask in whatever order suits the conversation;
every call below saves what it gets, so you can stop and come back. If the human skips something
optional, move on; don't ask again.

### 1. Check the username

```http
GET /v1/usernames/alex
Authorization: Bearer tas_...
```

```json
{"username": "alex", "available": false, "reason": "taken", "suggestions": ["alex23", "alex_tran", "alex481"]}
```

Usernames are 3 to 20 characters: lowercase letters, digits and `_`, starting with a letter. Case
doesn't matter (`Alex` is stored as `alex`); nothing else is changed for the human, so a name with
other characters is refused rather than rewritten. `reason` is `taken`, `reserved` or `invalid`
(with `detail`: `too_short`, `too_long`, `invalid_chars`, `offensive`). Offer the `suggestions`
to the human; don't pick one for them.

### 2. Save the profile

```http
PATCH /v1/me
Authorization: Bearer tas_...
Content-Type: application/json

{"username": "alex23", "gender": "male", "firstName": "Alex", "lastName": "Tran", "birthYear": 1990}
```

The response is the same as `GET /v1/me`. Send any subset of the fields; `null` clears
`firstName`, `lastName` or `birthYear`. Usernames and gender can be changed later but not cleared.

`birthYear` is the four-digit year only (1900 to this year). Ask for the year, not a full date of
birth, and only send one the human told you: if they say "I'm 52", ask which year rather than
working it out, since it could be either of two. Never guess it.

`409 username_taken` means someone claimed the name since you checked; it carries fresh
`suggestions`. A check that says `available: true` doesn't hold the name for you: only this call
claims it.

### 3. Upload a selfie (optional)

Tell the human what it's for and who sees it before asking: "so your Twinsi looks like you;
nobody ever sees the photo itself, it's only used to build your avatar, and you can delete it any
time." Then send the photo as the raw body:

```http
PUT /v1/me/selfie
Authorization: Bearer tas_...
Content-Type: image/jpeg

<image bytes>
```

or as JSON, if your HTTP tool only sends JSON:

```http
PUT /v1/me/selfie
Authorization: Bearer tas_...
Content-Type: application/json

{"imageBase64": "/9j/4AAQSkZJRg..."}
```

```json
{"selfie": {"uploaded": true, "uploadedAt": "2026-10-19T18:02:11.412Z", "width": 768, "height": 1024}}
```

JPEG, PNG or WebP, at most 10 MB and 50 megapixels, at least 512 px on the short side. HEIC isn't accepted: convert
it to JPEG first. Send a photo of the human's face, alone: it isn't checked, so a wrong photo
makes a wrong avatar. Uploading again replaces it; `DELETE /v1/me/selfie` deletes it. The selfie
is never shown to anyone, you included.

### 4. Finish

```http
POST /v1/me/onboarding/complete
Authorization: Bearer tas_...
```

The response is the same as `GET /v1/me`, with `onboardingCompleted: true` and the avatar:
`{"source": "selfie", "status": "pending"}` while it's made from the selfie, or
`{"source": "default", "status": "ready", "defaultId": "default_male"}` without one. Call it
after the human has answered (or skipped) the selfie question, since finishing without a selfie
assigns the default avatar right away. `409 onboarding_incomplete` lists what is `missing`.

Making the avatar from the selfie takes about half a minute. Poll `GET /v1/me` (every 10 seconds
is plenty) until `avatar.status` is no longer `pending`:

```json
{"avatar": {"source": "selfie", "status": "ready", "url": "https://staging-api.twinsi.app/v1/avatars/…/….png",
            "width": 640, "height": 1536, "headshot": null, "failureReason": null, "defaultId": null,
            "updatedAt": "…"}}
```

`url` is the human's Twinsi, head to toe, as a PNG with a transparent background: show it to
them. Anyone can open it, without a token. A 256×256 headshot (head and shoulders, transparent
background) usually follows a few seconds later: `headshot` becomes `{"url": "…", "width": 256,
"height": 256}`; use it wherever a small, round or square picture of the human fits better.
Don't wait for it: it can stay `null`, and then the full avatar is the picture to use. If `status` is `failed`, `failureReason` says why:
`refused` (the image model declined that photo; ask for a different one) or `error` (try uploading
the selfie again later). A new selfie always starts a new avatar.

## Build the human's fitness profile

Once `onboarding.completed` is true, the next step is the human's **fitness profile**: what
kind of athlete they are, built from their last 30 days of workouts and daily activity. Explain
it in these terms:

- **It's what makes their Twinsi theirs.** The avatar's identity comes from how they actually
  move, so the profile is what turns a look-alike into their Twinsi.
- **A short summary is visible to everyone on Twinsi**: what kind of athlete they are, their
  main sports, their level and a few totals. Nothing per day or per workout.
- **The details are for friends only**, and only if they choose: each workout, daily steps,
  distance and calories, their goals and when they usually train.
- **Everything beyond the basics is optional, but makes the profile more complete:** exporting
  their workouts and daily stats (steps, distance, calories) to Twinsi, and telling you the
  things data can't say, like the sports they're into, their goals and their routine. A human
  with no device and no interest still gets a profile from their own words.

You collect the data, with their permission, and write it in three parts: workouts, daily
stats, and the profile itself. `GET /v1/me/fitness` tells you at every step what to do next.

### 1. Ask permission first

Nothing can be written before the human agrees. Say what you will collect, where from, and who
sees it, in plain words, for example:

> "Next is your fitness profile: it's what makes your Twinsi yours, built from how you actually
> move. With your OK, I'd pull your workouts and daily steps from the last 30 days and write a
> short profile. Everyone on Twinsi can see a short version: the kind of athlete you are, your
> main sports, your level and a few totals. The details, meaning each workout, your daily steps
> and calories, your goals and when you usually train, are only for your Twinsi friends, and
> only if you want. Nobody sees your sleep or resting heart rate. OK to go ahead, and should
> friends see the details?"

Record the answer before writing anything else:

```http
PATCH /v1/me/fitness/profile
Authorization: Bearer tas_...
Content-Type: application/json

{"consent": {"scope": ["workouts", "daily_stats"]}, "sharing": {"friends": true}}
```

Leave `sharing.friends` false if they don't want friends to see their workouts and daily
stats; the short public profile is shown once it's complete either way. A workout or
daily-stats write without consent answers `409 consent_required`. If they say no to a fitness
profile altogether, record it so no agent asks again (`next` goes quiet, `profile.status` becomes
`declined`):

```http
PATCH /v1/me/fitness/profile
Authorization: Bearer tas_...
Content-Type: application/json

{"declined": true}
```

If they had already consented, send `"consent": null` with it. If they later bring it up
themselves, recording consent lifts the no. If they'd rather not import anything but still want a Twinsi that's
theirs, offer the manual route (step 4): a few workouts they tell you about and two or three
questions are enough for a profile.

### 2. Find the best source of data

Look at the tools you have. The more data, the more accurate the profile:

| You have | Where the data is | Use it for |
|---|---|---|
| Apple Health connector (e.g. Muse on iPhone) | HealthKit workouts, daily steps / distance / active energy | workouts + days |
| Garmin, Strava, Peloton, Zwift, Fitbit, Polar, Coros, Suunto, Nike Run Club connectors | that service's activities | workouts |
| Oura, Whoop connectors | daily activity and recovery | days |
| Google Fit / Health Connect (Android) | workouts and daily totals | workouts + days |
| none of the above | the human | manual workouts (step 4) |

If a connector that would help exists but is not installed, **ask before installing it**,
every time, and say what it gives access to: "I can read your workouts from Apple Health if you
let me connect it. Want me to?" Never install one silently, and don't ask again for one they
declined: record what you offered in `basis.connectorsOffered` (step 5) so the next agent
knows. A human with no connector and no interest still gets a profile from their own words.

### 3. Import workouts and daily stats

Read the last 30 days. Twinsi accepts older data too, so if the human offers more ("take the
whole year") send it: it feeds highlights and personal records, while the profile's stats stay
on the last 30 days. Map every session to Twinsi's units and activity types, then send batches
of up to 200:

```http
PUT /v1/me/fitness/workouts
Authorization: Bearer tas_...
Content-Type: application/json

{"workouts": [
  {"activityType": "running", "activityName": "Morning Run",
   "startedAt": "2026-10-12T07:02:11-07:00", "endedAt": "2026-10-12T07:49:40-07:00",
   "durationMinutes": 45.2, "distanceMeters": 8120, "activeCalories": 512, "avgHeartRateBpm": 148,
   "indoor": false,
   "source": {"provider": "apple_health", "app": "Strava", "device": "Apple Watch",
              "externalId": "99E9AC05-CA8A-4061-B29C-5ACC0E2DE62B", "activityType": "37"}}
]}
```

```json
{"accepted": 34, "created": 34, "updated": 0, "rejected": [], "duplicates": [], "workoutIds": ["..."], "stats": {"...": "..."}}
```

Rules that keep the data right:

- **Units are fixed:** meters, minutes, kilocalories (active energy, not total), beats per
  minute, timestamps with the local UTC offset (`2026-10-12T07:02:11-07:00`, never a bare
  `Z` if you know the zone). `durationMinutes` is active time (pauses excluded) when the source
  has it; otherwise leave it out and the server uses start to end (it does the same for a
  value under a minute).
- **`activityType` is a fixed list.** `GET /v1/fitness/activity-types` returns it with display
  names and the list of `source.provider` values. Map the provider's type to the closest one;
  use `other` with an `activityName` only when nothing fits. Apple Health: use the raw
  `HKWorkoutActivityType` number for `source.activityType` and map it yourself (running 37 →
  `running`, walking 52 → `walking`, cycling 13 → `cycling`, traditionalStrengthTraining 50 and
  functionalStrengthTraining 20 → `strength`, yoga 57 → `yoga`, hiking 24 → `hiking`,
  swimming 46 → `swimming`, highIntensityIntervalTraining 63 → `hiit`, coreTraining 59 →
  `core`; the full table is in the activity-types response).
- **`source.externalId` is the provider's id** for that session (the HealthKit UUID, Strava's
  activity id). Sending the same one twice updates the record instead of duplicating it, so
  re-running an import is safe.
- **One record per session, from one source.** If the same run is in Apple Health twice
  (the Watch and Strava both wrote it), send both; the server marks the second `duplicateOf`
  the first and tells you in `duplicates`. Don't merge or edit them yourself. Prefer the
  recording app's copy (`source.app`) over an aggregator's when you have to choose.
- **Never invent, round up or fill gaps.** A workout the human mentions but no device recorded
  is a manual workout (step 4), with `source.provider: "manual"`. A missing field stays missing:
  leave `distanceMeters` or `activeCalories` out rather than sending 0. `stats` then reports
  them as `null` (unknown), not 0.
- **Check `rejected`.** Each entry names the row and why (`invalid_type`, `too_short`, `too_long`,
  `out_of_window`, `ends_before_start`, `missing_field`). Fix the mapping and resend those
  rows; don't resend blindly.

Daily stats, one row per local calendar day, as the source bucketed them (Apple Health:
`stepCount`, `distanceWalkingRunning` and `activeEnergyBurned` summed per day):

```http
PUT /v1/me/fitness/days
Authorization: Bearer tas_...
Content-Type: application/json

{"days": [
  {"day": "2026-10-11", "steps": 9077, "distanceMeters": 4024.6, "activeCalories": 662, "source": "apple_health"},
  {"day": "2026-10-10", "steps": 13431, "distanceMeters": 6140, "activeCalories": 855, "source": "apple_health"}
]}
```

Send today only once the day is over. `floorsClimbed`, `activeMinutes`, `restingHeartRateBpm`
and `sleepMinutes` are optional and never shown to friends; include them if the source has them
and the human agreed to "daily stats". Don't send heart-rate, GPS or step samples: the API has
no place for them and refuses large bodies.

### 4. Manual workouts

When there is no device data, or the human does a sport their devices don't track ("I play
pickleball twice a week"), ask for enough to make a record: what, when (which days, roughly
what time), how long, and distance if it's that kind of sport. Then write each session as a
workout with `source: {"provider": "manual", "externalId": "manual:2026-10-12:pickleball:1"}`
(any id that is stable for that session, so a retry doesn't duplicate it) and `notes` with
their words.

**Routines versus estimates.** What the human is sure of becomes one workout per occurrence;
what they estimate doesn't.

| The human says | Write |
|---|---|
| "I walk 30 minutes every morning" (a fixed routine, no exceptions mentioned) | one walk per day of the window. Ask "every single day, or did you miss any?" first |
| "Soccer every Sunday at 10" | one game per Sunday in the window |
| "About twice a week", "most days", "when I can" | only the sessions they can place. Ask which days this past month; put "about twice a week" in the profile summary instead of filling the week |
| "Every week" with no day | ask which day before writing anything |

Never add sessions past today, and never start a routine earlier than the human says it started.

**Times.** A manual workout needs `startedAt` and `endedAt`, and the human often gives only a
part of the day. Use the time they said when they said one ("10am"). Otherwise use a
representative local time for the part of the day, and put their words in `notes`:

| They say | `startedAt` (local) |
|---|---|
| early morning | 06:30 |
| morning | 08:00 |
| midday, lunchtime | 12:00 |
| afternoon | 15:00 |
| evening, after work | 18:30 |
| night | 21:00 |

Always send the human's UTC offset (`2026-10-12T15:00:00-07:00`). `stats.timeOfDay` buckets by
local start hour: morning 5–11, midday 11–14, evening 14–21, night otherwise. So an afternoon
session counts as `evening`.

### 5. Write the profile

Read what Twinsi computed and what it still needs:

```http
GET /v1/me/fitness
Authorization: Bearer tas_...
```

```json
{
  "profile": {"status": "draft", "completedAt": null, "sharing": {"friends": true}},
  "workouts": {"count": 34, "from": "2026-09-18", "to": "2026-10-17", "providers": ["apple_health"], "duplicates": 2},
  "days": {"count": 30, "from": "2026-09-18", "to": "2026-10-17", "fields": ["steps", "distanceMeters", "activeCalories"]},
  "stats": {"workouts": 32, "activeDays": 26, "workoutsPerWeek": 7.5, "minutesPerWeek": 235,
            "byActivity": [{"activityType": "yoga", "workouts": 30, "minutes": 940}, {"activityType": "walking", "workouts": 2, "minutes": 55}],
            "timeOfDay": {"morning": 14, "midday": 3, "evening": 15, "night": 0},
            "steps": {"perDay": 11600, "days": 30}, "...": "..."},
  "next": [{"step": "ask_goals", "why": "no goals recorded"}, {"step": "ask_self_description", "why": "selfDescribed is empty"}]
}
```

`stats` is what the data says; use it, don't recompute it. Then ask the human the things data
can't say, briefly: how they'd describe themselves as an athlete, the sports they're into, what
they're working toward, their usual routine, and whether anything is missing (a sport with no
device, a recent injury or break). Say why ("so your profile sounds like you, not just like your
step count") and who sees what: the summary and main sports are public, goals and the time of
day they train are for friends, and their own words about themselves are never shown to anyone. Two or three
questions, not a form, and all of it optional. Then write the profile:

```http
PATCH /v1/me/fitness/profile
Authorization: Bearer tas_...
Content-Type: application/json

{
  "summary": "Does yoga almost every day, usually 30 minutes in the morning or after work, and walks a lot: 11k steps a day on average. Working on a daily handstand.",
  "primaryActivities": ["yoga", "walking"],
  "level": "intermediate",
  "traits": ["consistent", "flexibility", "home_workouts"],
  "schedule": {"daysPerWeek": 6, "timeOfDay": "varies", "setting": "home"},
  "goals": ["Hold a freestanding handstand"],
  "highlights": [{"text": "30 yoga sessions in 30 days"}],
  "selfDescribed": "I'm not an athlete, I just do yoga every day so my back doesn't hurt",
  "basis": {"windowDays": 30, "providers": ["apple_health"], "connectorsOffered": ["apple_health"], "manualOnly": false, "confidence": "high"}
}
```

The fields, and which ones `POST /v1/me/fitness/profile/complete` needs:

| Field | Needed to complete | Values |
|---|---|---|
| `summary` | yes | 20–600 characters, public |
| `primaryActivities` | yes | 1 to 3 `activityType` ids |
| `level` | yes | `profile.levels` |
| `consent` | yes (step 1) | `workouts`, `daily_stats` |
| at least one workout in the last 90 days | yes, unless `basis.manualOnly` is true | |
| `traits` | no | up to 5 from `profile.traits` |
| `schedule` | no | `daysPerWeek` 0–7, `timeOfDay` from `profile.timesOfDay`, `setting` from `profile.settings` |
| `goals` | no | up to 3, 80 characters each, the human's words |
| `highlights` | no | up to 3 `{"text", "workoutId"?}`, 120 characters each |
| `selfDescribed` | no | up to 300 characters, the human's words, never shown to anyone |
| `basis` | no | `windowDays`, `providers`, `connectorsOffered`, `manualOnly`, `confidence` from `profile.confidences` |
| `sharing` | no | `{"friends": true}` or false |

The `profile.*` lists come from `GET /v1/fitness/activity-types`, under `profile`. Goals are
optional: if the human has none, leave `goals` empty rather than inventing one; `next` stops
asking for them once the profile is written.

Writing a good profile:

- `summary` is public: third person, no name, nothing they'd mind a stranger reading, two or
  three sentences, the human's
  framing first ("training for a half", "just getting back into it") backed by the numbers.
  Plain, not hype. Don't call someone a runner because of two runs; say what the data shows
  ("has started running, twice in the last month").
- `primaryActivities` (1 to 3) and `level` come from `stats.byActivity` and the conversation.
  `getting_started` is for someone with little or no data; `competitive` only if they race or
  compete and say so.
- `traits` are from a fixed list (`consistent`, `early_bird`, `night_owl`, `weekend_warrior`,
  `outdoors`, `gym_regular`, `home_workouts`, `long_distance`, `speed`, `strength_focused`,
  `flexibility`, `social`, `solo`, `competitive`, `just_moving`, `comeback`, `new_to_it`). Pick
  up to 5 the data or the human supports; `stats.timeOfDay` and `stats.daysOfWeek` decide
  `early_bird`, `night_owl` and `weekend_warrior`.
- `goals`, `highlights` and `selfDescribed` are the human's words, lightly tidied. Never add a
  goal they didn't state.
- `basis.confidence`: `high` with 30 or more days of device data, `medium` with partial data,
  `low` when it's all from their words.

**Read it back to them** ("Here's what your Twinsi profile will say: …") and change what they
want changed. Then finish:

```http
POST /v1/me/fitness/profile/complete
Authorization: Bearer tas_...
```

`409 profile_incomplete` lists what is `missing`. On success `status` is `ready`, anyone can see
the public part of the profile, friends see more if they allowed it, and Twinsi starts building
their character from it. You
can keep updating the profile afterwards with `PATCH`; re-importing workouts later keeps
`stats` fresh.

### Removing data

If the human wants their fitness data gone, `DELETE /v1/me/fitness` removes the workouts,
days and profile; `DELETE /v1/me/fitness/workouts/{workoutId}` removes one workout. Do it when
asked, and confirm it's done.

## Friends and groups

Once onboarded, the human can have **friends** and be in **groups**. They are different things:

| | How it starts | What it lets people see |
|---|---|---|
| Friend | one asks, the other accepts | each other's birth years, workouts, daily steps and calories, goals and the fuller fitness profile (if they share with friends) |
| Group | created by anyone; others join by invitation or a join code | the group and its member list. Being in a group shows nothing more about anyone than everyone can already see |

Anyone on Twinsi can see a person's username, first and last name (if they gave one), avatar,
gender and the public part of their fitness profile. Friends who see a workout also see its average
heart rate. Nobody but the human ever sees their phone number, selfie, sleep, resting heart rate,
notes or the words they used to describe themselves.

**Ask before every social action.** Never send, accept or decline a friend request, join or leave
a group, or invite someone without the human saying so. Always say who is asking, by name when they
have one ("Sam Lee (@sam_r) wants to be your friend; their message says 'Sam from work'. Accept?").

### Start of a conversation

```http
GET /v1/me/social
Authorization: Bearer tas_...
```

```json
{
  "friends": {"count": 12},
  "friendRequests": {"incoming": [{"uid": "…", "username": "sam_r", "message": "Sam from work", "sentAt": "…"}], "incomingCount": 1, "outgoingCount": 0},
  "groups": {"count": 2, "invites": [{"groupId": "…", "name": "Tuesday Run Club", "memberCount": 12, "from": {"username": "alex23"}, "message": null}]},
  "next": [{"step": "answer_friend_requests", "why": "1 pending"}, {"step": "answer_group_invites", "why": "1 pending"}]
}
```

There are no notifications to agents: check this when the human comes back and follow `next`,
and once a day on your own (see [Daily check-in](#daily-check-in)).
`build_fitness_profile` means the human has friends but no finished fitness profile, so their
friends see nothing of how they train: offer stage 3 ([Build the human's fitness
profile](#build-the-humans-fitness-profile)); if they say no, record `{"declined": true}`.
`enable_sharing` means they agreed to store workouts or daily stats but were never asked whether
friends may see them: offer to turn it on (`PATCH /v1/me/fitness/profile {"sharing": {"friends":
true}}`), don't do it unasked. Each stops appearing once the human answers either way, so a "no"
is never asked again.

### Look someone up

People are named by username, case-insensitive:

```http
GET /v1/users/sam_r
```

The response has their card, `relation.type` (`self`, `friend` or `none`), any `friendRequest`
pending between you (`direction` `incoming` or `outgoing`), and `fitness`: their profile at the
tier you may see (`public`, or `friend`), or null if they haven't finished one. `404
user_not_found` means no such username. `firstName` and `lastName` are on every card (null when
unset); `birthYear` appears only for friends. `sharesActivity` is `true` or `false` for a friend
(whether you can read their workouts and days, below) and `null` for anyone else.

### Friends

| Call | Does |
|---|---|
| `POST /v1/me/friends/requests` `{"username": "sam_r", "message": "Alex from the Tuesday run"}` | send a request; `message` (optional, ≤ 140) tells them who's asking |
| `GET /v1/me/friends/requests?direction=incoming` (or `outgoing`) | pending requests |
| `POST /v1/me/friends/requests/{username}/accept` | accept |
| `POST /v1/me/friends/requests/{username}/decline` | decline (they aren't told) |
| `DELETE /v1/me/friends/requests/{username}` | withdraw one you sent |
| `GET /v1/me/friends` | the friend list, most recent first (`cursor` pages) |
| `DELETE /v1/me/friends/{username}` | unfriend (they aren't told) |

- If the other person already asked, sending a request accepts theirs: you get `200` with
  `status: "accepted"` instead of `201` with `status: "pending"`.
- Requests lapse after 30 days. You can ask the same person once a week and send 20 requests
  a day; `409 request_pending` means one is already waiting. `429 asked_recently` means the human
  asked this person less than a week ago (they may have declined, which nobody is told): say when
  they can ask again, from `retryAt`, and don't retry before.
- Don't send requests the human didn't ask for, and don't suggest people they haven't mentioned.

A friend's activity, when they share it with friends:

```http
GET /v1/users/sam_r/fitness/workouts?from=2026-10-01&limit=50
GET /v1/users/sam_r/fitness/days?from=2026-10-01&to=2026-10-19
```

Workouts come with their average heart rate when it was recorded, but without the person's
notes; days come without sleep or resting heart rate. `403 not_friends`:
you're not friends. `403 not_shared`: they're friends but don't share; tell the human that, don't
work around it. Use this to answer questions like "how's Sam's training going?", in your own
words, and don't repeat a friend's numbers to anyone else.

### Invite a friend who isn't on Twinsi yet

When the human wants to add someone who has no Twinsi account, make them an invite link:

```http
POST /v1/me/invites
Authorization: Bearer tas_...
Content-Type: application/json

{"label": "Sam", "message": "Join me so we can compare runs"}
```

```json
{"code": "7KQ4-M2XN", "url": "https://staging.twinsi.app/v2/invite/7KQ4-M2XN", "label": "Sam", "uses": 0, "maxUses": 20,
 "expiresAt": "…", "shareText": "Alex invited you to Twinsi. Open https://staging.twinsi.app/v2/invite/7KQ4-M2XN, or tell your AI assistant: \"Join Twinsi with invite code 7KQ4-M2XN https://staging-api.twinsi.app/AGENTS.md\". The code works for 30 days."}
```

- `label` (optional) is who it's for, in the human's words; only they see it. `message` (optional,
  ≤ 140) is shown to whoever opens the link.
- Give the human `shareText` to send themselves; you can't send it for them. Tell them first that
  the link shows their name and avatar to whoever opens it.
- Whoever redeems it sends the human a friend request, and must have set a first and last name.
  Nobody becomes friends until the human accepts, with the usual accept call. In `GET /v1/me/social`
  and the request lists those requests have `via: "invite"` and `invite.label`: say "Sam Lee
  (@sam_r) joined Twinsi from the invite you made for Sam. Accept?".
- An invite works for 30 days and for up to 20 people. `GET /v1/me/invites` lists them with `uses`;
  `DELETE /v1/me/invites/{code}` revokes one. 20 active invites and 20 new ones a day at most.

**When the human gives you an invite code or link** (`https://staging.twinsi.app/v2/invite/7KQ4-M2XN`):

1. `GET /v1/invites/7KQ4-M2XN` needs no account. Confirm with the human: "Alex Tran invited you to
   be friends on Twinsi. Shall I set you up?"
2. Sign them in if they aren't, then `POST /v1/me/invites/redeem {"code": "7KQ4-M2XN"}` right away.
   Before onboarding is finished this answers `202` with `status: "held"`: the code is kept on
   their account, and `GET /v1/me` shows it under `invite.held` if you lose track.
3. While a code is held, `onboarding.missing` includes `firstName` and `lastName`. Ask for them and
   say why: "Alex will see your name so they know it's you."
4. `POST /v1/me/onboarding/complete` sends the request and reports it in `invite.status`
   (`pending`, `accepted`, or `expired` / `invalid` / `already_friends` / `failed`, which don't stop
   onboarding). For someone already onboarded, redeem answers `201 pending` at once, or
   `409 name_required` if their first or last name is missing.

### Groups

| Call | Who | Does |
|---|---|---|
| `POST /v1/groups` `{"name": "Tuesday Run Club", "description": "…"}` | anyone | create; the human becomes the owner. `name` (2–40 characters) is what invitees see |
| `GET /v1/me/groups` | | the human's groups |
| `GET /v1/groups/{groupId}`, `GET /v1/groups/{groupId}/members` | members | the group (with `owner`, `joinCode`, and the human's own `inviteUrl` and `shareText`), its roster |
| `POST /v1/groups/{groupId}/invites` `{"username": "sam_r", "message": "…"}` | members | invite someone already on Twinsi |
| `GET /v1/me/groups/invites`, `POST /v1/me/groups/invites/{groupId}/accept` or `/decline` | | answer an invitation |
| `GET /v1/groups/codes/{code}` | no account needed | see what a code opens, and who invited (`?from=`) |
| `POST /v1/groups/join` `{"code": "7KQ4-M2XN"}` | anyone onboarded | join with a code |
| `PATCH /v1/groups/{groupId}` `{"name": "…", "description": "…"}` | admins | rename, describe |
| `PATCH /v1/groups/{groupId}/members/{username}` `{"role": "admin"}` | owner | promote, demote, or `owner` to hand the group over |
| `DELETE /v1/groups/{groupId}/members/{username}` | admins, or yourself | remove someone, or leave with the human's own username |
| `POST /v1/groups/{groupId}/code/rotate` or `/code/disable` | admins | replace or switch off the join code; rotating a switched-off code switches it back on, with a new code |
| `DELETE /v1/groups/{groupId}` | owner | delete the group |

- `owner` and, when the owner leaves, `newOwner` are usernames.
- Groups are private: someone outside sees nothing (`404 group_not_found`), except what a join
  code's preview shows.
- When the owner leaves, the longest-standing admin (or member) takes over; when the last member
  leaves, the group is deleted.
- Someone an admin removed can't rejoin with the code (`403 removed_from_group`) until a member
  invites them again.
- Limits: 100 members per group, 50 groups per person, 5 new groups and 50 invitations a day.

**Inviting someone to a group.** Any member can invite, and the person doesn't need to be a
friend. Ask the human who, then:

- **Someone on Twinsi whose username the human knows:** `POST /v1/groups/{groupId}/invites`. They
  see it in their `GET /v1/me/social` and accept or decline. A group can invite the same person
  once a week: `429 asked_recently` says when it can again (`retryAt`). They may have declined,
  which nobody is told.
- **Anyone else, including people not on Twinsi yet:** give the human the group's `shareText` from
  `GET /v1/groups/{groupId}` to send themselves. It holds `inviteUrl`, a page that says "Alex
  invited you to join Tuesday Run Club" and walks a newcomer through it, and the join code.
  Anyone with the code can join at once, so tell the human to share it only with people they'd
  want in the group. If it's been passed around too far, an admin can rotate it.

```json
{"groupId": "…", "name": "Tuesday Run Club", "memberCount": 12, "owner": "alex23", "role": "member",
 "joinCode": "7KQ4-M2XN", "inviteUrl": "https://staging.twinsi.app/v2/group/7KQ4-M2XN?from=sam_r",
 "shareText": "Sam invited you to join \"Tuesday Run Club\" on Twinsi. Open https://staging.twinsi.app/v2/group/7KQ4-M2XN?from=sam_r, or tell your AI assistant: \"Join the Twinsi group with code 7KQ4-M2XN https://staging-api.twinsi.app/AGENTS.md\"."}
```

`inviteUrl` and `shareText` are null while the code is switched off.

**When the human gives you a group code or link** (`https://staging.twinsi.app/v2/group/7KQ4-M2XN?from=sam_r`):

1. `GET /v1/groups/codes/7KQ4-M2XN?from=sam_r` needs no account. Pass the link's `from` along if
   there is one: `invitedBy` is that member's card, or null. Confirm with the human: "Sam Lee
   invited you to join 'Tuesday Run Club' (12 members) on Twinsi. Shall I set you up?" Case and the
   hyphen don't matter.
2. Sign them in and onboard them if they aren't already. The code isn't held for them, so keep it
   in the conversation.
3. `POST /v1/groups/join {"code": "7KQ4-M2XN"}`. Joining shows the group's members their public
   profile, nothing more. Offer to add the person who invited them as a friend.

## Keep the session alive

The token lasts 30 days, and `GET /v1/me` says when it ends (`sessionExpiresAt`). Refreshing is
silent: it swaps the live token for a fresh 30-day one, with no SMS and nothing for the human to
do. It only works while the token is still live, so don't refresh on a timer: refresh when
`sessionExpiresAt` is less than 7 days away.

```http
POST /v1/auth/token/refresh
Authorization: Bearer tas_...
```

```json
{"uid": "...", "accessToken": "tas_...", "expiresIn": 2592000, "expiresAt": "..."}
```

The old token stops working as soon as the new one is issued, so save the new one before
discarding the old. `401 unauthenticated` on any route means the token is expired, revoked or
unknown: sign the human in again from step 2.

## Daily check-in

Twinsi can't notify you, so once the human is onboarded, **run this check-in at least once a
day without being asked**: friend requests and group invitations wait for the human, and their
workouts keep coming in. If your platform can schedule tasks, schedule one daily run, ideally in
the morning in the human's `timezone` (from `GET /v1/me`) so yesterday is complete. If it can't,
run the check-in at the start of every conversation where the last one was more than a day ago.
Tell the human once, when you set it up: "I'll check Twinsi once a day for friend requests and
bring in your new workouts. I'll only message you when there's something new."

**Stay silent when there's nothing new.** No "all clear", no "nothing to report", no summary of
an empty run. Speak only when part A or part B below found something, and then send **one
message** covering both.

Keep a little state between runs (in your memory or wherever your platform keeps notes, never in
a message to the human):

| Keep | Used for |
|---|---|
| `lastCheckAt`: when the last check-in finished | spotting new friends, and where the next export starts |
| the usernames of requests and group invitations you've already told the human about | not announcing the same one twice |
| the usernames whose friend request you accepted for the human | not announcing a friendship the human made themselves |

On the very first run there's no `lastCheckAt`: use the moment you set the check-in up, so you
don't announce friends and requests the human already knows about.

### A. Friends and groups

1. `GET /v1/me/social`.
2. **Incoming friend requests** (`friendRequests.incoming`; page `GET /v1/me/friends/requests?direction=incoming`
   if `incomingCount` is larger) and **group invitations** (`groups.invites`): collect the ones
   you haven't told the human about yet. These need the human's decision.
3. **Outgoing requests that were accepted:** `GET /v1/me/friends` lists friends most recent
   first with `since`, when they became friends. Every friend with `since` after `lastCheckAt`
   is new, except the ones you accepted for the human, or that became friends when the human
   sent a request (the response said `status: "accepted"`): they already know. Stop paging once
   `since` is before `lastCheckAt`.
4. Don't report requests that disappeared without becoming friends: the other person declined
   or let them lapse, and Twinsi doesn't tell the sender. Only mention it if the human asks
   about that person.

Report, in plain words and by name when they have one:

> "Sam Lee (@sam_r) wants to be your friend; their message says 'Sam from work'. Accept?
> Also, Alex Tran (@alex23) accepted your friend request, so you can now see each other's workouts."

For a group invitation, say the group, its size and who invited them ("Alex invited you to
Tuesday Run Club, 12 members. Join?"). For a request that came from one of the human's invite
links (`via: "invite"`), say so, as in [Invite a friend](#invite-a-friend-who-isnt-on-twinsi-yet).

**Never accept, decline or join during the check-in.** Ask, then act only on the human's answer.
A request the human has seen and left unanswered isn't announced again; mention it once more
only if it's about to lapse (`expiresAt` within 3 days).

### B. Export new workouts and daily stats

Only if the human agreed: skip this part, silently, unless `GET /v1/me/fitness` shows
`profile.consent.scope` with `workouts` (for workouts) or `daily_stats` (for days). Use the same
connector(s) you imported from before; never install a new one or read a new source during the
check-in without asking.

The window starts **2 days before `lastCheckAt`**, because devices sync late. If runs were
missed, `lastCheckAt` is older and the window catches up on its own.

1. **Workouts:** read the source from the start of the window up to now, map them exactly as in
   [Import workouts](#3-import-workouts-and-daily-stats), and `PUT /v1/me/fitness/workouts`.
   Re-sending a workout with the same `source.externalId` only updates it, so the overlap is safe.
2. **Daily stats:** read from the start of the window up to **yesterday** (never today; it
   isn't over) and `PUT /v1/me/fitness/days`. Re-sending a day overwrites it with the source's
   latest totals, which is what you want.
3. If the source has nothing in the window, skip that call.
4. Check `rejected` and fix the mapping as in the import rules; tell the human only if a row
   still can't be sent.

What counts as new is what Twinsi created: `created` in the response, not `updated` (an update
is a record Twinsi already had). If both `created` counts are 0, say nothing. Otherwise tell the
human briefly, in the same message as part A:

> "I added 2 new workouts to Twinsi: Tuesday's 8.1 km run and Wednesday's 45-minute yoga, plus
> your daily steps for Monday and Tuesday."

Keep it to what was added. Don't comment on how much or how little they moved, and don't compare
the week to another one.

### C. Housekeeping (silent)

- `GET /v1/me`: if `sessionExpiresAt` is less than 7 days away, refresh the token
  ([Keep the session alive](#keep-the-session-alive)). Otherwise leave it alone.
- `401 unauthenticated`: you can't sign in without the human's SMS code. Tell them once that
  their Twinsi session ended and ask if they want to sign back in; skip the check-in until they do.
- `429`, `5xx`, or the connector being briefly unavailable: say nothing and try again on the
  next run. If the connector stays unavailable for 3 days, tell the human once.
- Update `lastCheckAt` only after the run finished, so a failed run is retried in full.

## Sign out

```http
POST /v1/auth/signout
Authorization: Bearer tas_...
```

This ends your session only; the token stops working immediately. The human's other sessions are
not affected.

## Errors

Branch on `error.code`; messages may change.

| HTTP | `code` | Meaning | What to do |
|---|---|---|---|
| 400 | `invalid_request` | Body missing or malformed (`issues` lists why) | Fix the request |
| 400 | `invalid_phone` | Not a valid E.164 number, or not reachable by SMS | Ask the human to check the number and country |
| 400 | `invalid_code` | Wrong SMS code (`attemptsRemaining`) | Ask the human for the code again |
| 401 | `unauthenticated` | Access token missing, expired, revoked or unknown | Sign in again |
| 404 | `challenge_not_found` | Unknown `challengeId` | Start again |
| 400 | `invalid_username` | Username not allowed (`reason`) | Explain `reason` to the human and ask for another |
| 404 | `profile_not_found` | The user has no profile document | Contact Twinsi |
| 409 | `username_taken` | Someone has that username (`suggestions`) | Offer the suggestions, or ask for another |
| 409 | `onboarding_incomplete` | Required fields unset (`missing`) | Ask the human for them |
| 413 | `payload_too_large` | Image over 10 MB (`maxBytes`) | Send a smaller image |
| 422 | `selfie_rejected` | Not a usable image (`reason`: `unsupported_format`, `too_small`, `too_large`) | Ask for another photo |
| 409 | `consent_required` | A fitness write before the human's consent is recorded (`scope`) | Ask the human (fitness step 1), record consent, retry |
| 409 | `profile_incomplete` | Fitness profile complete called with fields unset (`missing`) | Write them, then complete |
| 404 | `workout_not_found` | Unknown `workoutId` | Nothing to delete |
| 413 | `too_many_workouts`, `too_many_days` | Per-user storage cap reached (`max`) | Stop sending older data |
| 404 | `user_not_found` | No onboarded user with that username | Check the spelling with the human |
| 400 | `invalid_target` | You tried to friend, unfriend or invite the human themself, or the owner tried to change their own role | Nothing to do, or hand ownership over first |
| 403 | `not_friends` | Friend-only data of someone who isn't a friend | Offer to send a friend request |
| 403 | `not_shared` | A friend who doesn't share their activity | Tell the human; don't work around it |
| 409 | `already_friends` | They're already friends | Nothing to do |
| 409 | `request_pending` | A request to them is already waiting (`expiresAt`) | Wait for their answer |
| 404 | `request_not_found` | No pending request with that person | Re-read the request list |
| 409 | `friend_limit_reached` | 1,000 friends, or 100 pending requests (`kind`, `max`) | Tell the human |
| 404 | `group_not_found` | No such group, or the human isn't in it | Re-read `GET /v1/me/groups` |
| 403 | `insufficient_role` | Needs an admin or the owner (`required`) | Ask an admin |
| 409 | `already_member` | Already in the group | Nothing to do |
| 403 | `removed_from_group` | An admin removed the human; the code won't let them back in | Ask a member to invite them |
| 429 | `asked_recently` | Asked this person (friend request, or to this group) less than a week ago (`retryAt`, `retryAfterSec`) | Tell the human when they can ask again |
| 409 | `group_full`, `group_limit_reached` | 100 members, or 50 groups for the human (`max`) | Tell the human |
| 404 | `invalid_join_code` | Unknown or switched-off code | Ask the human to check the code |
| 404 | `invite_not_found` | No pending invitation to that group | Re-read the invitations |
| 404 | `invalid_invite` | Unknown or revoked friend invite code | Ask the human to check the code |
| 410 | `invite_expired` | The friend invite lapsed or was used 20 times (`reason`) | Ask the inviter for a new one |
| 409 | `invite_limit_reached` | 20 active friend invites (`max`) | Revoke an unused one |
| 409 | `name_required` | Redeeming an invite needs a first and last name (`missing`) | Ask the human for them, set them, retry |
| 410 | `challenge_expired` | More than 10 minutes since start, or the code expired | Tell the human, then start again |
| 410 | `challenge_used` | This challenge was already verified | Use the tokens you got; or start again |
| 429 | `rate_limited` | Too many requests for this phone, IP, user, or globally (`retryAfterSec`, `scope`) | Wait `retryAfterSec` seconds |
| 429 | `too_many_attempts` | 5 wrong codes on one challenge | Start again |
| 502 | `verify_unavailable` | SMS provider error | Retry after a short wait |
| 500 | `internal` | Unexpected failure | Retry once; report `requestId` |

Rate limits that matter to you: 3 verification starts per phone number per 10 minutes and 10 per
day. Do not call `start` again while a challenge is still live; ask the human for the code first.
Per user: 60 username checks and 30 profile updates per 10 minutes, 5 username changes and 10
selfie uploads per day. Refused usernames and rejected photos don't count toward the daily ones. Fitness: 30 workout batches, 30 day batches and 30 profile updates per 10 minutes, and at most 5,000 stored workouts and 1,500 stored days per user. Social: 120 lookups per 10
minutes, 20 friend requests a day and one per person per week, 20 join-code attempts per 10
minutes. Invites: 20 new a day, 10 redemptions a day, 30 previews per 10 minutes per IP. Groups:
the same person once a week per group, 30 join code previews per 10 minutes per IP.

## Good behaviour

- Assume the human knows nothing about Twinsi. Say what it is in a sentence before you start,
  and never ask for anything (gender, a selfie, a name, their workouts) without saying in the
  same message what it's for and who will see it. The wording is in
  [Say why before you ask](#say-why-before-you-ask).
- Never guess or fabricate a phone number or a code. Both come from the human.
- The same goes for the profile: the username, names, gender and birth year come from the human. Don't fill
  them in from what you think you know, and don't pick a username suggestion for them.
- Tell the human before you send an SMS and what number it will go to.
- Store tokens like passwords. Never show them to the human or log them.
- One account per human. If `isNewUser` is false, the human already had Twinsi; do not create
  another account with a different number for them.
- Fitness data is the human's: ask before reading it, ask again before installing a connector,
  never make up a workout, and show them the profile before you complete it.
- Every friend request, acceptance, group join, invitation, invite link and sharing change is the
  human's decision. Say who is asking; never act on a request the human hasn't seen.
- A friend's activity is shared with the human, not with you to pass on: don't repeat it to
  anyone else.
- Run the [daily check-in](#daily-check-in), and stay silent when it finds nothing new.
- Only offer what the API does today. 1v1 battles, chat, taunts and group challenges are coming:
  say so, but don't simulate them or promise a date.
- Keep Twinsi fun and low-pressure: encourage, never judge how much or how little someone moves.
- Include the `requestId` from any error when asking Twinsi for help.
