{"version":"1.0","authorization_url":"https://development-app.keeper.ai/agent-authorize","token_endpoint":"https://dev-api.keeper.ai/v1/agent/oauth/token","api_base_url":"https://dev-api.keeper.ai/v1/agent","scopes_supported":["manage_profile","full"],"scope_capabilities":{"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"],"full":["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"]},"grant_types_supported":["authorization_code"],"code_challenge_methods_supported":["S256"],"key_prefix":"keeper_","legacy_key_prefixes":["oclaw_"],"documentation_url":"https://development-app.keeper.ai/agent-api","llms_txt_url":"https://development-app.keeper.ai/llms.txt","llms_full_txt_url":"https://development-app.keeper.ai/llms-full.txt","capability_catalog_url":"https://development-app.keeper.ai/.well-known/mcp.json","mcp_discovery_url":"https://development-app.keeper.ai/.well-known/mcp.json","pick_a_flow":"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_flow; REMOTE/CLOUD agents must wait for the rollout and retry later — browser_oauth_flow requires the agent and user's browser to be on the SAME machine (loopback redirect), so it cannot substitute for them.","quick_start":{"description":"The fastest way to connect. Works for ALL agents regardless of where they run — local, remote, cloud, chat bots, etc. Availability: the Connected Agents section is rolling out behind a feature flag; if step 2 is not visible in the user's Settings, local agents can fall back to browser_oauth_flow — 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 to api_base_url. See 'using_the_api' below."],"settings_url":"https://development-app.keeper.ai/settings"},"using_the_api":{"instruction":"Use the API key as a Bearer token for every request except POST /auth/validate, which takes the key in its JSON body instead.","base_url":"https://dev-api.keeper.ai/v1/agent","header":"Authorization: Bearer keeper_<your-key>","rate_limit":"60 requests per minute per key by DEFAULT — each key's actual limit is configurable; the X-RateLimit-* response headers are authoritative for the current key.","rate_limit_buckets":{"general_per_key":{"default_limit_per_minute":60,"applies_to":"Every authenticated agent-api request. Per-key configurable — the X-RateLimit-* response headers are authoritative for the current key."},"writes_per_key":{"limit_per_minute":5,"applies_to":"One shared bucket across PATCH /profile, PATCH /preferences, PUT /preferences/:id/rerank, POST /preferences/rerank, and PATCH /modules/:moduleId/answers."},"photo_test_creation_per_key":{"limit_per_minute":10,"applies_to":"POST /photos/test only (separate from the shared write bucket)."},"retry_after":"429 responses carry a Retry-After header and a retryAfter body field (seconds)."},"example_curl":"curl https://dev-api.keeper.ai/v1/agent/profile -H 'Authorization: Bearer keeper_YOUR_KEY'","endpoints":{"profile":{"GET /profile":"Read user profile","PATCH /profile":"Update user profile (partial update)"},"preferences":{"GET /preferences":"Read dating preferences","PATCH /preferences":"Update dating preferences (partial update)","GET /preferences/items":"List `UserPreferences` rows as `{ id, preferenceCategory, rankSection, rankIndex, preferenceSource, data }`. The `id` here is the canonical source for `id`, `afterId`, and `beforeId` on the rerank endpoints — always read this before rerank, never invent IDs.","PUT /preferences/:id/rerank":{"description":"Rerank a single preference. The path `:id` and any `afterId`/`beforeId` in the body must be real `UserPreferences.id` values from `GET /preferences/items`. Unknown IDs return 404. Moving a bounded age/height preference to an inactive section ('disabled', 'deleted', or 'unranked') returns 400 while any range bound is stored — clear the range via PATCH /preferences first (WEB-8227).","body":{"rankSection":"Required. 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. The canonical looks-importance default preference may never move to deleted/unranked (400).","afterId":"Optional number. To move this preference to the position immediately after preference Y, set afterId = Y.id.","beforeId":"Optional number. To move this preference to the position immediately before preference Y, set beforeId = Y.id."}},"POST /preferences/rerank":{"description":"Batch rerank multiple preferences in one request. Every `id`, `afterId`, and `beforeId` in the batch must be a real `UserPreferences.id` from `GET /preferences/items`; the whole batch fails with 404 on any unknown id, and any rejected action (including a bounded age/height preference targeting an inactive section — 'disabled', 'deleted', or 'unranked' — WEB-8227) rolls back every action in the batch with a 400.","body":{"actions":"Required array of {id, rankSection, afterId?, beforeId?}. Each action has the same fields as PUT /preferences/:id/rerank plus a required 'id' field identifying the preference (rankSection accepts the same seven values, including 'deleted')."}}},"status":{"GET /status":"Get profile completion status and onboarding progress"},"photos":{"GET /photos/credits":"Get photo test credit balance","GET /photos/tests":"List photo tests","POST /photos/test":"Create a new photo test","POST /photos/tests/:trialId/cancel":"Cancel a running photo test","POST /photos/tests/:trialId/end":"End a running photo test"},"pool":{"GET /pool/stats":"Get dating pool statistics. Returns 503 if the ranking service is temporarily unavailable — retry later."},"questionnaire":{"GET /modules":"List all questionnaire modules with their questions and field metadata (slider min/max, choice options, text bounds). The fields[] metadata is simplified and non-exhaustive: complex answer shapes may be omitted and `required` may actually be conditional — each question's raw JSON `schema` stays authoritative. Pass ?withQuestions=false for module summaries only.","GET /modules/:id":"Get questionnaire module definition. The same fields[] caveat as GET /modules applies.","GET /modules/:id/answers":"Get user answers for a module","PATCH /modules/:id/answers":"Update answers for a module"},"lookups":{"_description":"ID-based fields on PATCH /profile (and PATCH /preferences) must reference IDs from these endpoints. Each endpoint supports `?q=<substring>&limit=<n>&offset=<n>` (default limit 50, max 200). Response shape: { items: [{ id, name, ... }], total, limit, offset }.","GET /schools":"IDs for `educations[].schoolId`","GET /degrees":"IDs for `educations[].degreeId`","GET /fields-of-study":"IDs for `educations[].fieldOfStudyIds`","GET /ethnicities":"IDs for `ethnicities` on /profile and `ethnicity.ideal/acceptable/unacceptable[].id` on /preferences","GET /interests":"IDs for `interests[].interestId`","GET /test-scores":"IDs for `testScores[].testId`","GET /religion-categories":"IDs for `religion.categories`","GET /politics-categories":"IDs for `politics.category`"},"identity":{"GET /me":"The authenticated user's display name (name) plus the key's keyPrefix, scope, and capabilities","POST /auth/validate":"Check whether an API key is valid without acting. Unauthenticated — the key goes in the body: { key }. Returns { valid, scope?, capabilities? }."},"_note":"All paths are relative to api_base_url. For example: GET https://dev-api.keeper.ai/v1/agent/photos/credits"}},"browser_oauth_flow":{"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_callback_server":{"instruction":"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.","example_callback_url":"http://127.0.0.1:9876/callback","callback_path":"/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.","implementation_hints":{"what_to_listen_for":"A GET request to your callback_path (e.g. /callback) with query parameter 'code' (on success) or 'error' (on denial). IGNORE any other requests (browsers send extra requests like /.well-known/appspecific/com.chrome.devtools.json, favicon.ico, etc. — these are NOT the OAuth callback).","how_to_detect_the_callback":"Check if the request path matches your callback_path AND the query string contains either 'code' or 'error'.","response_to_browser":"Return an HTML page saying 'Connected successfully! You can close this tab.' so the user knows it worked.","timing":"Extract the code and immediately proceed to Step 5 (token exchange). The code expires quickly."}},"step_2_pkce":{"instruction":"Generate a cryptographically random code_verifier (43-128 characters, using [A-Za-z0-9._~-]). Then compute: code_challenge = base64url_no_padding(sha256(code_verifier))","pseudocode":["code_verifier = random_base64url(48)  // e.g. 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'","code_challenge = base64url(sha256(code_verifier))  // no trailing '='"]},"step_3_authorize":{"instruction":"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.","url":"https://development-app.keeper.ai/agent-authorize","query_parameters":{"client_id":{"required":true,"description":"Your agent's identifier (any non-empty string).","example":"my-keeper-agent"},"redirect_uri":{"required":true,"description":"The URL of your local callback server from Step 1. MUST be a loopback address with http:// protocol.","example":"http://127.0.0.1:9876/callback","MUST_BE":"http://127.0.0.1:<port>/... OR http://localhost:<port>/...","WILL_BE_REJECTED":["https://anything (must be http, not https)","http://localhost.invalid/... (not a real loopback)","http://example.com/... (not loopback)","Any non-loopback hostname"]},"code_challenge":{"required":true,"description":"The base64url-encoded SHA-256 hash from Step 2."},"code_challenge_method":{"required":true,"description":"Must be exactly the string 'S256'. No other value is accepted.","value":"S256"},"scope":{"required":false,"description":"Defaults to 'manage_profile'. See scopes_supported for options.","default":"manage_profile"},"state":{"required":false,"description":"Random string for CSRF protection. Returned unchanged in callback."},"client_name":{"required":false,"description":"Human-readable name shown on consent screen."}},"do_NOT_include":["response_type (this is not standard OAuth — omit it)"],"example_url":"https://development-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_callback":{"instruction":"After the user approves, their browser will redirect to your callback server with query parameters.","on_success":"GET <redirect_uri>?code=<authorization_code>&state=<state>","on_denial":"GET <redirect_uri>?error=access_denied&state=<state>","IMPORTANT":"The authorization code is single-use and expires in 5 minutes. Exchange it immediately in Step 5."},"step_5_token_exchange":{"instruction":"POST to the token_endpoint with the authorization code and your original code_verifier. The response contains a permanent API key.","url":"https://dev-api.keeper.ai/v1/agent/oauth/token","method":"POST","content_type":"application/json","request_body":{"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":"curl -X POST https://dev-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":{"access_token":"keeper_<key> — a permanent API key. Store securely, shown only once.","token_type":"bearer","scope":"The granted scope."},"error_responses":{"invalid_grant":"The code is expired, already used, or the code_verifier does not match the code_challenge. Generate a new auth request from Step 3.","invalid_request":"Missing or malformed fields in the POST body. Check that all four fields are present and correct."}},"step_6_use_api":{"instruction":"After receiving the access_token, use the API exactly as described in the top-level 'using_the_api' section.","see":"using_the_api"}},"common_mistakes":["Using https:// in redirect_uri — MUST be http:// for loopback.","Using a non-loopback hostname like localhost.invalid — 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 — the callback has nowhere to go.","Waiting too long to exchange the code — it expires quickly, exchange immediately.","Reusing an authorization code — codes are single-use. Start over from Step 3.","Mismatched redirect_uri between Step 3 and Step 5 — they must be identical.","Fetching the authorization URL via curl/HTTP instead of opening in a browser — the user must interact with the consent page.","Exchanging the code against a different server than issued it — the token_endpoint in this document must match the server that created the code."]}