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.
---
**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`.
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.
---
**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`.
Operations
GET /courses
List courses in the Purple Learn catalog
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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 /courses
Add a course to the Purple Learn catalog
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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."
}
GET /courses/{id}
Get one Purple Learn course
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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 /courses/{id}
Update a Purple Learn course
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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 /courses/{id}
Delete a Purple Learn course
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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."
}
POST /courses/{id}/enroll
Enroll a learner in a course
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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"
}
}
}
GET /enrollments
List learner enrollments (training progress)
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'.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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 /enrollments
Create an enrollment record directly
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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."
}
GET /enrollments/{id}
Get one learner enrollment
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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 /enrollments/{id}
Update enrollment progress or status
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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 /enrollments/{id}
Delete a learner enrollment
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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."
}
POST /enrollments/{id}/complete
Mark an enrollment complete
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.
Authentication
[
{
"patAuth": []
}
]
Request, responses and operation details
{
"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"
}
}
}