{"openapi":"3.0.0","info":{"title":"Purple Suite LMS API","description":"Learning Management System (LMS) API — manage a course catalog and learner enrollments. Resembles tools like Cornerstone, Docebo, and SAP SuccessFactors Learning. Pairs with Purple HRS skills/goals for skill-gap-driven learning.\n\n---\n\n**Acting as a user (`X-PS-Impersonate-User`).** Every endpoint accepts an optional `X-PS-Impersonate-User` request header whose value is the email of the instance user to act as. The effective user resolves in this order: the header if present, else the instance's configured default MCP user, else none. User-scoped paths additionally accept the literal `me` in place of an email, and Graph-style apps also serve `/me/…` twins of their `/users/{email}/…` paths that resolve the same way. This header is honored by all operations but is intentionally omitted from each operation's parameter list so it is not surfaced as a per-tool argument by MCP clients; see the `ImpersonateUser` entry under `components.parameters`.","version":"1.0.0"},"servers":[{"url":"https://marketplace.moveworks.com/api/purple-suite/lms"}],"security":[{"patAuth":[]}],"components":{"securitySchemes":{"patAuth":{"type":"http","scheme":"bearer"}},"parameters":{"InstanceId":{"in":"header","name":"X-Instance-ID","required":true,"schema":{"type":"string"}},"ImpersonateUser":{"in":"header","name":"X-PS-Impersonate-User","required":false,"schema":{"type":"string","format":"email"},"description":"Optional. Act as a specific user in the target instance — the value is that user's email (must exist in the instance). Resolution order for the effective user: (1) this header, if set; (2) the instance's configured default MCP user; (3) otherwise no user is bound. User-scoped paths (e.g. /users/{email}/…) also accept the literal \"me\", and Graph-style apps serve /me/… twins of those paths (Graph: /me ≡ /users/{signed-in-user}) — both resolve to whatever this header (or the default MCP user) points at. An explicit path email always wins over the header. The unified MCP server does not send this header — it relies on the per-instance default — so it is primarily for direct REST/HTTP callers."}},"schemas":{"Course":{"type":"object","properties":{"id":{"type":"string","readOnly":true,"example":"CRS-0001"},"title":{"type":"string","example":"Foundations of People Management"},"description":{"type":"string","example":"A practical course covering the fundamentals of leading a team."},"skills_taught":{"type":"array","description":"Skills this course develops (matches Purple HRS employee skills).","items":{"type":"string"},"example":["People Management","Coaching","Communication"]},"level":{"type":"string","enum":["beginner","intermediate","advanced"],"example":"beginner"},"duration_minutes":{"type":"integer","example":90},"format":{"type":"string","enum":["video","article","path"],"example":"video"},"provider":{"type":"string","example":"Purple Academy"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}},"required":["title"]},"Enrollment":{"type":"object","properties":{"id":{"type":"string","readOnly":true,"example":"ENR-0001"},"course_id":{"type":"string","example":"CRS-0001"},"learner":{"type":"string","description":"Resolved learner identifier (employee_id or user_email). Set automatically on enroll.","example":"EMP-0001"},"employee_id":{"type":"string","nullable":true,"example":"EMP-0001"},"user_email":{"type":"string","nullable":true,"format":"email","example":"alice.smith0@acme.com"},"status":{"type":"string","enum":["not_started","in_progress","completed","dropped"],"example":"in_progress"},"progress_pct":{"type":"integer","minimum":0,"maximum":100,"example":40},"score":{"type":"number","nullable":true,"minimum":0,"maximum":100,"example":88},"enrolled_at":{"type":"string","format":"date-time","example":"2026-05-01T00:00:00.000Z"},"completed_at":{"type":"string","format":"date-time","nullable":true,"example":"2026-05-20T00:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}}}},"paths":{"/courses":{"get":{"summary":"List courses in the Purple Learn catalog","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Course"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}},"description":"Browse the learning catalog of this instance (Cornerstone/Docebo-style): each Course has a title, level, format (video/article/path), duration and skills_taught. This is the catalog of available training, NOT anyone's progress — for who is taking what, use lms__list__enrollments. Supports server-side OData querying — $filter, $search, $orderby, $select, $top, $skip, $count (MCP clients see these as _filter, _search, _orderby, _select, _top, _skip, _count because $ is not a legal tool-input key). Example: $filter=level eq 'beginner' and $search=leadership."},"post":{"summary":"Add a course to the Purple Learn catalog","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}}},"description":"Creates a new catalog entry (title required; optionally level, format, provider, duration_minutes and the skills_taught it develops). Creating a course does not assign it to anyone — use lms__enroll__courses to put a learner on it."}},"/courses/{id}":{"get":{"summary":"Get one Purple Learn course","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"404":{"description":"Not found"}},"description":"Fetches a single catalog course by id (e.g. CRS-0001), including its skills_taught, level, format and duration. Use it to confirm a course exists or check what skills it covers before enrolling someone."},"patch":{"summary":"Update a Purple Learn course","description":"Partially updates a catalog course (title, description, level, format, provider, duration_minutes, skills_taught). Affects the catalog entry only — existing learner enrollments and their progress are untouched.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a Purple Learn course","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}},"description":"DESTRUCTIVE and irreversible: removes the course from the catalog. Enrollment records that point at this course_id are not cleaned up and will reference a missing course, so prefer retiring a course via lms__update__courses unless it was created in error."}},"/courses/{id}/enroll":{"post":{"summary":"Enroll a learner in a course","description":"The normal way to put someone on training: creates a not_started Enrollment for the given learner (identify them by employee_id or user_email) against the course in the path. Fails with 422 if that learner already has an active (not_started/in_progress) enrollment for the course, and 404 if the course does not exist. Prefer this over lms__create__enrollments, which skips those checks.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"employee_id":{"type":"string","description":"Learner's HRS employee ID. Provide this or user_email.","example":"EMP-0001"},"user_email":{"type":"string","format":"email","description":"Learner's email. Provide this or employee_id.","example":"alice.smith0@acme.com"}}}}}},"responses":{"201":{"description":"Enrollment created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"400":{"description":"Missing learner (employee_id or user_email)"},"404":{"description":"Course not found"},"422":{"description":"Learner already has an active enrollment for this course"}}}},"/enrollments":{"get":{"summary":"List learner enrollments (training progress)","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Enrollment"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}},"description":"An Enrollment is one learner's run through one course — course_id, learner (employee_id or user_email), status (not_started | in_progress | completed | dropped), progress_pct, score and completion date. Use this for 'who has completed X', overdue training or compliance reporting; use lms__list__courses for the catalog itself. Supports server-side OData querying — $filter, $search, $orderby, $select, $top, $skip, $count (MCP clients see these as _filter, _search, _orderby, _select, _top, _skip, _count because $ is not a legal tool-input key). Example: $filter=status eq 'completed' and course_id eq 'CRS-0001'."},"post":{"summary":"Create an enrollment record directly","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}}},"description":"Low-level create that writes an Enrollment exactly as given — useful for backfilling historical or already-completed training. To simply put a learner on a course, prefer lms__enroll__courses, which resolves the learner, defaults the status and blocks duplicate active enrollments; this operation performs none of those checks."}},"/enrollments/{id}":{"get":{"summary":"Get one learner enrollment","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"404":{"description":"Not found"}},"description":"Fetches a single Enrollment by id (e.g. ENR-0001) with its current status, progress_pct, score and timestamps. Use it to check one learner's progress on one course after finding the id via lms__list__enrollments."},"patch":{"summary":"Update enrollment progress or status","description":"Partially updates an Enrollment — typically progress_pct, score, or status among not_started | in_progress | completed | dropped (use this to drop or resume someone). A completed enrollment cannot be reopened. To finish a course properly, prefer lms__complete__enrollments, which also sets progress to 100 and stamps completed_at.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a learner enrollment","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}},"description":"DESTRUCTIVE and irreversible: erases the enrollment and with it the learner's recorded progress, score and completion history for that course. To take someone off a course while keeping the audit trail, set status to 'dropped' via lms__update__enrollments instead."}},"/enrollments/{id}/complete":{"post":{"summary":"Mark an enrollment complete","description":"The correct way to finish training: transitions the enrollment to completed, forces progress_pct to 100, stamps completed_at and stores an optional final score. This is a one-way transition — a completed enrollment can never be reopened — and it fails with 422 if the enrollment is already completed or was dropped. Use lms__update__enrollments for partial progress instead.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"score":{"type":"number","minimum":0,"maximum":100,"description":"Final assessment score, if any.","example":88}}}}}},"responses":{"200":{"description":"Enrollment completed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"404":{"description":"Enrollment not found"},"422":{"description":"Enrollment already completed or dropped"}}}}}}