# Keeper Agent API (Full Reference)

> Keeper is an AI-powered matchmaking platform. The Keeper Agent API is a REST API that lets AI agents manage a user's dating profile, preferences, photo tests, and questionnaire answers on their behalf.

This document contains everything an AI agent needs to fully integrate with the Keeper Agent API.

---

## Table of Contents

1. [Authentication](#authentication)
2. [Using the API](#using-the-api)
3. [Endpoints](#endpoints)
4. [Error Codes](#error-codes)
5. [Rate Limits](#rate-limits)
6. [Scopes](#scopes)
7. [Discovery & Metadata](#discovery--metadata)

---

## Authentication

Two authentication flows are available. **Quick Start works for any agent type** (cloud agents, local agents, any device), but the Settings → Connected Agents section it relies on is **rolling out behind a feature flag** — if the user's Settings does not show it yet: local agents can use Browser OAuth; remote/cloud agents must wait for the rollout (Browser OAuth needs a loopback redirect on the user's machine). Browser OAuth is fully automated but requires the agent and user's browser to be on the **same machine** (loopback redirect).

All authenticated requests use: `Authorization: Bearer keeper_<key>`

Key prefix is always `keeper_`.

### Flow 1: Quick Start (Recommended)

Works for ALL agents regardless of where they run — local, remote, cloud, chat bots, etc. Availability note: Connected Agents is rolling out behind a feature flag; if step 2 below is not visible in the user's Settings, local agents can fall back to Browser OAuth; remote/cloud agents must retry later once the rollout reaches the user.

**Steps:**

1. Ask the user to open their Keeper app and go to **Settings**.
2. In Settings, find the **Connected Agents** section.
3. Ask the user to click **Generate API Key**.
4. The app will display a key starting with `keeper_`. Tell the user to copy it — it is shown only once.
5. Ask the user to paste or send you the key.
6. Use the key as `Authorization: Bearer keeper_<key>` for all API calls.

Settings URL: https://app.keeper.ai/settings

### Flow 2: Browser OAuth (Local Agents Only)

This flow is fully automated but ONLY works when the agent can run a local HTTP server on the SAME machine as the user's browser.

**When to use:**
- Agent is a desktop app on the user's machine
- Agent is a CLI tool the user runs locally
- Agent is a browser extension on the user's machine

**Does NOT work when:**
- Agent runs on a remote server (cloud, Telegram bot, etc.)
- Agent runs on a different device than the user's browser
- Agent cannot open a local HTTP server on the user's machine

#### Step 1: Start a Local Callback Server

Before anything else, start an HTTP server on `127.0.0.1` that listens on a free port. This server will receive the authorization code after the user approves. The server MUST be running and listening BEFORE you open the auth URL.

- Callback path: `/callback`
- Example: `http://127.0.0.1:9876/callback`
- **CRITICAL**: The `redirect_uri` MUST use `http://` (not `https://`) and MUST be one of: `http://127.0.0.1:<port>/...`, `http://localhost:<port>/...`, or `http://[::1]:<port>/...` — ANY other URL will be rejected.

Listen for a GET request to your callback path with query parameter `code` (on success) or `error` (on denial). Ignore any other requests (browsers send extra requests like favicon.ico — these are NOT the OAuth callback). Return an HTML page saying "Connected successfully! You can close this tab." so the user knows it worked.

#### Step 2: Generate PKCE Credentials

Generate a cryptographically random `code_verifier` (43-128 characters, using `[A-Za-z0-9._~-]`). Then compute:

```
code_verifier  = random_base64url(48)
code_challenge = base64url_no_padding(sha256(code_verifier))
```

#### Step 3: Open Authorization URL in the User's Browser

Construct the URL below and open it in the user's browser. The user will see a consent screen and click Approve or Deny. Do NOT fetch this URL via HTTP — it must be opened as a browser page because the user needs to interact with it.

**Authorization URL:** `https://app.keeper.ai/agent-authorize`

**Query Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `client_id` | Yes | Your agent's identifier (any non-empty string) |
| `redirect_uri` | Yes | URL of your local callback server. MUST be a loopback address with `http://` protocol |
| `code_challenge` | Yes | Base64url-encoded SHA-256 hash from Step 2 |
| `code_challenge_method` | Yes | Must be exactly `S256` |
| `scope` | No | Defaults to `manage_profile`. See [Scopes](#scopes) |
| `state` | No | Random string for CSRF protection. Returned unchanged in callback |
| `client_name` | No | Human-readable name shown on consent screen |

Do NOT include `response_type` — this is not standard OAuth, omit it.

**Example URL:**

```
https://app.keeper.ai/agent-authorize?client_id=my-agent&client_name=My+Agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&scope=manage_profile&state=abc123
```

#### Step 4: Receive the Callback

After the user approves, their browser redirects to your callback server:

- **Success:** `GET <redirect_uri>?code=<authorization_code>&state=<state>`
- **Denial:** `GET <redirect_uri>?error=access_denied&state=<state>`

The authorization code is **single-use** and expires quickly. Exchange it immediately in Step 5.

#### Step 5: Exchange Code for API Key

POST to the token endpoint with the authorization code and your original `code_verifier`.

**URL:** `https://prod-api.keeper.ai/v1/agent/oauth/token`
**Method:** POST
**Content-Type:** `application/json`

**Request Body:**

```json
{
  "grant_type": "authorization_code",
  "code": "<the code from Step 4 callback>",
  "code_verifier": "<the original code_verifier from Step 2>",
  "client_id": "<the same client_id from Step 3>",
  "redirect_uri": "<the exact same redirect_uri from Step 3>"
}
```

**Example curl:**

```bash
curl -X POST https://prod-api.keeper.ai/v1/agent/oauth/token \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "authorization_code",
    "code": "CODE_FROM_CALLBACK",
    "code_verifier": "YOUR_CODE_VERIFIER",
    "client_id": "my-agent",
    "redirect_uri": "http://127.0.0.1:9876/callback"
  }'
```

**Success Response:**

```json
{
  "access_token": "keeper_<key>",
  "token_type": "Bearer", "expires_in": null,
  "scope": "manage_profile"
}
```

The `access_token` is a **permanent** API key. Store it securely — it is shown only once.

**Error Responses:**

| Error | Meaning |
|-------|---------|
| `invalid_grant` | Code is expired, already used, or `code_verifier` does not match. Start over from Step 3. |
| `invalid_request` | Missing or malformed fields in the POST body. Check all fields are present. |

#### Step 6: Make API Calls

Use the `access_token` as described in the [Using the API](#using-the-api) section.

### Common Authentication Mistakes

- Using `https://` in `redirect_uri` — MUST be `http://` for loopback
- Using a non-loopback hostname — MUST be `127.0.0.1`, `localhost`, or `[::1]`
- Including `response_type=code` — this is NOT standard OAuth, omit it
- Not running a local HTTP server before opening the auth URL
- Waiting too long to exchange the code — it expires quickly
- Reusing an authorization code — codes are single-use
- Mismatched `redirect_uri` between Step 3 and Step 5 — they must be identical
- Fetching the authorization URL via curl instead of opening in a browser
- Exchanging the code against a different server than issued it

---

## Using the API

**Base URL:** `https://prod-api.keeper.ai/v1/agent`

**Required Header:** `Authorization: Bearer keeper_<your-key>` — every endpoint except `POST /auth/validate`, which takes the key in its JSON body instead

**Example:**

```bash
curl https://prod-api.keeper.ai/v1/agent/profile -H 'Authorization: Bearer keeper_YOUR_KEY'
```

All paths below are relative to the base URL. For example, `GET /profile` means `GET https://prod-api.keeper.ai/v1/agent/profile`.

---

## Endpoints

### Profile

#### GET /profile

Read the authenticated user's profile.

```bash
curl https://prod-api.keeper.ai/v1/agent/profile \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** JSON object with profile fields including:
`relationshipReadinessScore`, `height`, `ethnicities`, `educations`, `career`, `financialStability`, `children`, `politics`, `religion`, `interests`, `testScores`, `personality`, `location`, `anythingWeMissed`

Only allowed fields are returned — internal fields are stripped.

#### PATCH /profile

Update the user's profile (partial update). Only include fields you want to change.

```bash
curl -X PATCH https://prod-api.keeper.ai/v1/agent/profile \
  -H 'Authorization: Bearer keeper_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"height":{"heightSystem":"metric","height":180},"interests":[{"interestId":12,"interest":"Hiking","importance":4}]}'
```

**Request Body:** JSON object with any subset of the profile fields listed above. `personality` additionally accepts explicit `null` to clear the stored assessment entirely (an empty object leaves existing scores untouched); the other nested groups reject top-level `null` (use their documented nested clears instead).

**Response:** Updated profile object.

### Preferences

#### GET /preferences

Read the user's dating preferences.

```bash
curl https://prod-api.keeper.ai/v1/agent/preferences \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** JSON object with preference fields including:
`age`, `height`, `personality`, `appearance`, `ethnicity`, `location`, `whatElse`

#### PATCH /preferences

Update dating preferences (partial update).

```bash
curl -X PATCH https://prod-api.keeper.ai/v1/agent/preferences \
  -H 'Authorization: Bearer keeper_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"age":{"ideal":[27,33],"acceptable":[25,40]}}'
```

**Request Body:** JSON object with any subset of the preference fields listed above.

**Response:** always an empty object `{}` — the update result is stripped server-side; fetch `GET /preferences` for the new state.

#### GET /preferences/items

List the user's non-deleted BUNDLED preference items with the ids the rerank endpoints require — age, height, ethnicity, and location rows are grouped bundles with representative metadata, not raw preference rows. Rerank ids (`id`, `afterId`, `beforeId`) come from this endpoint — never invent them.

```bash
curl https://prod-api.keeper.ai/v1/agent/preferences/items \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** `{ "items": [...] }` where each item carries `id`, `preferenceCategory`, `rankSection`, `rankIndex` (lexicographic position within its `rankSection`), `preferenceSource`, and a category-dependent `data` payload.

#### PUT /preferences/:id/rerank

Rerank a single preference by ID.

```bash
curl -X PUT https://prod-api.keeper.ai/v1/agent/preferences/42/rerank \
  -H 'Authorization: Bearer keeper_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"rankSection": "very_important"}'
```

**Request Body:**

| Field | Required | Description |
|-------|----------|-------------|
| `rankSection` | Yes | One of: `non_negotiable`, `very_important`, `somewhat_important`, `nice_to_have`, `disabled`, `deleted`, `unranked`. Reranking to `deleted` removes the preference — it no longer appears in `GET /preferences/items`. |
| `afterId` | No | To move this preference to the position immediately after preference Y, set `afterId = Y.id` |
| `beforeId` | No | To move this preference to the position immediately before preference Y, set `beforeId = Y.id` |

#### POST /preferences/rerank

Batch rerank multiple preferences in one request.

```bash
curl -X POST https://prod-api.keeper.ai/v1/agent/preferences/rerank \
  -H 'Authorization: Bearer keeper_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"actions": [{"id": 42, "rankSection": "very_important"}, {"id": 43, "rankSection": "nice_to_have"}]}'
```

**Request Body:**

| Field | Required | Description |
|-------|----------|-------------|
| `actions` | Yes | Array of objects, each with `id` (required), `rankSection` (required), `afterId` (optional), `beforeId` (optional) |

### Status

#### GET /status

Get profile completion status and onboarding progress.

```bash
curl https://prod-api.keeper.ai/v1/agent/status \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** JSON object with completion percentages and onboarding state.

### Photos

#### GET /photos/credits

Get photo test credit balance.

```bash
curl https://prod-api.keeper.ai/v1/agent/photos/credits \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** JSON object with available credits.

#### GET /photos/tests

List all photo tests.

```bash
curl https://prod-api.keeper.ai/v1/agent/photos/tests \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** JSON object with a `tests` array. Each test carries `trialId`, `mediaId`, `requestedRatings`, `completedRatings`, `score` (number, or null until scored), `status`, `createdAt`, and `completedAt` (null while the test is running; stamped when it stops for any reason - completion, early end, or cancellation, so a cancelled test also carries one). `completedRatings` is the displayed count: the live surviving count for every non-completed status, while a completed test with a recorded completion basis floors at that basis, so post-completion vote loss cannot lower a finished test's count.

#### POST /photos/test

Create a new photo test. Requires sufficient credits.

```bash
curl -X POST https://prod-api.keeper.ai/v1/agent/photos/test \
  -H 'Authorization: Bearer keeper_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"mediaId":"123e4567-e89b-42d3-a456-426614174000","credits":10,"idempotencyKey":"my-key-1"}'
```

**Request Body:** `{ "mediaId": UUID string, "credits": integer 1-100, "idempotencyKey"?: string }` — idempotency is this JSON BODY field (scoped to photo-test creation for the authenticated user; alphanumeric/hyphen/underscore only, max 256). There is no `Idempotency-Key` header. Replay is durable: the same key with the same settings returns the SAME `trialId`; the same key with different settings returns 400 `INVALID_REQUEST`; a key whose trial was since cancelled returns terminal 409 `CONFLICT` (send a fresh key); a key still being processed returns retryable 409 `IDEMPOTENCY_IN_PROGRESS` (older API instances during rollout: `CONFLICT` WITH `retryAfter` — any 409 carrying `retryAfter` is retryable); a completed trial keeps replaying the same `trialId`. Rollout caveat (WEB-10588): during the fenced rollout phase a FIRST use of a key on an upgraded instance also returns retryable 409 `IDEMPOTENCY_IN_PROGRESS` without accepting the request — while legacy instances remain a retry can land on one and succeed, but in the post-drain window before the operator lifts the fence, retries will not succeed until the flip.

**Response:** `{ "trialId": string }` — the id to pass to the cancel/end endpoints.

**Error:** Returns `INSUFFICIENT_CREDITS` if the user does not have enough credits.

#### POST /photos/tests/:trialId/cancel

Cancel a running photo test.

```bash
curl -X POST https://prod-api.keeper.ai/v1/agent/photos/tests/018f6bde-7b2a-7c3e-9f4a-2b1c3d4e5f60/cancel \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

#### POST /photos/tests/:trialId/end

End a running photo test early.

```bash
curl -X POST https://prod-api.keeper.ai/v1/agent/photos/tests/018f6bde-7b2a-7c3e-9f4a-2b1c3d4e5f60/end \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

### Pool

#### GET /pool/stats

Get dating pool statistics. Returns information about the user's potential match pool.

```bash
curl https://prod-api.keeper.ai/v1/agent/pool/stats \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** JSON object with pool statistics.

**Note:** Returns `503 Service Unavailable` if the ranking service is temporarily unavailable. Retry later.

### Questionnaire

#### GET /modules

List all available questionnaire modules. Returns IDs that can be used in the other questionnaire endpoints.

Each question carries a denormalized `fields[]` array (slider min/max, choice options, text bounds) alongside its raw JSON `schema`. `fields[]` is simplified and non-exhaustive: complex answer shapes may be omitted and `required` may actually be conditional — the raw `schema` stays authoritative for validation.

```bash
curl https://prod-api.keeper.ai/v1/agent/modules \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** Array of module objects with IDs, names, and metadata.

#### GET /modules/:id

Get a specific questionnaire module definition.

```bash
curl https://prod-api.keeper.ai/v1/agent/modules/5 \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** Module object with questions and answer options. The same `fields[]` caveat as GET /modules applies: `fields[]` is simplified and non-exhaustive — the raw `schema` stays authoritative.

#### GET /modules/:id/answers

Get the user's answers for a specific module.

```bash
curl https://prod-api.keeper.ai/v1/agent/modules/5/answers \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** a JSON ARRAY of `{ id, questionId, answer }` objects — `id` is the stored answer row id (GET-only; the PATCH response has no ids).

#### PATCH /modules/:id/answers

Update answers for a questionnaire module.

```bash
curl -X PATCH https://prod-api.keeper.ai/v1/agent/modules/5/answers \
  -H 'Authorization: Bearer keeper_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"answers":[{"questionId":"q1","answer":"answer1"},{"questionId":"q2","answer":"answer2"}]}'
```

**Request Body:** `{ "answers": [...] }` — an ARRAY of `{ questionId, answer }` objects (never a key-value map).

**Response:** a JSON ARRAY of the updated `{ questionId, answer }` pairs (no ids).

### Lookups

ID-based fields on `PATCH /profile` (and `PATCH /preferences`) must reference IDs from these lookup endpoints. All eight share the same query parameters and response shape:

- `?q=<substring>` — case-insensitive substring filter on `name` (trimmed to 100 chars)
- `?limit=<n>` — page size (default 50, max 200)
- `?offset=<n>` — items to skip (default 0)

**Response shape:** `{ items: [{ id, name, ... }], total, limit, offset }` — `id` is the numeric ID to pass to `PATCH /profile`; `total` is the match count before pagination. School items additionally include `country` (ISO 3166-1 alpha-2) and `acronym`.

| Endpoint | Provides IDs for |
|----------|------------------|
| `GET /schools` | `educations[].schoolId` |
| `GET /degrees` | `educations[].degreeId` |
| `GET /fields-of-study` | `educations[].fieldOfStudyIds` |
| `GET /ethnicities` | `ethnicities` on /profile and `ethnicity.ideal/acceptable/unacceptable[].id` on /preferences |
| `GET /interests` | `interests[].interestId` |
| `GET /test-scores` | `testScores[].testId` |
| `GET /religion-categories` | `religion.categories` |
| `GET /politics-categories` | `politics.category` |

```bash
curl "https://prod-api.keeper.ai/v1/agent/schools?q=stanford&limit=10" \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** `{ "items": [{ "id": 1, "name": "Stanford University", "country": "US", "acronym": "SU" }], "total": 1, "limit": 10, "offset": 0 }`

### Key Identity

#### GET /me

Returns the authenticated user's display name (`name`) plus the API key's prefix, scope, and capability list. Useful for verifying which key you hold and what it can do. `name` is personal data about the user, not a label for the key.

```bash
curl https://prod-api.keeper.ai/v1/agent/me \
  -H 'Authorization: Bearer keeper_YOUR_KEY'
```

**Response:** `{ "name", "keyPrefix", "scope", "capabilities": [...] }`

#### POST /auth/validate

Checks whether an API key is valid without performing any action. Unauthenticated endpoint — the key goes in the body, not the Authorization header. Used internally by MCP gateway servers to validate incoming credentials; direct REST integrations rarely need it.

**Body:** `{ "key": "keeper_<key>" }` — **Response:** `{ "valid": <bool>, "scope"?, "capabilities"? }`

---

## Error Codes

Standard endpoints return this envelope — `error` carries the human-readable
message and `code` carries the machine-readable code (key on `code`, never
on `error`):

```json
{
  "error": "Human-readable description",
  "code": "ERROR_CODE",
  "retryAfter": 60
}
```

`retryAfter` (seconds) appears on 429 responses, mirroring the `Retry-After` header. For 409s, discriminate on `retryAfter` PRESENCE, not on the code alone: any 409 carrying `retryAfter` is retryable — new API instances send it with code `IDEMPOTENCY_IN_PROGRESS`, and during rollout older instances signal the same still-processing state as `CONFLICT` with `retryAfter`. A 409 without `retryAfter` is terminal (`CONFLICT` — cancelled or unverifiable key: send a fresh key). The OAuth token endpoint is the one exception: it
returns RFC 6749 shape `{ "error": "invalid_grant", "error_description": "..." }`
with no `code` field.

| Error Code | HTTP Status | Description |
|------------|-------------|-------------|
| `UNAUTHORIZED` | 401 | Missing or invalid API key. Check your `Authorization` header. |
| `FORBIDDEN` | 403 | API key is valid but lacks the required scope for this endpoint. |
| `NOT_FOUND` | 404 | The requested resource does not exist. |
| `CONFLICT` | 409 | Without `retryAfter`: terminal — the `idempotencyKey` belongs to a photo test that was since cancelled (or can no longer be verified); send a fresh key. WITH `retryAfter` (older API instances during rollout): the key is still processing — retry like `IDEMPOTENCY_IN_PROGRESS`. (A completed test replays 201 with its `trialId`.) |
| `IDEMPOTENCY_IN_PROGRESS` | 409 | Another request with this `idempotencyKey` is still processing. RETRYABLE — carries `retryAfter` (seconds) and a `Retry-After` header. During the fenced rollout phase (WEB-10588) a first-use key on an upgraded instance returns this same response without accepting the request; retries succeed only via a remaining legacy instance or after the operator lifts the fence. |
| `INVALID_REQUEST` | 400 | Malformed request body or invalid parameters. |
| `RATE_LIMITED` | 429 | Too many requests. Check the `Retry-After` header. |
| `INSUFFICIENT_CREDITS` | 402 | Not enough photo test credits. |
| `UPSTREAM_ERROR` | 502 / 503 | A downstream service failed or is temporarily unavailable. Retry later. |
| `NOT_IMPLEMENTED` | 501 | This endpoint or feature is not yet available. |
| `SERVER_ERROR` | 500 | Unexpected server error. |

---

## Rate Limits

| Limit | Value |
|-------|-------|
| General requests per API key (default — per-key configurable; `X-RateLimit-*` headers are authoritative) | 60 per minute |
| Writes per API key — one bucket shared across profile, preference, rerank, and questionnaire writes | 5 per minute |
| Photo test creation (separate bucket) | 10 per minute |
| Invalid key attempts per IP | 10 per minute |
| OAuth token exchange per IP | 10 per minute |

Rate-limited responses return HTTP 429 with:
- `Retry-After` header indicating seconds to wait
- Error code `RATE_LIMITED` in the response body
- EXCEPTION — `POST /oauth/token`: its 429 body is the RFC 6749 envelope `{"error": "rate_limited", "error_description": ...}` with no `retryAfter` body field (the Retry-After header is still set)

The rate limit window is 60 seconds (sliding).

### Idempotency

Only `POST /photos/test` supports idempotency, via the `idempotencyKey` field in its JSON request body (see that endpoint above): replaying the same key with the same settings durably returns the SAME `trialId`; reusing a key with different settings returns 400 `INVALID_REQUEST`; and reusing a key whose trial was since cancelled returns terminal 409 `CONFLICT` (send a fresh key); a key still being processed returns retryable 409 `IDEMPOTENCY_IN_PROGRESS` (older API instances during rollout: `CONFLICT` WITH `retryAfter` — any 409 carrying `retryAfter` is retryable); a completed trial keeps replaying the same `trialId`. There is no `Idempotency-Key` header on any endpoint, and no other write endpoint dedupes retries — replaying a `PATCH` simply applies it again (all documented writes are safe to re-apply with the same body).

---

## Scopes

Scopes control what an API key can access.

| Scope | Description |
|-------|-------------|
| `manage_profile` | Default scope. Allows reading and updating the user's profile, preferences, photos, pool stats, and questionnaire answers. |
| `full` | Includes everything in `manage_profile`, plus future capabilities (e.g., view matches, send messages) as they are released. |

**Capabilities included in `manage_profile`:**
`view_pool_stats`, `check_status`, `view_photo_tests`, `view_credits`, `read_preferences`, `update_preferences_weights`, `update_preferences`, `read_profile`, `update_profile`, `create_photo_test`, `manage_photo_tests`, `read_questionnaire`, `manage_answers`

**Scope hierarchy:** `full` includes all capabilities of `manage_profile`.

---

## Discovery & Metadata

| Resource | URL | Description |
|----------|-----|-------------|
| Discovery Document | [https://app.keeper.ai/.well-known/agent-configuration](https://app.keeper.ai/.well-known/agent-configuration) | Machine-readable JSON with all endpoints, auth flows, and example curl commands |
| API Documentation | [https://app.keeper.ai/agent-api](https://app.keeper.ai/agent-api) | Human-readable landing page with setup guides |
| OpenAPI Spec | [https://app.keeper.ai/agent-api/openapi.json](https://app.keeper.ai/agent-api/openapi.json) | Machine-readable API specification |
| Capability catalog | [https://app.keeper.ai/.well-known/mcp.json](https://app.keeper.ai/.well-known/mcp.json) | Non-exhaustive REST operation summary (openapi.json is canonical) |
| LLMs Summary | [https://app.keeper.ai/llms.txt](https://app.keeper.ai/llms.txt) | This document's concise version |
