{
  "openapi": "3.1.0",
  "info": {
    "title": "Nemos API",
    "version": "1.0.0",
    "summary": "Public endpoints of nemosapp.com and the hosted Nemos MCP server.",
    "description": "Nemos is a save-and-find app for iPhone, iPad and Apple Watch; each person's library lives in their own iCloud account. This document describes the public HTTP endpoints on nemosapp.com, which need no authentication, and the hosted MCP server at mcp.nemosapp.com, which reads one person's library after that person signs in through OAuth 2.1 and grants the read-only `nemos.read` scope.\n\nVersioning: the version is in the path (/api/v1/). Additive changes (new fields, new endpoints) ship within v1; a breaking change ships as /api/v2/ alongside v1. Before a path is retired it answers with a Deprecation header (RFC 9745) and a Sunset header (RFC 8594) naming the date it stops, and the change is listed at https://nemosapp.com/developers/#versioning. The unversioned /api/<name> paths are aliases of v1, kept for links already published (share images, email unsubscribe links).\n\nRate limits: every /api/ response carries RateLimit-Policy and RateLimit headers (IETF draft-ietf-httpapi-ratelimit-headers) plus RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; past the limit the answer is 429 with Retry-After.\n\nErrors under /api/ are JSON in the RFC 9457 problem-details format (see the Problem schema). Every page on nemosapp.com also answers `Accept: text/markdown` with Markdown. Human-readable guide: https://nemosapp.com/developers/",
    "termsOfService": "https://nemosapp.com/terms/",
    "contact": {
      "name": "Nemos",
      "url": "https://nemosapp.com/contact/",
      "email": "nemos@brainchild-co.com"
    }
  },
  "externalDocs": {
    "description": "Developer guide: authentication, scopes, errors and the MCP server",
    "url": "https://nemosapp.com/developers/"
  },
  "servers": [
    {
      "url": "https://nemosapp.com/api/v1",
      "description": "Public website API, version 1. No authentication."
    }
  ],
  "security": [],
  "tags": [
    {
      "name": "Status",
      "description": "Service health."
    },
    {
      "name": "Images",
      "description": "Generated share images."
    },
    {
      "name": "Email",
      "description": "Email preferences for people who receive Nemos email."
    },
    {
      "name": "MCP",
      "description": "The hosted Model Context Protocol server. Requires OAuth.",
      "externalDocs": {
        "url": "https://nemosapp.com/developers/#mcp"
      }
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "Status"
        ],
        "summary": "Check service health",
        "description": "Reports whether the website's API can reach its database and email provider. Returns 200 when every check passes and 503 when any fails. Never cached.",
        "responses": {
          "200": {
            "description": "Every check passed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "At least one check failed; `checks` says which.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Problem"
          }
        }
      }
    },
    "/og": {
      "get": {
        "operationId": "renderShareImage",
        "tags": [
          "Images"
        ],
        "summary": "Render an Open Graph share image",
        "description": "Returns a 1200x630 SVG card with the Nemos branding and the given title, for use as an og:image. Text is XML-escaped. Cached for 7 days.",
        "parameters": [
          {
            "name": "title",
            "in": "query",
            "description": "Headline drawn on the card. Defaults to the Nemos tagline.",
            "required": false,
            "schema": {
              "type": "string"
            },
            "example": "Nemos vs Apple Notes"
          },
          {
            "name": "type",
            "in": "query",
            "description": "Card layout. `vs` and `alternative` draw a comparison badge in the competitor's colour; any other value draws the standard card.",
            "required": false,
            "schema": {
              "type": "string",
              "default": "home",
              "examples": [
                "home",
                "vs",
                "alternative",
                "blog",
                "faq",
                "seo-landing"
              ]
            }
          },
          {
            "name": "subtitle",
            "in": "query",
            "description": "Optional second line under the title.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "competitor",
            "in": "query",
            "description": "Competitor slug whose brand colour accents a comparison card. Unknown slugs fall back to the Nemos orange.",
            "required": false,
            "schema": {
              "type": "string",
              "examples": [
                "apple-notes",
                "notion",
                "obsidian",
                "evernote",
                "onenote",
                "pocket",
                "google-keep",
                "bear",
                "raindrop",
                "picojar"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The SVG image.",
            "content": {
              "image/svg+xml": {
                "schema": {
                  "type": "string",
                  "description": "SVG document, 1200x630."
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "default": {
            "$ref": "#/components/responses/Problem"
          }
        }
      }
    },
    "/unsubscribe": {
      "get": {
        "operationId": "unsubscribeFromEmailLink",
        "tags": [
          "Email"
        ],
        "summary": "Unsubscribe from Nemos email (link in an email)",
        "description": "The unsubscribe link carried in every Nemos email. Marks the recipient unsubscribed. A browser gets a short HTML confirmation page; a client that sends Accept: application/json gets {\"ok\": true, \"unsubscribed\": <bool>}, where unsubscribed is false for an unknown or malformed token (the page says the link is no longer valid).",
        "parameters": [
          {
            "$ref": "#/components/parameters/UnsubscribeToken"
          }
        ],
        "responses": {
          "200": {
            "description": "Confirmation, as JSON or as a page for a person.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "unsubscribed"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "unsubscribed": {
                      "type": "boolean",
                      "description": "False when the token is unknown or malformed."
                    }
                  }
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "default": {
            "$ref": "#/components/responses/Problem"
          }
        }
      },
      "post": {
        "operationId": "unsubscribeOneClick",
        "tags": [
          "Email"
        ],
        "summary": "One-click unsubscribe (RFC 8058)",
        "description": "Called by mail clients from the List-Unsubscribe-Post header. The token may be in the query string or the form body. Always answers 200 with {\"ok\": true}, whether or not the token existed, so it cannot be used to test tokens.",
        "parameters": [
          {
            "name": "t",
            "in": "query",
            "description": "The token from the email's unsubscribe link, if not sent in the body.",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[0-9a-f]{32}$"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "t": {
                    "type": "string",
                    "pattern": "^[0-9a-f]{32}$",
                    "description": "The token from the email's unsubscribe link."
                  },
                  "List-Unsubscribe": {
                    "type": "string",
                    "const": "One-Click",
                    "description": "Sent by RFC 8058 clients."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Processed.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "default": {
            "$ref": "#/components/responses/Problem"
          }
        }
      }
    },
    "/mcp": {
      "servers": [
        {
          "url": "https://mcp.nemosapp.com",
          "description": "Hosted Nemos MCP server."
        }
      ],
      "post": {
        "operationId": "callNemosMcp",
        "tags": [
          "MCP"
        ],
        "summary": "Send an MCP JSON-RPC message",
        "description": "Model Context Protocol endpoint (Streamable HTTP transport, JSON responses, stateless). Send `initialize`, then `tools/list` and `tools/call` as JSON-RPC 2.0 requests. It serves five read-only tools on the signed-in person's library: search_nemos, get_item, get_related_items, get_recent_items and build_context_pack. MCP clients normally drive this endpoint themselves; it is described here so the authentication and scope are machine-readable. Without a valid token it answers 401 with a WWW-Authenticate challenge that points at https://mcp.nemosapp.com/.well-known/oauth-protected-resource/mcp.",
        "security": [
          {
            "nemosOAuth": [
              "nemos.read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "description": "Streamable HTTP clients send both types.",
            "required": true,
            "schema": {
              "type": "string",
              "default": "application/json, text/event-stream"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JsonRpcRequest"
              },
              "example": {
                "jsonrpc": "2.0",
                "id": 1,
                "method": "tools/call",
                "params": {
                  "name": "search_nemos",
                  "arguments": {
                    "query": "ramen recipe",
                    "limit": 5
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JsonRpcResponse"
                }
              }
            }
          },
          "202": {
            "description": "Accepted a JSON-RPC notification. No body."
          },
          "400": {
            "$ref": "#/components/responses/McpError"
          },
          "401": {
            "description": "Missing, expired or revoked access token. Follow the WWW-Authenticate header to authorize.",
            "headers": {
              "WWW-Authenticate": {
                "description": "Bearer challenge naming resource_metadata and scope=\"nemos.read\".",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/McpError"
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/McpError"
          },
          "413": {
            "$ref": "#/components/responses/McpError"
          },
          "429": {
            "description": "A per-account usage limit was reached. Wait for Retry-After seconds. The limits are listed at https://nemosapp.com/mcp/.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/McpError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "nemosOAuth": {
        "type": "oauth2",
        "description": "OAuth 2.1 authorization code flow with PKCE (S256). Public clients identify themselves with an OAuth Client ID Metadata Document, so no client registration or API key is needed. Metadata: https://mcp.nemosapp.com/.well-known/oauth-authorization-server (RFC 8414) and https://mcp.nemosapp.com/.well-known/oauth-protected-resource/mcp (RFC 9728). Guide: https://nemosapp.com/developers/#authentication",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://mcp.nemosapp.com/oauth/authorize",
            "tokenUrl": "https://mcp.nemosapp.com/oauth/token",
            "refreshUrl": "https://mcp.nemosapp.com/oauth/token",
            "scopes": {
              "nemos.read": "Read-only access to the signed-in person's saved Nemos items: search them, read one, list recent and related items, and build context packs. Grants no create, change, delete or share access."
            }
          }
        }
      }
    },
    "parameters": {
      "UnsubscribeToken": {
        "name": "t",
        "in": "query",
        "description": "The per-recipient token from the email's unsubscribe link.",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^[0-9a-f]{32}$"
        }
      }
    },
    "responses": {
      "Problem": {
        "description": "The request failed. RFC 9457 problem details.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "McpError": {
        "description": "The MCP server rejected the request.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/McpError"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Wait Retry-After seconds. RFC 9457 problem details with code rate_limited.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          },
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimit-Policy"
          },
          "RateLimit": {
            "$ref": "#/components/headers/RateLimit"
          },
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimit-Limit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimit-Remaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimit-Reset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          },
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    },
    "schemas": {
      "Problem": {
        "type": "object",
        "description": "RFC 9457 problem details, with a stable machine code and a resolution hint.",
        "required": [
          "type",
          "title",
          "status",
          "code",
          "hint"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "description": "Documentation for this kind of error.",
            "example": "https://nemosapp.com/developers/#error-not_found"
          },
          "title": {
            "type": "string",
            "description": "Short summary of the error kind.",
            "example": "Not found"
          },
          "status": {
            "type": "integer",
            "description": "The HTTP status code.",
            "example": 404
          },
          "detail": {
            "type": "string",
            "description": "What failed on this request.",
            "example": "GET /api/nope failed with HTTP 404 (not found)."
          },
          "instance": {
            "type": "string",
            "description": "The path that was requested.",
            "example": "/api/nope"
          },
          "code": {
            "type": "string",
            "description": "Stable error code.",
            "enum": [
              "bad_request",
              "unauthorized",
              "forbidden",
              "not_found",
              "method_not_allowed",
              "not_acceptable",
              "rate_limited",
              "internal_error",
              "unavailable",
              "error"
            ]
          },
          "hint": {
            "type": "string",
            "description": "What to do next."
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "Where the API is described."
          }
        }
      },
      "Health": {
        "type": "object",
        "required": [
          "ok",
          "checks",
          "version",
          "timestamp"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "True when every check passed."
          },
          "checks": {
            "type": "object",
            "required": [
              "d1_bound",
              "resend_env",
              "d1_reachable",
              "waitlist_count"
            ],
            "properties": {
              "d1_bound": {
                "type": "boolean",
                "description": "The database binding is configured."
              },
              "resend_env": {
                "type": "boolean",
                "description": "The email provider key is configured."
              },
              "d1_reachable": {
                "type": "boolean",
                "description": "A query against the database succeeded."
              },
              "waitlist_count": {
                "type": "integer",
                "description": "Rows in the email list, or -1 when the database could not be read."
              }
            }
          },
          "version": {
            "type": "string",
            "description": "Deployed commit, or \"unknown\"."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "JsonRpcRequest": {
        "type": "object",
        "description": "A JSON-RPC 2.0 request or notification carrying an MCP method.",
        "required": [
          "jsonrpc",
          "method"
        ],
        "properties": {
          "jsonrpc": {
            "type": "string",
            "const": "2.0"
          },
          "id": {
            "description": "Omit for a notification.",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "integer"
              }
            ]
          },
          "method": {
            "type": "string",
            "description": "MCP method.",
            "examples": [
              "initialize",
              "notifications/initialized",
              "tools/list",
              "tools/call"
            ]
          },
          "params": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "JsonRpcResponse": {
        "type": "object",
        "required": [
          "jsonrpc"
        ],
        "properties": {
          "jsonrpc": {
            "type": "string",
            "const": "2.0"
          },
          "id": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ]
          },
          "result": {
            "type": "object",
            "additionalProperties": true
          },
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "integer"
              },
              "message": {
                "type": "string"
              },
              "data": {}
            }
          }
        }
      },
      "McpError": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Error code.",
            "enum": [
              "unauthorized",
              "invalid_json",
              "method_not_allowed",
              "payload_too_large",
              "rate_limited",
              "not_found"
            ]
          },
          "windows": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "On a 429: the usage windows that are exhausted."
          }
        }
      }
    },
    "headers": {
      "RateLimit-Policy": {
        "description": "The quota: \"default\";q=<requests>;w=<window seconds>.",
        "schema": {
          "type": "string",
          "example": "\"default\";q=120;w=60"
        }
      },
      "RateLimit": {
        "description": "What is left: \"default\";r=<remaining>;t=<seconds until reset>.",
        "schema": {
          "type": "string",
          "example": "\"default\";r=119;t=60"
        }
      },
      "RateLimit-Limit": {
        "description": "Requests allowed per window.",
        "schema": {
          "type": "integer",
          "example": 120
        }
      },
      "RateLimit-Remaining": {
        "description": "Requests left in this window.",
        "schema": {
          "type": "integer",
          "example": 119
        }
      },
      "RateLimit-Reset": {
        "description": "Seconds until the window resets.",
        "schema": {
          "type": "integer",
          "example": 60
        }
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying.",
        "schema": {
          "type": "integer",
          "example": 30
        }
      }
    }
  }
}
