---
title: LMS (Courses)
---

# LMS (Courses)

## What it is

The LMS surface — Screen 1 (HR-manager dashboard), Screen 2 (course editor + outline generator), Screen 3 (lesson editor: doc + quiz), Screen 4 (learner home), Screen 5 (learner lesson viewer: doc + quiz), Screen 6 (learner progress + certificate), and Screen 8 (Docebo import wizard) — ships as an **in-repo plugin webview**. Wave 1.

## Where to find it

It ships as an **in-repo plugin**, so it appears inside the app as its own surface rather than as a separate website. Its screens run from the manager dashboard and the course and lesson editors through to the learner home, lesson viewer and progress view. It is gated behind a feature switch, so it stays hidden until it is turned on.

## How it behaves

### Feature gate

`isLmsFeatureVisible()` in `src/main/services/lms/lms-feature-gate.ts` is the sole gate. It returns `true` when the LMS plugin is installed AND enabled — no separate feature flag. Retired: the pre-plugin `lms-panel` `UnreleasedFeatureId` (Decision 4, split-map.md).

### Deep links

`omniscio://lms/*` is parsed in main and forwarded to the plugin webview via the `lms:route` push kind. The plugin's `lms-route-resolver.ts` maps the parsed path to a `useLmsCoursesStore.view` kind (`list`, `course-editor`, `lesson-editor`, `learner-home`, `learner-lesson`, `learner-progress`, `manager-dashboard`, `manager-per-course-table`, `import-wizard`).

### Known gaps (Wave 2)

- **Active-account credential provider**: the plugin's `getCreds` returns `null` in Wave 1 so `generateOutline` + `doceboAiReview` degrade to the "no api key" empty state. Wave 2 wires a real credential provider.
- **Docebo OAuth**: opens the consent page via `window.open` (Wave 1 fallback). The wizard prompts the user to paste the redirect code back. Wave 2 wires a bridge-level `openExternalUrl` + auto-capture.
- **LinkedIn share button**: uses `window.open` for the LinkedIn Add-to-Profile URL. Wave 2 wires the guarded external-URL bridge.
- **Push subscribers**: not wired; wizard polls instead. Wave 2.
- **i18n keys**: the plugin ships English-only via the identity-passthrough `rendererT` shim (`src/plugins/lms/web/src/lib/i18n.ts`). Every user-facing string is authored with a key + English fallback so Wave 2 can drop in a real catalog without touching call sites.
- **Certificate URL**: the PDF lives on-disk in the user's userData folder (not a public URL LinkedIn can crawl); the LinkedIn share URL therefore omits `certUrl`. Wave 2 with hosted cert URLs adds it.
- **Alert-actions barrel**: `LMS_DOCEBO_ALERT_ACTIONS` is exported but not registered in `ALL_ALERT_ACTION_MODULES` (matches ref bug). Wave 2 or a follow-up wires it.

### Notable adaptations from the ref (do NOT re-open without splitting the design)

Locked at GATE 1 (Session 1); details in `_kb/kickoffs/lms/split-map.md`:

1. **Reading A** — author the plugin webview fresh from the ref, not port-then-delete. `src/renderer/src/features/lms/` never existed on this branch.
2. **Push wiring approach 1** — plugin-scoped IPC channels via `webContents.send` (matches `plugin-session-status-change`), not IPC channel additions.
3. **UI primitives** — plugin owns its own primitives under `src/plugins/lms/web/src/ui/**` (Writer's 8 base + 4 LMS-specific + Session 2's 4 additions + Session 3's Modal/DialogShell extras). The plugin webview cannot import from the renderer kit.
4. **DirectionalIcon dropped** across every ported component — the plugin ships English-only, so raw lucide icons work.
5. **`data-ui-anchor` props dropped** across every ported component — the plugin webview is an isolated React tree the anchor registry cannot see across.

## For agents

### Where the code lives

- **Plugin webview (renderer)**: `src/plugins/lms/web/src/` (React 19, Zustand, Tailwind, dnd-kit for drag-reorder, react-markdown for lesson bodies, @tanstack/react-virtual for large lists).
- **Plugin bundle**: `src/plugins/lms/manifest.json` + `src/plugins/lms/ui/*` (built with `pnpm build` in `web/`; tracked committed artifacts).
- **Plugin bridge (main)**: `src/plugins/lms/bridge/lms-bridge.ts` — one dispatcher exposing 33 methods (course CRUD, module/lesson CRUD, enrollment + completion, manager rollup, Docebo import, AI outline, certificate).
- **Backend services (main)**: `src/main/services/lms/**` — course service, learner rollup, certificate PDF generation, Docebo mapper + import orchestrator, AI outline via the `generateOutline` service.
- **CLI routes (agent-facing)**: `src/main/services/cli/cli-server-lms-routes.ts` — 33 routes under `/lms/*`, each gated on `isLmsFeatureVisible()` + `X-AMC-Source-Session-Id`.
- **Shared schemas (Zod)**: `src/shared/lms/**` (13 row shapes) + `src/main/ipc/bridge-method-schemas.ts` LMS_SCHEMAS (bridge input validation) + `src/shared/plugin-bridge-response-schemas.ts` `lms.*` entries (response markers).
- **Shared IPC push kinds**: `src/shared/ipc-channels/lms.ts` — only 5 push kinds (4 import lifecycle + 1 deep-link route). Every request/response goes through the plugin bridge, NOT the shared channel barrel.

### Data invariants

- **A lesson is completed ONCE per enrollment.** `lesson_completions` carries a UNIQUE index on `(enrollment_id, lesson_id)` (`uniq_lesson_completions_enrollment_lesson`, added by `20260918203040-lms-lesson-completion-uniqueness.ts`), and `LmsService.createLessonCompletion` returns the completion already on record instead of inserting a second row.
- **Progress counts are per LESSON, never per row.** Both sides must collapse completions to a set of lesson ids before counting — the learner surfaces do it in `lms-learner-utils.ts`, the manager math in `lms-manager-aggregation.ts` (`latestCompletionPerLesson`). Counting raw completion rows let one lesson completed twice report 100% on a two-lesson course.

### How the plugin talks to the backend

The plugin webview cannot import from `src/main/**` or `src/renderer/**` — it's an isolated React tree. It reaches the backend through the plugin bridge:

```
plugin webview  →  window.plugin.lms.<method>(input)
                    │
                    │  (preload validates response with PLUGIN_BRIDGE_RESPONSE_SCHEMAS['lms.<method>'])
                    ▼
                  handleLmsBridgeCall  (src/plugins/lms/bridge/lms-bridge.ts)
                    │
                    │  (input validated by LMS_SCHEMAS in bridge-method-schemas.ts)
                    ▼
                  LmsService / DoceboImportOrchestrator / etc.
```

The plugin's `lmsApi` (`src/plugins/lms/web/src/lib/lms-api.ts`) wraps the 33 bridge calls into typed methods. Consumer-only Zustand stores under `src/plugins/lms/web/src/store/` hold state; every mutation goes through `lmsApi` (UFC `state-lifecycle-integrity`).

### Push events (backend-broadcast)

Five push kinds survive on the shared IPC barrel (`src/shared/ipc-channels/lms.ts` `LMS_CHANNELS`), broadcast via `emitPush()` on the main side:

- `lms:import-started` / `-progress` / `-complete` / `-failed` — Docebo import lifecycle
- `lms:route` — deep-link forwarding (`omniscio://lms/*` parsed in main → pushed to the plugin webview)

**Wave 1 note**: the plugin webview does not yet subscribe to these push kinds. The Docebo import wizard polls `getImportStatus(jobId)` every 2 seconds while a job is running instead. Wave 2 wires the plugin's push subscribers through the preload bridge.

## Related

Plugins in general — how they are installed, reviewed and enabled — are described on the [Plugin marketplace](plugin-marketplace.md) page.
