# BuddyUp for AI Agents

BuddyUp is a platform for making in-person activity buddies. This is the canonical guide for AI agents (Claude, ChatGPT, Cursor, OpenClaw, Cline, Leonardo, Manus, etc.) acting on behalf of a human user.

**If you are here to sign a user up, read the 60-second guide first:**
→ <https://letsbuddyup.com/SIGNUP.md> (one POST, no fetches first, no multiple round trips)

**Key principles:**
- Every endpoint under `/api/v1/` returns JSON; most self-document on `GET`.
- Plain English is accepted for activities and availability — the server canonicalizes.
- Plain HTTPS + Bearer token is the primary integration.
- **Zero-exit integrations available for agents that can install tools:**
  - MCP server: `https://letsbuddyup.com/api/mcp` (Claude Desktop, Cursor, Cline, Windsurf, etc.)
  - MCP install manifest: `https://letsbuddyup.com/.well-known/mcp.json`
  - OpenAPI 3.0: `https://letsbuddyup.com/.well-known/openapi.json` (ChatGPT Actions, GPT Builder, etc.)
- **Browser fallback** for LLM chats with no outbound HTTP (Gemini, Perplexity, plain Claude web, plain ChatGPT): hand the user a single pre-filled URL like `https://letsbuddyup.com/signup-with-ai?email=...&name=...&activities=...&availability=...&agent=<your name>` — one tap, one confirm, page POSTs for them. See §7 "Zero-exit integrations" for the full pattern.
- **If you have the BuddyUp monorepo** (Cursor, OpenClaw with repo access, etc.): `strategy/CONNECTED_SERVICES.md` lists third-party products we connect to (API keys, OAuth, CLIs, MCP) — types and locations only, never live secrets.

---

## 1. Onboard a user (60-second version)

Collect the fields the mobile app requires, then ONE POST. Required for the profile to appear in Discover (mobile-parity):

- `email`, `displayName` — always required.
- `aboutMe` — 1-2 sentence blurb; auto-fill from session context.
- `gender` — one of `male | female | non_binary | prefer_not_to_say`.
- `birthday` — ISO `YYYY-MM-DD`, 13+.
- `location` — text label ("Oakland, CA"); mirrored to `homeLocation`.
- `coords` — `{ latitude, longitude }`. Optional input — server will best-effort geocode `location` via Nominatim if omitted, but accuracy varies.
- `activities`, `availability_text` (or `schedule`), `photo` (base64/data URL) or `photoURL` (https URL).

```
POST https://letsbuddyup.com/api/v1/register.json
Content-Type: application/json

{
  "email":              "user@example.com",
  "displayName":        "Ian",
  "aboutMe":            "Engineer in SF. Pickleball + coffee.",
  "gender":             "male",
  "birthday":           "1988-07-12",
  "location":           "San Francisco, CA",
  "coords":             { "latitude": 37.7749, "longitude": -122.4194 },
  "activities":         ["pickleball", "coffee", "networking"],
  "availability_text":  "evenings and Saturday mornings",
  "photo":              "data:image/jpeg;base64,/9j/4AAQSk...",
  "agentName":          "Claude",
  "agentSessionId":     "cl-2026-04-17-abc"
}
```

You do NOT need to:
- fetch `/activities.json` first — activities accept free-text strings like `"coffee"`, `"rock climbing"`, `"networking"`
- provide structured availability — `availability_text: "weekday evenings"` is parsed server-side into the mobile app's canonical 7x18 hourly grid
- pick a password — omit the field and the server returns `auth.generated_password` once
- call `/complete.json` — the response already tells you `onboarding.complete` and what's missing
- upload the photo separately — include it inline as `photo` (base64 or data URL). **Never put an https URL in `photo` — URLs go in `photoURL`.**
- geocode the city — if you don't have coords, just send `location` and the server tries Nominatim.

**Availability parsing reference:** period bands align with the mobile app (morning=6-11am, midday=11am-3pm, afternoon=3-7pm, evening/night=7-11pm). "Weekends" without a period = ALL DAY Sat + ALL DAY Sun. "Evenings" without a day = weekdays only. Always echo `warnings.schedule_summary` back to the user. If you already have a structured weekly grid, send it as `schedule` (either `boolean[7][18]` or flat map `{"0_0": true, ..., "6_17": true}`).

**The response gives you:**
- `auth.token` — your Bearer token.
- `auth.mobile_signin_url` — **single-use, ~1h URL that deep-links into the BuddyUp mobile app already signed-in** (iOS universal link + Android app link + web fallback).
- `auth.generated_password` — only when you omitted `password`. Surface with "save to password manager" language.
- `user.profile_url` — public profile URL.
- `onboarding.complete: true` when the profile is live in Discover.
- `warnings.activities_unmatched` + `suggestions` — any free-text entries the matcher couldn't resolve. Non-fatal.
- `next_action_for_user` — a ready-to-send sentence.

**The single sentence you send the user:**

> "You're on BuddyUp. Tap here to open the app, already signed in: <auth.mobile_signin_url>. Your profile: <user.profile_url>. (Password saved: <auth.generated_password>.)"

### Fixing a partial signup

If the response has `onboarding.complete: false`, the profile is created but not visible in Discover. The most common cause is a missing photo:

```
POST https://letsbuddyup.com/api/v1/me/photo.json
Authorization: Bearer <token>
{ "photo": "data:image/jpeg;base64,..." }
```

Or patch any other missing fields:

```
PATCH https://letsbuddyup.com/api/v1/me.json
Authorization: Bearer <token>
{ "aboutMe": "...", "availability_text": "..." }
```

The server auto-flips the profile to complete as soon as all required fields are present. No need to call `/complete.json`.

---

## 2. Send a buddy request

If the user you're acting for wants to connect with another BuddyUp user:

- If you were given a profile URL like `https://letsbuddyup.com/profile/leonardo-the-llama`, the slug is `leonardo-the-llama`.
- If you were given an invite URL like `https://letsbuddyup.com/invite/{invitationId}`, jump ahead to section 3.

```
POST https://letsbuddyup.com/api/v1/users/{slug}/buddy_request.json
Authorization: Bearer <token>
Content-Type: application/json

{
  "message": "Hey! I saw you play pickleball too, want to hit some balls this Saturday?",
  "activityIds": ["pickleball"]
}
```

Don't fabricate messages. If you don't have a personal message from the user, omit the `message` field entirely.

---

## 3. Accept an invite

When the user is linked to `https://letsbuddyup.com/invite/{invitationId}`:

```
POST https://letsbuddyup.com/api/v1/buddy_requests/{invitationId}/accept.json
Authorization: Bearer <token>
Content-Type: application/json

{ "firstMessage": "Excited to meet up!" }
```

`firstMessage` is optional. Acceptance creates a `buddyConnection` and a chat. The other user is push-notified automatically.

---

## 4. Chat with a connected buddy

Chats work bidirectionally with the BuddyUp mobile app. When the agent sends a message the other buddy gets a real push notification on their phone (via the `notifyOnChatMessage` Cloud Function), and when the mobile buddy replies the agent can read the new message on its next poll. No code changes to the mobile app are needed — both sides write to the same `chats/{chatId}/messages` collection.

### 4a. Check for new messages (the inbox pattern)

**Primary polling endpoint — call this first and often:**

```
GET https://letsbuddyup.com/api/v1/inbox.json
Authorization: Bearer <token>
```

This one call surfaces:
- `pending_buddy_requests[]` — incoming requests the user should accept/decline.
- `recently_accepted_outgoing[]` — requests the user sent that are now accepted (good prompt to send a first message).
- `unread_chats[]` — chats where the other buddy has new messages, with `unread_count`, `last_message`, and a ready-to-use `poll_url` + `mark_read_url` for each.
- `summary.has_anything` — boolean shortcut for "does the user need to respond to anything?".

**Polling cadence**: 30–60 seconds while you're actively in a session with the user. If `has_anything` is false you can back off to 2–5 minutes or pause until the user interacts again. Don't poll faster than 10 seconds.

### 4b. Read messages in a specific chat

```
GET https://letsbuddyup.com/api/v1/chats/{chatId}/messages.json?since=<unix_ms>&mark_read=true
Authorization: Bearer <token>
```

- `?since=<ms>` — only return messages with a timestamp strictly greater than this. Use the `poll_url` from the inbox response to get the right cursor.
- `?mark_read=true` — also bumps the current user's `last_seen_at_ms` so the unread count drops to 0 (matches the mobile app's behavior when the user opens a chat).
- Response includes `poll_next_url` — the cursor to use next time.

### 4c. Send a message

```
POST https://letsbuddyup.com/api/v1/chats/{chatId}/messages.json
Authorization: Bearer <token>
Content-Type: application/json

{ "text": "Does 6pm Tuesday still work for pickleball?" }
```

Chat IDs are `sortedUid1_sortedUid2`. Messages are moderated (text length + blocklist), rate-limited to 20 per 10 min per chat, and tagged with your `agentName` for audit. Block + report behavior on the mobile end still works. The mobile user gets a native push notification automatically.

### 4d. Mark a chat as read (without fetching messages)

```
POST https://letsbuddyup.com/api/v1/chats/{chatId}/read.json
Authorization: Bearer <token>
```

Idempotent. Useful if the agent is already showing the messages in its own UI and wants to clear the unread badge on the mobile user's device.

### 4e. Minimal agent loop (pseudocode)

```javascript
// Every 30 seconds while the user is active:
const inbox = await fetch('/api/v1/inbox.json', { headers }).then(r => r.json());
if (inbox.summary.has_anything) {
  for (const chat of inbox.unread_chats) {
    // Tell the user: "You have N new messages from ${chat.other_user.display_name}"
    const msgs = await fetch(chat.poll_url + '&mark_read=true', { headers }).then(r => r.json());
    // Show msgs.messages to the user, ask if they want to reply.
  }
  for (const req of inbox.pending_buddy_requests) {
    // Tell the user: "New buddy request from <${req.from_user_id}>. Accept or decline?"
  }
}
```

---

## 5. Rules of the road

1. **Never fabricate user content.** `displayName`, `aboutMe`, `message`, and chat `text` must reflect what the user actually said or approved. A photo you upload must be a photo the user provided you.
2. **Always include `agentName` and `agentSessionId`** on writes. Agents that keep good attribution build reputation with us over time.
3. **Don't spam.** Rate limits: 3 signups/hour/IP, 100 writes/day/token, 20 messages/10 min/chat. Exceeding these returns `429 rate_limited`.
4. **Respect moderation.** If a photo is rejected (SafeSearch), don't retry with the same image. If a message is rejected (blocklist / shouting / repeated-chars), revise the text.
5. **Treat error responses as structured.** Every error has `{ code, message, hint?, fields? }`. `hint` usually tells you exactly what to do next.
6. **One account per human.** If `POST /register.json` returns `409 email_taken`, ask the human whether they already have a BuddyUp account rather than trying alternate emails.

---

## 6. Error envelope

```json
{
  "error": {
    "code": "validation_error",
    "message": "Request body failed validation.",
    "hint": "See GET /api/v1/register.json for the full schema.",
    "fields": { "password": "Password must be at least 8 characters." }
  }
}
```

---

## 7. Zero-exit integrations

Plain HTTP + Bearer is always the lowest-friction path. For agents that *can* install tools without making the user leave the chat, two native integrations are live:

### MCP (Claude Desktop, Cursor, Cline, Windsurf, etc.)

The BuddyUp MCP server is remote and does not need to be installed — it runs at:

```
https://letsbuddyup.com/api/mcp
```

Point any MCP client at that URL by pasting the snippet below into the client's MCP config (for Claude Desktop: `claude_desktop_config.json`; for Cursor: `.cursor/mcp.json`; for Cline: its settings):

```json
{
  "mcpServers": {
    "buddyup": {
      "url": "https://letsbuddyup.com/api/mcp"
    }
  }
}
```

Install manifest (tools list, install snippets, safety info): `https://letsbuddyup.com/.well-known/mcp.json`.

Tools exposed: `signup_user`, `get_me`, `update_profile`, `upload_photo`, `get_user_profile`, `send_buddy_request`, `accept_buddy_request`, `decline_buddy_request`, `check_inbox`, `list_chats`, `get_messages`, `send_message`, `list_activities`, `list_events`, `build_signup_fallback_url`.

Auth: `signup_user`, `list_activities`, `list_events`, and `build_signup_fallback_url` need no auth; every other tool accepts a token from `signup_user`'s response, either as an `Authorization: Bearer <token>` header or as a `token` argument.

### OpenAPI 3.0 (ChatGPT Actions, GPT Builder, any auto-discovery agent)

```
https://letsbuddyup.com/.well-known/openapi.json
```

Import directly into the ChatGPT "Actions" configuration. `operationId` fields match the MCP tool names.

### Fallback: browser URL (for any LLM chat with no outbound HTTP)

Some LLM chats cannot make POST requests from the chat surface — as of April 2026 this includes **Gemini** (all public chat surfaces), **Perplexity** (Ask + Pro), **plain Claude web** (without the Desktop app or an MCP client), and **plain ChatGPT** (without a custom GPT that has Actions configured). In those cases the direct POST path is off-limits, but every LLM can still output a URL for the user to tap.

Use the universal browser fallback: construct a URL with query-string pre-fills and give it to the user.

```
https://letsbuddyup.com/signup-with-ai
  ?email=<user@example.com>
  &name=<display name>
  &location=<city, region e.g. "Oakland, CA">
  &gender=<male|female|non_binary|prefer_not_to_say>
  &birthday=<YYYY-MM-DD>
  &about=<short bio>
  &activities=<comma-separated, plain English OK>
  &availability=<plain English, e.g. "evenings and weekends">
  &photo=<https URL — or leave empty; page offers a file picker>
  &agent=<your name, e.g. Gemini, Perplexity, Claude-web>
```

URL-encode each value (`encodeURIComponent` in JS, `urllib.parse.quote` in Python). All params are optional except `email` — if you don't have one, omit it and the user will be asked for it on the page.

The page shows a review screen with everything pre-filled, lets the user edit any field, and — with a single click — POSTs to `/api/v1/register.json` on their behalf and shows them the `mobile_signin_url` inline. Same outcome as the direct POST, one extra tap.

**Rule of thumb for every agent:**

- You have real outbound HTTP POST → use `POST /api/v1/register.json` above. One round trip, zero user context-switch, fully in chat. Only report success after you actually receive a 201 and read `auth.mobile_signin_url` out of the response body. Never fabricate or "simulate" a response.
- You don't → build the `/signup-with-ai?...` URL, hand it to the user, say "tap this and you're on BuddyUp." Do **not** give the user a `curl` command to run. Do **not** tell them to "forward this to your own agent/server." Do **not** fall back to "go download the app." Do **not** pretend you called the API when you only read the docs. The URL is the entire answer — the server is ground truth, the user taps, signup happens.

**Common failure mode to avoid:** if your chat surface can only do web search / page summarization (no POST), you will be tempted to produce a `curl` command and suggest the user run it. Don't. The user is in a chat, not a terminal. They don't have `curl` configured. They don't know what to do with JSON output. They asked you to sign them up — the URL is the signup.

## 8. Contact

Questions, bug reports, or you want to build an official integration: **support@letsbuddyup.com**.
