openapi: 3.1.0
info:
  title: Memohopper Public API
  version: "1.0.0"
  description: |
    The Memohopper public REST API — a small, **stable** surface for storing and recalling
    knowledge from your own scripts, agents, and integrations. It mirrors the Memohopper MCP
    tools: one contract, two transports (MCP tools and this REST API).

    ## Authentication
    Every request carries a Personal Access Token (PAT) as a bearer token:

        Authorization: Bearer mh_xxxxxxxxxxxxxxxxxxxx

    Mint and scope keys in the Memohopper app (Settings → API keys). A key can be limited to
    specific workspaces and to a subset of capabilities (`read`, `write`, `ingest`). This API
    accepts **API keys only** — a web session token (JWT) is rejected with 401.

    ## Workspaces
    Most calls act on one workspace. Pass `workspace` (its name or id) in the body or query. If
    your key can access exactly one workspace, you may omit it; if it can access several, either
    pass one or set the key's default workspace.

    ## Versioning & deprecation policy
    This is `/v1`. **Additive** changes — new endpoints, new optional request fields, new response
    fields — never change the version; write clients that ignore unknown fields. A **breaking**
    change (removing or renaming a field, changing a type or its meaning) ships as `/v2`, and `/v1`
    keeps working for a published sunset window of **at least 6 months**, announced in these docs
    and via a `Deprecation` response header. Never rely on undocumented fields or on the internal
    `/api/*` surface, which is unversioned and changes without notice.

    ## Idempotency
    `POST /v1/memories` and `POST /v1/ingest` accept an optional `Idempotency-Key` header. The first
    success is remembered for 24h; a repeat with the same key returns the original response (with
    `Idempotent-Replay: true`) instead of writing again — so a retry after a dropped connection is
    safe.

    ## Credits
    Generative operations (`ingest`, `ask`, and `memories` with `mode: "synthesize"`) consume plan
    credits, exactly as they do in the app and over MCP. When the window is exhausted you get a
    `429` with `error: "credits_exhausted"`; plain reads and verbatim stores are free.
servers:
  - url: https://api.memohopper.com
    description: Production
security:
  - bearerAuth: []
tags:
  - name: workspaces
  - name: contexts
  - name: memory
  - name: recall
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: mh_… (Personal Access Token)
  headers:
    XApiVersion:
      description: Always "1" on this API.
      schema: { type: string, example: "1" }
    IdempotentReplay:
      description: Present and "true" when this response was replayed from a prior identical request.
      schema: { type: string, example: "true" }
  parameters:
    Workspace:
      name: workspace
      in: query
      required: false
      description: Workspace name or id. Omit if the key can access only one workspace.
      schema: { type: string }
  responses:
    Error:
      description: Error — a stable machine `error` code plus a human `message`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing/invalid API key, or a non-PAT credential was used.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "unauthorized", message: "/v1 accepts API keys (mh_…) only." }
    Forbidden:
      description: The key lacks the required scope, or may not access that workspace.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "insufficient_scope", message: "This access token is read-only." }
    TooManyRequests:
      description: Out of credits, or too many concurrent operations. Retryable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "credits_exhausted", message: "You're out of credits for now — they refill over time, or you can upgrade your plan." }
  schemas:
    Error:
      type: object
      required: [error, message]
      properties:
        error: { type: string, description: Stable snake_case code., example: not_found }
        message: { type: string, description: A short, user-safe sentence. }
    Workspace:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        contexts: { type: integer, description: Number of contexts in the workspace. }
        url: { type: string, description: Deep link into the web app. }
    ContextHit:
      type: object
      description: A search hit (compact).
      properties:
        id: { type: string }
        label: { type: string }
        type: { type: string }
        snippet: { type: string }
    Context:
      type: object
      properties:
        id: { type: string }
        label: { type: string }
        type: { type: string }
        summary: { type: string }
        body: { type: array, items: { type: string }, description: Paragraphs. }
        related: { type: array, items: { type: string } }
        sources: { type: array, items: { type: object } }
        followups: { type: array, items: { type: string } }
        url: { type: string }
        connections: { type: integer, description: Only when includeWorkspaceData=true. }
        documents: { type: array, items: { type: object } }
        discussions: { type: array, items: { type: object } }
        evolutions: { type: array, items: { type: object } }
        asked: { type: array, items: { type: object } }
    RecallHit:
      type: object
      properties:
        id: { type: string, description: Item uuid or context slug. }
        kind: { type: string, enum: [context, item] }
        title: { type: string }
        snippet: { type: string, description: Best-matching paragraph. }
        type: { type: string }
        workspace: { type: string }
        chunk_id: { type: [string, "null"], description: '"<id>#<ix>" — the paragraph address.' }
        origin: { type: [string, "null"], description: Who wrote it (provenance). }
        session: { type: [string, "null"], description: Episodic tag, if any. }
    MemoryWrite:
      type: object
      required: [content]
      properties:
        content: { type: string, description: The exact text to store (verbatim). }
        title: { type: string }
        workspace: { type: string, description: Name or id. }
        kind: { type: string, enum: [memo, doc, clip, code, chat] }
        mode:
          type: string
          enum: [verbatim, synthesize, auto]
          description: '"synthesize" also extracts concepts into the graph (costs credits).'
        contextId: { type: string, description: Attach this memo under an existing context (slug). }
        supersedes: { type: string, description: Item id this replaces (soft-invalidates it). }
        session: { type: string, description: A stable per-task id for episodic recall. }
        expires:
          type: string
          description: ISO date or shorthand (e.g. "7d", "3mo"). Review-on-expire; never a hard delete.
    MemoryResult:
      type: object
      properties:
        id: { type: string, description: The stored item id. }
        created: { type: boolean }
        node: { type: [string, "null"], description: Set when the content became a graph node. }
        stored_as: { type: string, enum: [memo, node] }
        workspace: { type: string }
        expires: { type: string }
        similar: { type: array, items: { type: object }, description: Near-duplicate items. }
        related: { type: array, items: { type: object }, description: Same-topic earlier memories (possible contradictions). }
        url: { type: string }
    IngestBody:
      type: object
      required: [name, text]
      properties:
        name: { type: string, description: A title/source name. }
        text: { type: string, description: The full text to store + extract concepts from. }
        workspace: { type: string }
        parentId: { type: string, description: Attach the source under a parent context (slug). }
        wait:
          type: boolean
          description: If true, block until concept extraction finishes (slow). Default false — extraction runs in the background.
        expires: { type: string, description: ISO date or shorthand (e.g. "7d"). }
    IngestResult:
      type: object
      properties:
        workspace: { type: string }
        itemId: { type: string }
        sourceNodeId: { type: [string, "null"] }
        pending: { type: boolean, description: true when concepts are still extracting in the background. }
        concepts: { type: array, items: { type: object } }
        added: { type: array, items: { type: object } }
        edges: { type: array, items: { type: object } }
        url: { type: string }
    RecallBody:
      type: object
      required: [query]
      properties:
        query: { type: string, description: Natural language is fine. }
        workspace: { type: string }
        scope: { type: string, enum: [workspace, all], description: Default "workspace". }
        kind: { type: string, enum: [context, item], description: Restrict to one plane. }
        session: { type: string, description: Only recall memories stamped with this session id. }
        limit: { type: integer, minimum: 1, maximum: 25, default: 10 }
        response_format: { type: string, enum: [concise, detailed] }
    AskBody:
      type: object
      required: [id, question]
      properties:
        id: { type: string, description: The context id/slug to ask about. }
        question: { type: string }
        kind: { type: string, enum: [define, relate, example, simplify, matters, depends, free] }
        workspace: { type: string }
paths:
  /v1/workspaces:
    get:
      tags: [workspaces]
      summary: List accessible workspaces
      description: The workspaces this key may access, with context counts. Requires the `read` scope.
      responses:
        "200":
          description: OK
          headers: { X-Api-Version: { $ref: "#/components/headers/XApiVersion" } }
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Workspace" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/contexts:
    get:
      tags: [contexts]
      summary: Search contexts
      description: Keyword search over contexts. Requires the `read` scope.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string }, description: Search query. }
        - { $ref: "#/components/parameters/Workspace" }
        - { name: scope, in: query, schema: { type: string, enum: [workspace, all] }, description: Default "workspace". }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 25 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  scope: { type: string }
                  workspace: { type: string }
                  count: { type: integer }
                  items: { type: array, items: { $ref: "#/components/schemas/ContextHit" } }
                  next_cursor:
                    type: [string, "null"]
                    description: Reserved for pagination. Search returns ranked top-N, so this is null today.
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/contexts/{slug}:
    get:
      tags: [contexts]
      summary: Read one context
      description: Full content of a single context by id/slug. Requires the `read` scope.
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/Workspace" }
        - { name: includeWorkspaceData, in: query, schema: { type: boolean }, description: Also return attached documents/discussions/evolutions/asked + connection count. }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Context" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/briefing:
    get:
      tags: [workspaces]
      summary: Session-start briefing
      description: Counts, recent memories, top contexts, recent session ids, plan + credits for a workspace. Requires the `read` scope.
      parameters:
        - { $ref: "#/components/parameters/Workspace" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/recall:
    post:
      tags: [recall]
      summary: Semantic recall across memory + graph
      description: Meaning-based (paraphrase-friendly) search across both planes. Requires the `read` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecallBody" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  results: { type: array, items: { $ref: "#/components/schemas/RecallHit" } }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/memories:
    post:
      tags: [memory]
      summary: Store a memory
      description: |
        Store text verbatim. Long/document-shaped content becomes a navigable graph node; short
        notes become Library memos. `mode: "synthesize"` also extracts concepts (costs credits).
        Requires the `write` scope. Supports the `Idempotency-Key` header.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: Retry-safety — a repeat within 24h replays the first response.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemoryWrite" }
      responses:
        "201":
          description: Stored
          headers:
            X-Api-Version: { $ref: "#/components/headers/XApiVersion" }
            Idempotent-Replay: { $ref: "#/components/headers/IdempotentReplay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MemoryResult" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/memories/{id}:
    delete:
      tags: [memory]
      summary: Forget a memory
      description: Soft-invalidate an item — it stops appearing in recall but stays restorable in the app. Never a hard delete. Requires the `write` scope.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/Workspace" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  superseded: { type: boolean }
                  id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/ingest:
    post:
      tags: [memory]
      summary: Ingest a document
      description: |
        Store a block of text as a SOURCE: kept verbatim, with a document node created immediately
        and concepts extracted in the background (or synchronously with `wait: true`). Requires the
        `ingest` scope. Costs credits. Supports the `Idempotency-Key` header.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestBody" }
      responses:
        "201":
          description: Ingested
          headers:
            X-Api-Version: { $ref: "#/components/headers/XApiVersion" }
            Idempotent-Replay: { $ref: "#/components/headers/IdempotentReplay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestResult" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/ask:
    post:
      tags: [recall]
      summary: Ask a question scoped to one context
      description: |
        A short (1–3 sentence) answer grounded in one context's content, stored as a Q&A insight.
        Requires the `read` scope. LLM-backed — costs credits.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AskBody" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, description: "{ context, q, a, … }" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
