# 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**. Each person gets
a Twinsi: an avatar that looks like them, shaped by how they actually move. They share their
workouts and daily activity with friends, see how their friends are doing, and get together in
groups. It's about moving together and having fun, not about being the fittest.

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 social fitness app you use with your friends. It's meant to be fun and
> low-pressure: you get a Twinsi, an avatar that looks like you, built from your workouts, and
> you can follow how your friends are training and start groups together. I can set it all up
> for you right here: create your account, build your fitness profile from your workouts, and
> add your friends. There's also an iPhone app if you'd rather tap through it yourself. 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) | humans who want Twinsi on their phone. There's no Android app yet |

The official website is https://staging.twinsi.app. If the human asks about the app, give
them the App Store link; you can't install it for them. For an Android user, say there's no
Android app yet and that you can set them up through the API instead.

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) |

### 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.
2. **Set up their profile**: a username, gender, optionally a name and a selfie. Twinsi makes
   their avatar from it.
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.
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` isn't `ready` | stage 3: follow `next` |
| `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. 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.

## 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, ask the human for:

| What | Required | Who sees it |
|---|---|---|
| Username | yes | everyone on Twinsi; unique |
| Gender, `male` or `female` | yes | everyone (their avatar shows it) |
| First and last name | no | their friends |
| Birth year | no | their friends |
| A selfie | no | nobody; it's only used to make their Twinsi avatar look like them |

`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.

### 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 ("so your Twinsi looks like you") before asking. 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. Twinsi
uses it to make their character unique to them, to show anyone on Twinsi what kind of athlete
they are, and to show their friends their fitness habits.
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:

> "To set up your Twinsi, I'd like to pull your workouts and daily steps from the last 30 days
> and write a short fitness profile. Anyone on Twinsi can see a short version of the profile:
> the summary, your main sports, your level and a few totals. If you share with friends, your
> Twinsi friends also see your workouts, your daily steps and calories, your goals and when you
> usually train. Nobody sees your sleep or heart rate. OK to go ahead, and should I share with
> your friends?"

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, stop here;
you can offer again later.

### 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, what they're working toward,
and whether anything is missing (a sport with no device, a recent injury or break). Two or
three questions, not a form. 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 names, 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, avatar, gender and the public part of their
fitness profile. Nobody but the human ever sees their phone number, selfie, sleep, 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 ("@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`.
`enable_sharing` means the human has friends but shares no activity with them; offer to turn it
on (`PATCH /v1/me/fitness/profile {"sharing": {"friends": true}}`), don't do it unasked.

### 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. Names (`firstName`, `lastName`) and `birthYear` appear
only for friends.

### 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.
- 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 without the person's notes; days without sleep or 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\". 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 **with their first and last name on it**.
  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. The response has `joinCode` |
| `GET /v1/me/groups` | | the human's groups |
| `GET /v1/groups/{groupId}`, `GET /v1/groups/{groupId}/members` | members | the group, its roster |
| `POST /v1/groups/{groupId}/invites` `{"username": "sam_r"}` | members (or admins, if `whoCanInvite` is `admins`) | invite someone |
| `GET /v1/me/groups/invites`, `POST /v1/me/groups/invites/{groupId}/accept` or `/decline` | | answer an invitation |
| `GET /v1/groups/codes/{code}` then `POST /v1/groups/join` `{"code": "7KQ4-M2XN"}` | anyone | see what a code opens, then join |
| `PATCH /v1/groups/{groupId}` | admins | rename, describe, set `whoCanInvite` |
| `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 |
| `DELETE /v1/groups/{groupId}` | owner | delete the group |

- A **join code** like `7KQ4-M2XN` lets anyone who has it join at once. The human shares it
  themselves (read it out, text it). When the human gives you a code, preview it and confirm
  ("That's 'Tuesday Run Club', 12 members. Join?") before joining. Case and the hyphen don't matter.
- Groups are private: someone outside sees nothing (`404 group_not_found`).
- When the owner leaves, the longest-standing admin (or member) takes over; when the last member
  leaves, the group is deleted.
- Limits: 100 members per group, 50 groups per person, 5 new groups and 50 invitations a day.

## Keep the session alive

The token lasts 30 days. To keep a session going without asking the human for a new code,
exchange it for a fresh 30-day token from time to time (weekly is plenty):

```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.

## 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 | Nothing to do |
| 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 |
| 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.

## Good behaviour

- 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.
- 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.
