{
  "openapi": "3.0.3",
  "info": {
    "title": "PlayPath API",
    "version": "1.0.0",
    "description": "PlayPath turns a coaching content library into grounded answers, multi-turn coaching sessions and personalised recommendations inside your own product.\n\nThe API is organised into five groups, each gated by its own scope:\n\n| Group | Scope | What it does |\n| --- | --- | --- |\n| Ingest | `telemetry:ingest` | Push catalogue metadata and member activity signals. Write-only. |\n| Ask | `chat` | Retrieval-grounded answers with sources, streaming or as one JSON response. |\n| Recommend | `recommendations:read` | Ranked, rotating slates per member and surface, with a reason per item. |\n| Converse | `chat` | Multi-turn coach sessions and direct session-plan edits. |\n| Library | `content:read`, `content:write` | Read or manage the items PlayPath has indexed for you. |\n\n**Authentication.** Every `/api/*` endpoint requires an organization API key; there is no session fallback and no anonymous access. Send it as the `X-Api-Key` header or as `Authorization: Bearer <key>`. A credential in a query string leaks into logs, proxies and referrer headers, so the query-string form is accepted **only** on the two RAG endpoints, where browser `EventSource` clients cannot set headers.\n\n**Scopes.** Each key carries an explicit subset of `content:read`, `content:write`, `chat`, `telemetry:ingest` and `recommendations:read`. Each operation below states the scope it requires in its description and in the `x-required-scope` field. A valid key without the required scope receives `403 {\"error\": \"Insufficient scope: <scope> required\"}`. A missing or unknown key receives `401 {\"error\": \"Unauthorized\"}`.\n\nManage your keys at https://playpath.io/api_keys. Pricing and access: https://playpath.io/pricing.",
    "contact": { "name": "PlayPath", "url": "https://playpath.io/api-access" }
  },
  "servers": [
    { "url": "https://playpath.io", "description": "Production" }
  ],
  "tags": [
    { "name": "Ingest", "description": "Catalogue metadata and activity signals. Write-only; never member records." },
    { "name": "Ask", "description": "Retrieval-grounded answers with sources." },
    { "name": "Recommend", "description": "Personalised, rotating slates per member and surface." },
    { "name": "Converse", "description": "Multi-turn coach sessions and session-plan editing." },
    { "name": "Library", "description": "The indexed items themselves." }
  ],
  "security": [
    { "ApiKeyHeader": [] },
    { "BearerAuth": [] }
  ],
  "paths": {
    "/api/v1/source-articles/batch": {
      "post": {
        "tags": ["Ingest"],
        "operationId": "createSourceArticleBatch",
        "summary": "Upsert a batch of source articles",
        "description": "Ingests up to 25 article snapshots into your library for indexing.\n\nRequires the **content:write** scope (not `telemetry:ingest`, unlike the other batch endpoints).\n\n`observed_at` must be the moment the snapshot was read from your source, not the moment the request was made: it is how conflicting writes are resolved. A snapshot older than the stored one is reported as `stale` and discarded; an equal `observed_at` with different content is reported as `conflict`.",
        "x-required-scope": "content:write",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["articles"],
                "properties": {
                  "articles": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 25,
                    "items": { "$ref": "#/components/schemas/SourceArticleInput" }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-entry results, in request order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "results": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "index": { "type": "integer", "description": "Position in the submitted array." },
                          "status": { "type": "string", "enum": ["recorded", "updated", "stale", "unchanged", "conflict", "rejected"] },
                          "id": { "type": "integer", "description": "PlayPath id of the stored article. Absent when rejected." },
                          "error": { "type": "string", "description": "Present only when status is rejected." }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "$ref": "#/components/responses/BatchSizeRejected" }
        }
      }
    },
    "/api/v1/source-activity-events/batch": {
      "post": {
        "tags": ["Ingest"],
        "operationId": "createSourceActivityEventBatch",
        "summary": "Ingest a batch of member activity events",
        "description": "Records up to 500 activity signals — what members assigned, opened, played or finished. These drive recommendation ranking.\n\nRequires the **telemetry:ingest** scope.\n\nThe ledger is append-only and idempotent: `idempotency_key` is unique per organization. Re-sending an identical payload returns `already_recorded`; re-using the key with different content returns `conflict`. A correction is expressed by sending a later event that names the key it corrects, never by rewriting history.\n\n`external_user_id` must be an opaque pseudonymous id matching `trs:v1:<16-200 url-safe chars>` — PlayPath never accepts a real member identifier here. `metadata` keys are allow-listed per `kind`.",
        "x-required-scope": "telemetry:ingest",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["events"],
                "properties": {
                  "events": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 500,
                    "items": { "$ref": "#/components/schemas/SourceActivityEventInput" }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-entry results, in request order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "results": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "idempotency_key": { "type": "string" },
                          "id": { "type": "integer" },
                          "status": { "type": "string", "enum": ["recorded", "already_recorded", "conflict", "rejected"] },
                          "error": { "type": "string" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "$ref": "#/components/responses/BatchSizeRejected" }
        }
      }
    },
    "/api/v1/recommendation-contents/batch": {
      "post": {
        "tags": ["Ingest"],
        "operationId": "createRecommendationContentBatch",
        "summary": "Upsert a batch of rankable content",
        "description": "Registers up to 500 pieces of rankable content — title, topics, coach, availability, published date — so the recommender can rank them.\n\nRequires the **telemetry:ingest** scope.\n\nIdentity is the `(content_type, content_external_id)` pair within your organization; sending the same pair again updates the stored record.",
        "x-required-scope": "telemetry:ingest",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["contents"],
                "properties": {
                  "contents": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 500,
                    "items": { "$ref": "#/components/schemas/RecommendationContentInput" }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-entry results, in request order.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ContentBatchResults" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "$ref": "#/components/responses/BatchSizeRejected" }
        }
      }
    },
    "/api/v1/content-parents/batch": {
      "post": {
        "tags": ["Ingest"],
        "operationId": "createContentParentBatch",
        "summary": "Upsert a batch of catalogue hierarchy edges",
        "description": "Declares up to 500 parent/child relationships — which chapters belong to which course, which videos belong to which series.\n\nRequires the **telemetry:ingest** scope.\n\nIdentity is the child `(content_type, content_external_id)` pair within your organization; sending the same pair again re-points it at the supplied parent.",
        "x-required-scope": "telemetry:ingest",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["contents"],
                "properties": {
                  "contents": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 500,
                    "items": { "$ref": "#/components/schemas/ContentParentInput" }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-entry results, in request order.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ContentBatchResults" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "$ref": "#/components/responses/BatchSizeRejected" }
        }
      }
    },
    "/api/rag/stream": {
      "get": {
        "tags": ["Ask"],
        "operationId": "streamRagAnswer",
        "summary": "Stream a retrieval-grounded answer (SSE)",
        "description": "Returns a `text/event-stream`. The `content_source` event arrives **before** any prose, so you can paint citations while the answer is still being written.\n\nRequires the **chat** scope.\n\nThis is the one endpoint where the credential may travel in the query string, because a browser `EventSource` cannot set request headers. Prefer the `X-Api-Key` header whenever your client can send one.\n\nEvents emitted:\n\n- `content_source` — where the answer's material came from (see the ContentSource schema). Always first.\n- *(unnamed `data:` frames)* — successive chunks of the answer text. Concatenate them in order.\n- `done` — `{}`; the answer is complete.\n- `error` — `{\"error\": \"...\"}`; the stream then closes. Because SSE commits a 200 before the body, errors arrive as events rather than as an HTTP status.\n\nLimits: `message` is capped at 4,000 characters and the combined text of `history` at 20,000.",
        "x-required-scope": "chat",
        "security": [
          { "ApiKeyHeader": [] },
          { "BearerAuth": [] },
          { "RagQueryApiKey": [] },
          { "RagStreamToken": [] }
        ],
        "parameters": [
          {
            "name": "message",
            "in": "query",
            "required": true,
            "description": "The question. Max 4,000 characters.",
            "schema": { "type": "string", "maxLength": 4000 }
          },
          {
            "name": "history",
            "in": "query",
            "required": false,
            "description": "Prior turns, as a JSON-encoded array of `{\"role\": \"user\"|\"assistant\", \"text\": \"...\"}`. Combined text max 20,000 characters.",
            "schema": { "type": "string" }
          },
          {
            "name": "api_key",
            "in": "query",
            "required": false,
            "description": "Your API key, for EventSource clients that cannot set headers. Accepted only on this endpoint and POST /api/rag/chat.",
            "schema": { "type": "string" }
          },
          {
            "name": "token",
            "in": "query",
            "required": false,
            "description": "A signed per-session stream token minted server-side with your organization's token secret. Replaces `api_key` for embedded widgets and carries a trusted end-user id, so no API key is exposed in client JavaScript.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "An event stream. Note that stream-level failures (blank message, over-long message or history, retrieval failure, generation failure) are delivered as an `error` event inside a 200 response.",
            "content": {
              "text/event-stream": {
                "schema": { "type": "string" },
                "example": "event: content_source\ndata: {\"type\":\"shared_sample\",\"read_only\":true,\"organization_name\":\"The Rugby Site\",\"organization_slug\":\"the-rugby-site\"}\n\ndata: The most common cause is the tighthead\ndata: binding late. Three drills in your\ndata: library address exactly this...\n\nevent: done\ndata: {}\n\n"
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/rag/chat": {
      "post": {
        "tags": ["Ask"],
        "operationId": "createRagAnswer",
        "summary": "Get a retrieval-grounded answer as one JSON response",
        "description": "The same answer as `GET /api/rag/stream`, delivered in one response for clients that cannot stream.\n\nRequires the **chat** scope.\n\nIf retrieval finds nothing that covers the question, PlayPath declines rather than improvising: you receive a 200 whose `reply` says so. Grounding is not optional.",
        "x-required-scope": "chat",
        "security": [
          { "ApiKeyHeader": [] },
          { "BearerAuth": [] },
          { "RagQueryApiKey": [] }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["message"],
                "properties": {
                  "message": { "type": "string", "maxLength": 4000, "description": "The question." },
                  "history": {
                    "type": "array",
                    "description": "Prior turns. Combined text max 20,000 characters.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "role": { "type": "string", "enum": ["user", "assistant"] },
                        "text": { "type": "string" }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The answer.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "reply": { "type": "string" },
                    "content_source": { "$ref": "#/components/schemas/ContentSource" },
                    "usage": { "type": "integer", "description": "Searches used this period. Present only while your organization is on the free evaluation allowance." },
                    "limit": { "type": "integer", "description": "Free-allowance search limit. Present only while capped." },
                    "resets_at": { "type": "string", "format": "date", "description": "When the free allowance renews. Present only while capped." }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Blank message, or a malformed request body.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Insufficient scope, or the free evaluation allowance is exhausted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": { "type": "string" },
                    "limit": { "type": "integer" },
                    "resets_at": { "type": "string", "format": "date" },
                    "pricing_url": { "type": "string" }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Message longer than 4,000 characters, or history longer than 20,000.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "502": {
            "description": "Retrieval or embedding is temporarily unavailable.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/v1/recommendations": {
      "post": {
        "tags": ["Recommend"],
        "operationId": "createRecommendationRun",
        "summary": "Generate a ranked slate for one member on one surface",
        "description": "Returns a ranked slate with a human-readable reason per item, plus the algorithm and experiment metadata needed to attribute downstream activity events back to this run.\n\nRequires the **recommendations:read** scope.\n\nRanking quality depends on what you have pushed through the Ingest group: catalogue via `/api/v1/recommendation-contents/batch` and behaviour via `/api/v1/source-activity-events/batch`.",
        "x-required-scope": "recommendations:read",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["external_user_id", "actor_role", "surface", "intent"],
                "properties": {
                  "external_user_id": {
                    "type": "string",
                    "pattern": "^trs:v1:[A-Za-z0-9_-]{16,200}$",
                    "description": "Opaque pseudonymous member id — the same value you send with activity events."
                  },
                  "actor_role": { "type": "string", "enum": ["coach", "learner"] },
                  "surface": { "type": "string", "enum": ["video_library", "video_topic", "course_detail", "article_library", "article_detail"] },
                  "intent": { "type": "string", "enum": ["continue", "related", "assign_next", "discover", "because_saved"] },
                  "limit": { "type": "integer", "default": 5, "description": "Maximum items in the slate." },
                  "context": {
                    "type": "object",
                    "description": "The content the member is currently looking at, when the surface has one.",
                    "properties": {
                      "content_type": { "type": "string", "enum": ["video", "video_chapter", "series", "article"] },
                      "content_external_id": { "type": "string" }
                    }
                  },
                  "session_exclude": {
                    "type": "array",
                    "description": "Content already shown in this session, so a slate does not repeat itself.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "content_type": { "type": "string" },
                        "content_external_id": { "type": "string" }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The generated slate.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "recommendation_id": { "type": "string", "description": "Send this back on recommendation_impression / recommendation_clicked activity events." },
                    "algorithm": { "type": "string" },
                    "algorithm_version": { "type": "string" },
                    "experiment_id": { "type": "string" },
                    "variant": { "type": "string" },
                    "intent": { "type": "string" },
                    "repeat_policy_version": { "type": "string" },
                    "generated_at": { "type": "string", "format": "date-time" },
                    "expires_at": { "type": "string", "format": "date-time" },
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "rank": { "type": "integer" },
                          "content_type": { "type": "string" },
                          "content_external_id": { "type": "string" },
                          "reason_code": { "type": "string" },
                          "reason": { "type": "string", "description": "Display-ready sentence explaining why this item is here." },
                          "evidence_category": { "type": "string" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": {
            "description": "A required parameter is missing, or a value is outside its allowed set.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/end_user_profiles": {
      "post": {
        "tags": ["Converse"],
        "operationId": "upsertEndUserProfile",
        "summary": "Upsert a member profile",
        "description": "Creates or updates the profile behind an `external_user_id`, so answers land at the right age grade and level. Idempotent per `(organization, external_user_id)`.\n\nRequires the **chat** scope.\n\nA profile is only re-derived when `fingerprint` differs from the stored one, so it is safe and cheap to call on every page load with a stable fingerprint.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["external_user_id"],
                "properties": {
                  "external_user_id": { "type": "string", "description": "Your opaque identifier for the member." },
                  "fingerprint": { "type": "string", "description": "A digest of the signals below. Re-derivation is skipped when it is unchanged." },
                  "signals": { "type": "object", "additionalProperties": true, "description": "Free-form profile signals, stored verbatim and summarised asynchronously." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "external_user_id": { "type": "string" },
                    "status": { "type": "string", "enum": ["ok"] }
                  }
                }
              }
            }
          },
          "400": {
            "description": "external_user_id missing or blank, or a malformed request body.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/coach_surface": {
      "get": {
        "tags": ["Converse"],
        "operationId": "getCoachSurface",
        "summary": "Fetch the coach surface contract",
        "description": "Returns the vocabulary a session-planner UI needs to render itself: the session structure types on offer, the session lengths, the phases a coach can add, the per-phase quick-action prompts, and the topic and age-grade lists drawn from your library.\n\nRequires the **chat** scope.\n\nNot advertised on the marketing site, but it is a stable, key-gated contract: build your planner UI against it rather than hard-coding these lists, so new phases, topics and age grades appear without a client release. `version` is bumped when the shape changes.",
        "x-required-scope": "chat",
        "responses": {
          "200": {
            "description": "The contract.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "version": { "type": "integer", "description": "Contract version. Currently 1." },
                    "structure_types": {
                      "type": "array",
                      "description": "The session shapes a coach can start from.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "string", "enum": ["full", "unit", "skills", "walkthrough"] },
                          "label": { "type": "string" },
                          "count": { "type": "integer", "description": "How many blocks this shape produces." },
                          "note": { "type": "string" }
                        }
                      }
                    },
                    "lengths": {
                      "type": "array",
                      "description": "Selectable session lengths, as display strings.",
                      "items": { "type": "string" },
                      "example": ["45 min", "60 min", "75 min", "90 min"]
                    },
                    "add_sections": {
                      "type": "array",
                      "description": "Phases a coach can append to a plan.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "phase": { "type": "string", "enum": ["warmup", "skill", "conditioned", "game", "cooldown"] },
                          "label": { "type": "string" }
                        }
                      }
                    },
                    "block_pills": {
                      "type": "object",
                      "description": "Per-phase quick actions, keyed by phase. Each entry is a two-element array: the button label, and the instruction to send as a message when it is pressed.",
                      "additionalProperties": {
                        "type": "array",
                        "items": {
                          "type": "array",
                          "items": { "type": "string" },
                          "minItems": 2,
                          "maxItems": 2
                        }
                      }
                    },
                    "topics": { "type": "array", "items": { "type": "string" }, "description": "Topic names, alphabetical." },
                    "age_grades": { "type": "array", "items": { "type": "string" }, "description": "Age grade names, alphabetical." }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/conversations": {
      "get": {
        "tags": ["Converse"],
        "operationId": "listConversations",
        "summary": "List a member's conversations",
        "description": "Returns up to 100 of the member's conversations, most recently active first.\n\nRequires the **chat** scope, and the `X-Playpath-External-User-Id` header — without it the request is rejected, because there is no such thing as \"everyone's conversations\".",
        "x-required-scope": "chat",
        "parameters": [
          { "$ref": "#/components/parameters/ExternalUserIdRequired" }
        ],
        "responses": {
          "200": {
            "description": "The member's conversations.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "conversations": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/ConversationSummary" }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "X-Playpath-External-User-Id was not supplied.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      },
      "post": {
        "tags": ["Converse"],
        "operationId": "createConversation",
        "summary": "Open a coaching session",
        "description": "Opens a new conversation. Send `X-Playpath-External-User-Id` to attribute it to a member; otherwise supply `member_id` in the body. Both are attribution only — scoping to your organization comes from the API key, never from these values.\n\nRequires the **chat** scope.",
        "x-required-scope": "chat",
        "parameters": [
          { "$ref": "#/components/parameters/ExternalUserId" }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "member_id": { "type": "string", "maxLength": 255, "description": "Fallback member identifier, used when the X-Playpath-External-User-Id header is absent." },
                  "member_name": { "type": "string", "maxLength": 255, "description": "Display name, used to stamp authorship on coach-written plan notes." }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new conversation.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConversationSummary" } } }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/conversations/{id}": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationId" },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "get": {
        "tags": ["Converse"],
        "operationId": "getConversation",
        "summary": "Fetch a conversation with its messages and session plan",
        "description": "Returns the conversation, its visible message history and its session plan if one exists.\n\nRequires the **chat** scope. When `X-Playpath-External-User-Id` is supplied, the lookup is additionally narrowed to that member's conversations.",
        "x-required-scope": "chat",
        "responses": {
          "200": {
            "description": "The conversation.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConversationDetail" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/conversations/{conversation_id}/messages": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationIdInPath" },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "post": {
        "tags": ["Converse"],
        "operationId": "createConversationMessage",
        "summary": "Add a turn and wait for the coach's reply",
        "description": "Submits a member turn and returns once the coach has replied. The coach may call tools and rewrite the session plan while it works; the updated plan comes back with the reply.\n\nRequires the **chat** scope.\n\nCoach turns are slow by nature. If you can stream, prefer `POST /api/conversations/{conversation_id}/messages/stream`, which reports progress as it goes.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["message"],
                "properties": { "message": { "type": "string", "description": "The member's turn." } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The coach's reply, recent history and the current session plan.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "conversation_id": { "type": "integer" },
                    "messages": { "type": "array", "items": { "$ref": "#/components/schemas/Message" }, "description": "The last 8 turns of visible history." },
                    "assistant": { "$ref": "#/components/schemas/Message" },
                    "session_plan": { "$ref": "#/components/schemas/SessionPlan" }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Blank message, or a malformed request body.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Insufficient scope, or the free planner allowance is exhausted (`code: \"free_planner_limit\"`).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "502": {
            "description": "The coach completed without producing a response.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "503": {
            "description": "The coach is temporarily unavailable (`code: \"coach_unavailable\"`). A `Retry-After` header is supplied.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/conversations/{conversation_id}/messages/stream": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationIdInPath" },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "post": {
        "tags": ["Converse"],
        "operationId": "streamConversationMessage",
        "summary": "Add a turn and stream the reply (SSE)",
        "description": "Same as `POST .../messages`, but returns a `text/event-stream` so you can show progress during a long coach turn.\n\nRequires the **chat** scope.\n\nEvents, each carrying a `run_id` and a monotonic SSE `id`:\n\n- `run` — accepted; includes `base_plan_version` so you can detect concurrent edits.\n- `progress` — a named stage, e.g. `understanding_request`, `finalising`.\n- `heartbeat` — every 5 seconds while generating; keeps intermediaries from closing the connection.\n- `plan` — the session plan was committed mid-run; carries the new `version` and `changed_block_ids`.\n- `assistant` — the coach's message.\n- `complete` — final plan and version.\n- `error` — `code` is one of `coach_unavailable`, `empty_response`, `free_planner_limit`, `stream_failed`.\n\nUnlike the RAG stream, this endpoint accepts the credential only via header — it is a POST, so `EventSource` cannot call it anyway.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["message"],
                "properties": { "message": { "type": "string" } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "An event stream. Run-level failures are delivered as an `error` event inside the 200.",
            "content": { "text/event-stream": { "schema": { "type": "string" } } }
          },
          "400": {
            "description": "Blank message.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/conversations/{conversation_id}/blocks": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationIdInPath" },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "post": {
        "tags": ["Converse"],
        "operationId": "createSessionPlanBlock",
        "summary": "Append a coach-authored block to the session plan",
        "description": "Writes straight to the plan and stamps coach authorship. That authorship is what makes later agent writes arrive as proposals rather than silent overwrites, so use this rather than asking the coach to make the edit for you.\n\nRequires the **chat** scope.\n\nAll block endpoints accept an optional `version`. Supply the `version` you last read and the write is rejected with 409 if the plan moved underneath you; omit it and you are not tracking versions, and PlayPath will not force the issue.\n\nNote authorship is stamped server-side from the authenticated request: any `author` / `author_id` / `at` you send inside a note is overwritten, so a note can never claim to be from someone else.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/BlockWrite" }
            }
          }
        },
        "responses": {
          "201": { "$ref": "#/components/responses/SessionPlanResponse" },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/StalePlan" }
        }
      }
    },
    "/api/conversations/{conversation_id}/blocks/{id}": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationIdInPath" },
        { "$ref": "#/components/parameters/BlockId" },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "patch": {
        "tags": ["Converse"],
        "operationId": "updateSessionPlanBlock",
        "summary": "Update a session-plan block",
        "description": "Replaces the supplied attributes on one block and returns the whole plan.\n\nRequires the **chat** scope. Supports the optional `version` precondition described on the create operation.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BlockWrite" } } }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/SessionPlanResponse" },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/StalePlan" }
        }
      },
      "delete": {
        "tags": ["Converse"],
        "operationId": "deleteSessionPlanBlock",
        "summary": "Delete a session-plan block",
        "description": "Removes one block and returns the remaining plan.\n\nRequires the **chat** scope. Supports the optional `version` precondition described on the create operation.",
        "x-required-scope": "chat",
        "parameters": [
          {
            "name": "version",
            "in": "query",
            "required": false,
            "description": "Optimistic-lock precondition: the plan `version` you last read.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/SessionPlanResponse" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/StalePlan" }
        }
      }
    },
    "/api/conversations/{conversation_id}/blocks/reorder": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationIdInPath" },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "post": {
        "tags": ["Converse"],
        "operationId": "reorderSessionPlanBlocks",
        "summary": "Reorder the blocks in a session plan",
        "description": "`ordered_ids` must list every block in the plan exactly once — a partial order is rejected rather than guessed at.\n\nRequires the **chat** scope.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ordered_ids"],
                "properties": {
                  "ordered_ids": { "type": "array", "items": { "type": "string" } },
                  "version": { "type": "string", "description": "Optimistic-lock precondition." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/SessionPlanResponse" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/StalePlan" },
          "422": {
            "description": "ordered_ids did not list every block exactly once.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/conversations/{conversation_id}/blocks/{id}/references/reorder": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationIdInPath" },
        { "$ref": "#/components/parameters/BlockId" },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "post": {
        "tags": ["Converse"],
        "operationId": "reorderBlockReferences",
        "summary": "Reorder the references on a block",
        "description": "References are reordered by index rather than by resending them, because a client round trip would drop the source citation attached to each one.\n\n`ordered_indexes` must list every reference on the block exactly once.\n\nRequires the **chat** scope.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ordered_indexes"],
                "properties": {
                  "ordered_indexes": { "type": "array", "items": { "type": "integer" } },
                  "version": { "type": "string", "description": "Optimistic-lock precondition." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/SessionPlanResponse" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/StalePlan" },
          "422": {
            "description": "ordered_indexes did not list every reference exactly once.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/conversations/{conversation_id}/blocks/{id}/references/{index}/note": {
      "parameters": [
        { "$ref": "#/components/parameters/ConversationIdInPath" },
        { "$ref": "#/components/parameters/BlockId" },
        {
          "name": "index",
          "in": "path",
          "required": true,
          "description": "Zero-based position of the reference on the block.",
          "schema": { "type": "integer" }
        },
        { "$ref": "#/components/parameters/ExternalUserId" }
      ],
      "post": {
        "tags": ["Converse"],
        "operationId": "annotateBlockReference",
        "summary": "Annotate one reference on a block",
        "description": "Attaches a coach note to a single reference. Author and timestamp are stamped server-side from the authenticated request.\n\nRequires the **chat** scope.",
        "x-required-scope": "chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["note"],
                "properties": {
                  "note": {
                    "type": "object",
                    "properties": { "body": { "type": "string" } }
                  },
                  "version": { "type": "string", "description": "Optimistic-lock precondition." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/SessionPlanResponse" },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/StalePlan" }
        }
      }
    },
    "/api/items": {
      "get": {
        "tags": ["Library"],
        "operationId": "listItems",
        "summary": "List indexed items",
        "description": "Returns every item PlayPath has indexed for your organization. Embedding vectors are omitted.\n\nRequires the **content:read** scope, which never implies write access.",
        "x-required-scope": "content:read",
        "responses": {
          "200": {
            "description": "Your items.",
            "content": {
              "application/json": {
                "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Item" } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      },
      "post": {
        "tags": ["Library"],
        "operationId": "createItem",
        "summary": "Create or update an item",
        "description": "Upsert, not strict create. `url` identifies the item: if one already exists at the posted `url` it is updated in place and returned with 200 (an exact `title` + `url` match wins when a url carries more than one item). If the payload has no `url`, a matching `text`, then a matching `title`, resolves the item instead. Anything unmatched is created and returned with 201. That is deliberate — re-syncing a catalogue must not produce duplicates, but records that share a description and differ by url are distinct content and stay distinct items.\n\nChanging the content queues re-embedding, so `state` will read `pending` until that completes.\n\nRequires the **content:write** scope.\n\nAttributes may be sent at the top level or nested under `item`.",
        "x-required-scope": "content:write",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ItemInput" } } }
        },
        "responses": {
          "200": {
            "description": "An existing item matched and was updated.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Item" } } }
          },
          "201": {
            "description": "A new item was created.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Item" } } }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": {
            "description": "Validation failed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "errors": { "type": "array", "items": { "type": "string" } } }
                }
              }
            }
          }
        }
      }
    },
    "/api/items/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
      ],
      "get": {
        "tags": ["Library"],
        "operationId": "getItem",
        "summary": "Fetch one item with its nearest neighbours",
        "description": "Returns the item plus up to 10 semantically nearest items from your library, ordered by cosine distance. Neighbours are empty until the item has been embedded.\n\nRequires the **content:read** scope.",
        "x-required-scope": "content:read",
        "responses": {
          "200": {
            "description": "The item and its neighbours.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Item" },
                    {
                      "type": "object",
                      "properties": {
                        "neighbors": { "type": "array", "items": { "$ref": "#/components/schemas/Item" } }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "No such item in your organization." }
        }
      },
      "patch": {
        "tags": ["Library"],
        "operationId": "updateItem",
        "summary": "Update an item",
        "description": "Updates the item. If the id does not resolve, PlayPath falls back to the same upsert-by-content path as `POST /api/items` rather than 404ing.\n\nRequires the **content:write** scope.",
        "x-required-scope": "content:write",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ItemInput" } } }
        },
        "responses": {
          "200": {
            "description": "The updated item.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Item" } } }
          },
          "201": {
            "description": "No item matched, so one was created.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Item" } } }
          },
          "400": { "$ref": "#/components/responses/MalformedBody" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "description": "Validation failed." }
        }
      },
      "delete": {
        "tags": ["Library"],
        "operationId": "deleteItem",
        "summary": "Delete an item",
        "description": "Removes the item from your library and from retrieval.\n\nRequires the **content:write** scope.",
        "x-required-scope": "content:write",
        "responses": {
          "204": { "description": "Deleted." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "No such item in your organization." }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "Your organization API key. The preferred form on every endpoint."
      },
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "The same API key sent as `Authorization: Bearer <key>`. Equivalent to X-Api-Key."
      },
      "RagQueryApiKey": {
        "type": "apiKey",
        "in": "query",
        "name": "api_key",
        "description": "API key in the query string. Accepted **only** on GET /api/rag/stream and POST /api/rag/chat, because a browser EventSource cannot set request headers. A credential in a URL leaks into logs, proxies and referrer headers — use a header wherever you can."
      },
      "RagStreamToken": {
        "type": "apiKey",
        "in": "query",
        "name": "token",
        "description": "A signed, per-session stream token minted server-side from your organization's token secret. Accepted only on the RAG endpoints, where it grants chat access and carries a trusted end-user id — so an embedded widget never ships an API key to the browser."
      }
    },
    "parameters": {
      "ExternalUserId": {
        "name": "X-Playpath-External-User-Id",
        "in": "header",
        "required": false,
        "description": "Your opaque identifier for the end user behind this request. Derive it from your own session server-side; a browser must never send it directly. Supplying it narrows reads and writes to that member's own conversations.",
        "schema": { "type": "string", "maxLength": 255 }
      },
      "ExternalUserIdRequired": {
        "name": "X-Playpath-External-User-Id",
        "in": "header",
        "required": true,
        "description": "Your opaque identifier for the end user whose conversations to list. Derive it from your own session server-side.",
        "schema": { "type": "string", "maxLength": 255 }
      },
      "ConversationId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": { "type": "integer" }
      },
      "ConversationIdInPath": {
        "name": "conversation_id",
        "in": "path",
        "required": true,
        "schema": { "type": "integer" }
      },
      "BlockId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The block's id within the session plan.",
        "schema": { "type": "string" }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "No credential was supplied, or it is unknown or revoked.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "Unauthorized" }
          }
        }
      },
      "Forbidden": {
        "description": "The key is valid but lacks the scope this endpoint requires.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "Insufficient scope: content:write required" }
          }
        }
      },
      "NotFound": {
        "description": "No such record within your organization.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "not found" }
          }
        }
      },
      "MalformedBody": {
        "description": "The request body could not be parsed as JSON.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "Malformed request body" }
          }
        }
      },
      "BatchSizeRejected": {
        "description": "The batch array was missing, empty, or larger than the endpoint's limit.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "StalePlan": {
        "description": "The supplied `version` no longer matches: the plan changed since you read it. The current version is returned so you can refetch and retry.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": { "type": "string" },
                "version": { "type": "string" }
              }
            },
            "example": { "error": "plan has changed since you loaded it", "version": "2026-09-12T09:14:22.481000Z" }
          }
        }
      },
      "SessionPlanResponse": {
        "description": "The full session plan after the write.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionPlan" } } }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string" },
          "code": { "type": "string", "description": "Machine-readable code, on the error shapes that carry one." }
        },
        "required": ["error"]
      },
      "ContentSource": {
        "type": "object",
        "description": "Where the answer's supporting material came from. `workspace` means your own library. `shared_sample` means a read-only sample library shared with you, and names it — so you can label answers you cannot yet edit the sources of.",
        "properties": {
          "type": { "type": "string", "enum": ["workspace", "shared_sample"] },
          "read_only": { "type": "boolean" },
          "organization_name": { "type": "string", "description": "Present only for shared_sample." },
          "organization_slug": { "type": "string", "description": "Present only for shared_sample." }
        }
      },
      "SourceArticleInput": {
        "type": "object",
        "required": ["external_id", "observed_at"],
        "properties": {
          "external_id": { "type": "string", "description": "Your identifier for the article." },
          "observed_at": { "type": "string", "format": "date-time", "description": "When you read this state from your source. Conflict resolution depends on it." },
          "source_updated_at": { "type": "string", "format": "date-time" },
          "publicly_available": { "type": "boolean" },
          "title": { "type": "string" },
          "url": { "type": "string" },
          "body": { "type": "string" }
        }
      },
      "SourceActivityEventInput": {
        "type": "object",
        "required": ["schema_version", "external_user_id", "actor_role", "kind", "content_type", "content_external_id", "idempotency_key", "occurred_at"],
        "properties": {
          "schema_version": { "type": "integer", "enum": [1] },
          "external_user_id": { "type": "string", "pattern": "^trs:v1:[A-Za-z0-9_-]{16,200}$", "description": "Opaque pseudonymous member id. Never a real identifier." },
          "actor_role": { "type": "string", "enum": ["coach", "learner"] },
          "kind": {
            "type": "string",
            "enum": [
              "recommendation_impression", "recommendation_clicked", "recommendation_dismissed",
              "course_impression", "course_opened",
              "play_started", "progress_25", "progress_50", "progress_75", "completed",
              "saved", "unsaved", "assigned", "assignment_resent", "reviewed",
              "assignment_email_clicked", "assignment_claimed", "assignment_started", "assignment_completed",
              "legacy_watch_proxy", "legacy_assignment_snapshot"
            ]
          },
          "content_type": { "type": "string", "enum": ["video", "video_chapter", "series", "article"] },
          "content_external_id": { "type": "string", "maxLength": 255 },
          "activity_group_id": { "type": "string", "maxLength": 255, "description": "Groups events that belong to one member action." },
          "recommendation_id": { "type": "string", "description": "The recommendation_id this event is attributed to, for recommendation_* kinds." },
          "corrects_idempotency_key": { "type": "string", "description": "The earlier event this one corrects. Corrections are new events; the ledger is never rewritten." },
          "idempotency_key": { "type": "string", "maxLength": 255, "description": "Unique per organization. Resending the same key with the same payload is a no-op." },
          "occurred_at": { "type": "string", "format": "date-time" },
          "metadata": { "type": "object", "additionalProperties": true, "description": "Keys are allow-listed per kind; anything else is rejected." }
        }
      },
      "RecommendationContentInput": {
        "type": "object",
        "required": ["content_type", "content_external_id", "title"],
        "properties": {
          "content_type": { "type": "string", "enum": ["video", "article"] },
          "content_external_id": { "type": "string", "maxLength": 255 },
          "title": { "type": "string", "maxLength": 255 },
          "coach_name": { "type": "string", "maxLength": 255 },
          "topics": { "type": "array", "items": { "type": "string" } },
          "available": { "type": "boolean" },
          "source_updated_at": { "type": "string", "format": "date-time" },
          "published_at": { "type": "string", "format": "date-time" },
          "popularity_score": { "type": "integer", "minimum": 0 }
        }
      },
      "ContentParentInput": {
        "type": "object",
        "required": ["content_type", "content_external_id", "parent_content_type", "parent_content_external_id"],
        "properties": {
          "content_type": { "type": "string", "enum": ["video", "video_chapter", "series", "article"] },
          "content_external_id": { "type": "string" },
          "parent_content_type": { "type": "string", "enum": ["video", "video_chapter", "series", "article"] },
          "parent_content_external_id": { "type": "string" },
          "source_updated_at": { "type": "string", "format": "date-time" }
        }
      },
      "ContentBatchResults": {
        "type": "object",
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "content_type": { "type": "string" },
                "content_external_id": { "type": "string" },
                "id": { "type": "integer" },
                "status": { "type": "string", "enum": ["recorded", "updated", "rejected"] },
                "error": { "type": "string", "description": "Present only when status is rejected." }
              }
            }
          }
        }
      },
      "ConversationSummary": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "title": { "type": "string" },
          "has_session_plan": { "type": "boolean" },
          "total_minutes": { "type": "integer", "nullable": true },
          "last_message_at": { "type": "string", "format": "date-time", "nullable": true }
        }
      },
      "ConversationDetail": {
        "allOf": [
          { "$ref": "#/components/schemas/ConversationSummary" },
          {
            "type": "object",
            "properties": {
              "messages": { "type": "array", "items": { "$ref": "#/components/schemas/Message" } },
              "session_plan": { "$ref": "#/components/schemas/SessionPlan" }
            }
          }
        ]
      },
      "Message": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "role": { "type": "string", "enum": ["user", "assistant", "tool"] },
          "content": { "type": "string", "nullable": true },
          "tool_name": { "type": "string", "nullable": true },
          "tool_arguments": { "type": "object", "additionalProperties": true, "nullable": true },
          "tool_result": { "type": "object", "additionalProperties": true, "nullable": true },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "SessionPlan": {
        "type": "object",
        "nullable": true,
        "description": "A coaching session plan. `version` is the optimistic-lock token: send it back on any block write to be told, rather than silently overwritten, when the plan moved underneath you.",
        "properties": {
          "id": { "type": "integer" },
          "version": { "type": "string", "description": "Microsecond-precision ISO 8601 timestamp." },
          "title": { "type": "string", "nullable": true },
          "summary": { "type": "string", "nullable": true },
          "total_minutes": { "type": "integer", "nullable": true },
          "state": { "type": "string" },
          "blocks": { "type": "array", "items": { "$ref": "#/components/schemas/PlanBlock" } },
          "concept_blocks": { "type": "array", "items": { "$ref": "#/components/schemas/PlanBlock" } }
        }
      },
      "PlanBlock": {
        "type": "object",
        "description": "One section of the plan. Block attributes are open-ended; the fields below are the ones the API writes and returns.",
        "properties": {
          "id": { "type": "string" },
          "kind": { "type": "string" },
          "phase": { "type": "string", "description": "warmup, skill, conditioned, game or cooldown." },
          "name": { "type": "string" },
          "objective": { "type": "string" },
          "duration_minutes": { "type": "integer" },
          "notes": { "type": "string", "description": "The coaching cue written by the assistant. Distinct from `note`, which is the coach's own annotation." },
          "summary": { "type": "string" },
          "source": { "type": "string" },
          "status": { "type": "string" },
          "key_points": { "type": "array", "items": { "type": "string" } },
          "note": { "$ref": "#/components/schemas/AuthoredNote" },
          "drills": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "text": { "type": "string" },
                "note": { "$ref": "#/components/schemas/AuthoredNote" }
              }
            }
          },
          "references": {
            "type": "array",
            "description": "Library citations attached to this block, resolved to public URLs on read.",
            "items": {
              "type": "object",
              "properties": {
                "title": { "type": "string" },
                "url": { "type": "string" },
                "entity_type": { "type": "string" },
                "wistia_id": { "type": "string" },
                "duration": { "type": "string" },
                "coach": { "type": "string" },
                "series": { "type": "string" }
              }
            }
          }
        }
      },
      "AuthoredNote": {
        "type": "object",
        "description": "A coach's annotation. Send `body`; `author`, `author_id` and `at` are stamped server-side from the authenticated request and overwrite anything you supply.",
        "properties": {
          "body": { "type": "string" },
          "author": { "type": "string", "readOnly": true },
          "author_id": { "type": "string", "readOnly": true, "nullable": true },
          "at": { "type": "string", "format": "date-time", "readOnly": true }
        }
      },
      "BlockWrite": {
        "type": "object",
        "required": ["block"],
        "properties": {
          "block": { "$ref": "#/components/schemas/PlanBlock" },
          "version": { "type": "string", "description": "Optimistic-lock precondition: the plan `version` you last read. Omit if you are not tracking versions." }
        }
      },
      "Item": {
        "type": "object",
        "description": "One indexed piece of content. The embedding vector itself is never returned.",
        "properties": {
          "id": { "type": "integer" },
          "title": { "type": "string" },
          "url": { "type": "string", "nullable": true },
          "text": { "type": "string", "nullable": true },
          "tags": { "type": "array", "items": { "type": "string" } },
          "state": { "type": "string", "enum": ["pending", "embedded", "failed"], "description": "Embedding state. New and edited items read `pending` until embedding completes." },
          "organization_id": { "type": "integer" },
          "user_id": { "type": "integer", "nullable": true },
          "embedded_at": { "type": "string", "format": "date-time", "nullable": true },
          "embedding_model": { "type": "string", "nullable": true },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        }
      },
      "ItemInput": {
        "type": "object",
        "description": "Attributes may be sent at the top level or nested under an `item` key.",
        "properties": {
          "title": { "type": "string" },
          "url": { "type": "string" },
          "text": { "type": "string" },
          "tags": { "type": "array", "items": { "type": "string" } }
        }
      }
    }
  }
}
