RSS Amplifier

Development and Entrepreneurship - Ernesto · Mar 3, 2026

Reverse-Engineering the Sensei LMS API with an AI Agent

0
Sign in to vote or save

Ernesto Butto · Development and Entrepreneurship - Ernesto

We needed programmatic access to Sensei LMS lesson content — vocabulary tables, grammar notes, dialogues — so an AI agent could read it and generate practice blueprints. Sensei has no published API reference. The WordPress REST API docs cover core resources, but Sensei’s custom endpoints? Undocumented.

We started with admin credentials and a Playwright MCP browser session. Logged into wp-admin, explored the dashboard, confirmed Sensei was installed with 0 courses. Created test content (1 course, 2 modules, 2 lessons) to have something to query against.

First surprise: The Gutenberg editor timed out when opening via Playwright. Block editors are heavy JavaScript apps — not great for browser automation. We pivoted to the REST API immediately, using page.evaluate() to make fetch calls from the authenticated browser session with the WordPress nonce.

Every WordPress site exposes its full route map at /wp-json/. We fetched it and filtered for Sensei-related routes:

GET /wp-json/
→ Filter routes containing "course", "lesson", "sensei", "module"

This revealed two API namespaces:

  • wp/v2/courses and wp/v2/lessons — standard WordPress custom post types

  • sensei-internal/v1/ — Sensei’s private namespace with course-structure, lesson-quiz, and other endpoints

The wp/v2 endpoints follow standard WordPress REST conventions. No surprises there. But modules? Not exposed as a REST taxonomy at all. GET /wp-json/wp/v2/module returned 404.

Modules only exist inside the sensei-internal/v1/course-structure/{course_id} endpoint. But what’s the POST format to create them?

Attempt 1: Flat array of modules and lessons.

[{ "type": "module", "title": "...", "lessons": [{ "id": 3609 }] }]

400 invalid_input

Attempt 2: PUT instead of POST, different structure.
400 invalid_input

Attempt 3: Wrapped in { "structure": [...] } but without titles on lessons.
400 Please ensure all lessons have a name

Attempt 4 (success): The structure wrapper with titles on ALL items — both modules and lessons:

{
  "structure": [{
    "type": "module",
    "title": "Ordering Food & Drinks",
    "lessons": [{
      "type": "lesson",
      "id": 3609,
      "title": "At the Restaurant"
    }]
  }]
}

200 OK — modules created, lessons assigned.

Lesson learned: Sensei’s internal API requires redundant data. Even though lesson 3609 already has a title in the database, you must include it in the structure payload or it rejects.

With content created, we tried reading it back:

GET /wp-json/wp/v2/lessons/3609

The content.rendered field came back empty. The lesson existed, had content, but the API returned nothing.

The fix: context=edit. Sensei gates lesson content behind enrollment for the default context=view. Since our API user isn’t “enrolled” as a student, the content is hidden. Adding ?context=edit tells WordPress to return the full content as an editor would see it — bypassing enrollment checks for authenticated users with edit permissions.

This is standard WordPress behavior, but Sensei applies it more aggressively than most plugins.

To call the API from outside the browser (curl, agents), we needed Application Passwords — WordPress’s built-in Basic Auth mechanism for server-to-server access.

POST /wp-json/wp/v2/users/2/application-passwords
→ 501 "Application passwords are not available"

The hosting provider (Hostinger) had disabled them entirely. We found the Hostinger Tools plugin in the plugin list and applied the standard WordPress override by editing the active theme’s functions.php:

add_filter( 'wp_is_application_passwords_available', '__return_true' );

After that, Application Passwords worked. Created one via the API, tested with curl, confirmed all endpoints returned full content.

Final verification — three endpoints, all working from the terminal:

# List courses
curl -s -u 'user:pass' 'https://site.com/wp-json/wp/v2/courses?context=edit'
# Course structure (modules + lessons)
curl -s -u 'user:pass' 'https://site.com/wp-json/sensei-internal/v1/course-structure/3608'
# Lesson content (full HTML)
curl -s -u 'user:pass' 'https://site.com/wp-json/wp/v2/lessons/3609?context=edit'

All returned complete data. The lesson HTML includes vocabulary tables, dialogues, grammar notes — exactly what an AI agent needs to generate practice blueprints.

The session produced SENSEI_API_REFERENCE.md — a complete reference for LLM agents covering authentication, all endpoints with real response examples, content parsing tips, and error handling. Everything an agent needs to connect to a Sensei instance and read course content without repeating the discovery process.

  1. /wp-json/ is your route map. Every WordPress site publishes its complete API surface. Start there.

  2. sensei-internal/v1 is undocumented but stable. The course-structure endpoint is the only way to manage modules — they don’t exist as a standard REST resource.

  3. context=edit is mandatory. Without it, Sensei returns empty content for any lesson that requires enrollment.

  4. Hosting providers break things. Hostinger disables Application Passwords. One filter line fixes it.

  5. Trial and error works when you read the error messages. Each failed attempt gave a hint (”invalid_input” → wrong wrapper, “lessons_missing_title” → add titles). Three failures, one success.

*Discovery performed by Claude Code (Opus) using Playwright MCP for browser automation and Bash for API testing.

No posts

Read the original on ebuttodev.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.