Note
Unofficial community project. Not affiliated with, endorsed or authorized by Duolingo.
A TypeScript package that provides a client library for the Duolingo API, a
command line interface (duolingo) and an
MCP server for LLM agents (e.g. Claude).
Built natively in TypeScript against the unofficial Duolingo REST API — no third-party Duolingo library dependency.
The CLI, the MCP server and the library all require a Duolingo JWT token for authentication.
- Log in to duolingo.com in your browser.
- Open the browser developer console (F12 → Console tab).
- Run:
document.cookie.match(new RegExp('(^| )jwt_token=([^;]+)'))[0].slice(11);
- Copy the output — that is your JWT token.
Note: JWT tokens expire. If you get authentication errors, repeat the steps above.
npm install -g @udondan/duolingo
duolingo --helpOr run it without installing: npx -y @udondan/duolingo --help.
The CLI and the MCP server are also available as the Docker image
udondan/duolingo (amd64 and
arm64), no Node.js needed. See With Docker.
The CLI reads DUOLINGO_USERNAME and DUOLINGO_JWT from the environment. You
can also put them in ~/.config/duolingo/config:
mkdir -p ~/.config/duolingo
cat > ~/.config/duolingo/config <<'EOF'
DUOLINGO_USERNAME=your_username
DUOLINGO_JWT=your_jwt_token
EOF
chmod 0600 ~/.config/duolingo/configThe file is only read when no other user can access it (mode 0600).
Environment variables take precedence over the file.
Output is JSON by default. Use --format markdown (or -f markdown) for the
same readable text the MCP tools return.
duolingo streak
duolingo --format markdown progress es
duolingo known-words fr --username someone
duolingo leaderboard --unit month| Command | Description |
|---|---|
user [-u name] |
Profile: name, bio, location, followers, languages |
settings |
Account settings |
streak [-u name] |
Current streak, daily goal and longest streak |
xp |
XP earned and lessons completed today |
languages [-u name] [-a] |
Languages being learned (-a for abbreviations) |
courses [-u name] |
All courses, including Math, Chess and Music |
friends |
Users the authenticated user follows |
calendar [-u name] |
Recent activity of the current course |
leaderboard [--unit week|month] |
Friends ranked by XP |
shop |
Shop items and prices |
health |
Hearts and refill status |
currencies |
Gem and lingot balances |
streak-goal |
Current streak goal and checkpoints |
language <name> [-u name] |
Level, points and streak of a language (e.g. French) |
progress <abbr> |
Detailed progress in a language (e.g. fr) |
known-topics <abbr> [-u name] |
Learned topics |
unknown-topics <abbr> [-u name] |
Topics not learned yet |
golden-topics <abbr> [-u name] |
Fully mastered topics |
reviewable-topics <abbr> [-u name] |
Learned topics that need review |
known-words <abbr> [-u name] |
Words learned |
learned-skills <abbr> [-u name] |
Learned skills in learning order |
voices [abbr] |
Text-to-speech voices |
audio-url <word> [abbr] [--voice v] [--no-random] |
Pronunciation audio URL |
language-name <abbr> [-u name] |
Full name of a language (fr → French) |
abbreviation <name> [-u name] |
Abbreviation of a language (French → fr) |
mcp |
Start the MCP server (stdio) |
Errors go to stderr and the CLI exits with code 1.
Arguments after the image name are passed to the CLI. -e NAME without a
value takes the variable from your shell:
docker run --rm -e DUOLINGO_USERNAME -e DUOLINGO_JWT udondan/duolingo streakAs an alias, the image behaves like an installed duolingo:
alias duolingo='docker run --rm -e DUOLINGO_USERNAME -e DUOLINGO_JWT udondan/duolingo'
duolingo --format markdown progress esThe config file ~/.config/duolingo/config is not visible inside the
container, so pass the credentials as environment variables.
Option A — run directly with npx (no install needed):
DUOLINGO_USERNAME=your_username DUOLINGO_JWT=your_jwt npx -y @udondan/duolingo mcpOption B — install globally:
npm install -g @udondan/duolingo
DUOLINGO_USERNAME=your_username DUOLINGO_JWT=your_jwt duolingo mcpOption C — run the Docker image:
docker run --rm -i -e DUOLINGO_USERNAME -e DUOLINGO_JWT udondan/duolingoWithout arguments, the image starts the MCP server. -i keeps stdin open,
which the server uses to talk to the client.
Option D — clone and build from source:
git clone https://github.com/udondan/duolingo.git
cd duolingo
npm install && npm run build
node dist/cli.js mcpAdd to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"duolingo": {
"command": "npx",
"args": ["-y", "@udondan/duolingo", "mcp"],
"env": {
"DUOLINGO_USERNAME": "your_username",
"DUOLINGO_JWT": "your_jwt_token"
}
}
}
}With Docker instead of npx, the entry looks like this. -e NAME without a
value passes the variable from env to the container:
{
"mcpServers": {
"duolingo": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e",
"DUOLINGO_USERNAME",
"-e",
"DUOLINGO_JWT",
"udondan/duolingo"
],
"env": {
"DUOLINGO_USERNAME": "your_username",
"DUOLINGO_JWT": "your_jwt_token"
}
}
}
}claude mcp add duolingo -- npx -y @udondan/duolingo mcpThen set the environment variables before starting Claude Code:
export DUOLINGO_USERNAME="your_username"
export DUOLINGO_JWT="your_jwt_token"With the Docker MCP Toolkit of Docker Desktop, the server runs as a container behind the MCP Gateway. The gateway starts the container on demand and passes its tools on to the connected clients. The credentials are stored as secrets in Docker Desktop instead of in plain text in the client configuration.
Requires Docker Desktop with the MCP Toolkit enabled.
-
Download the server definition
docker-mcp.ymland add it to a Toolkit profile. The Toolkit only reads such files from its catalogs directory:mkdir -p ~/.docker/mcp/catalogs curl -fsSL https://raw.githubusercontent.com/udondan/duolingo/main/docker-mcp.yml -o ~/.docker/mcp/catalogs/duolingo.yml # new profile "duolingo" ... docker mcp profile create --name duolingo --id duolingo --server "file://$HOME/.docker/mcp/catalogs/duolingo.yml" # ... or add it to an existing profile docker mcp profile server add default --server "file://$HOME/.docker/mcp/catalogs/duolingo.yml"
The profile keeps the image at the version it had when it was added. Run
profile server addagain to update. -
Store the credentials as secrets (see Getting Your JWT Token):
docker mcp secret set duolingo.username=your_username docker mcp secret set duolingo.jwt=your_jwt_token
-
Connect a client to the profile:
docker mcp client connect claude-desktop --profile duolingo
docker mcp client connect --helplists more clients such asclaude-code,cursororvscode. This also works in Docker Desktop under MCP Toolkit.
docker mcp gateway run --profile duolingo --dry-run --verbose checks the setup
without starting a client. The output should end with duolingo: (N tools).
All tools are read-only — the server never modifies your Duolingo account.
| Tool | Description |
|---|---|
duolingo_get_user_info |
Profile: username, name, bio, location, avatar, followers, learning language |
duolingo_get_settings |
Notification and account settings |
duolingo_get_streak_info |
Current streak, longest streak, daily goal, extended today |
duolingo_get_daily_xp_progress |
XP goal, XP earned today, lessons completed today |
duolingo_get_languages |
Languages being learned (full names or abbreviations) |
duolingo_get_courses |
All courses including Math, Chess, and Music with XP per course |
duolingo_get_friends |
Users the authenticated user follows, with total XP |
duolingo_get_calendar |
Recent activity calendar for the current course (~last 2 weeks) |
duolingo_get_leaderboard |
Authenticated user's friends sorted by XP for week or month |
duolingo_get_shop_items |
Full shop catalogue with prices and item types |
duolingo_get_health |
Current hearts count, max hearts, refill timing |
duolingo_get_currencies |
Gem and lingot balances |
duolingo_get_streak_goal |
Current streak goal with upcoming checkpoints |
| Tool | Description |
|---|---|
duolingo_get_language_details |
Level, points, streak for a specific language |
duolingo_get_language_progress |
Detailed progress: level %, points to next level, fluency |
duolingo_get_known_topics |
Learned topic/skill names |
duolingo_get_unknown_topics |
Not-yet-learned topics |
duolingo_get_golden_topics |
Fully mastered topics (strength = 1.0) |
duolingo_get_reviewable_topics |
Learned but not fully mastered topics |
duolingo_get_known_words |
Set of known words for a language |
duolingo_get_learned_skills |
Full skill objects sorted by learning order |
duolingo_get_language_voices |
Available TTS voice names for a language |
duolingo_get_audio_url |
Pronunciation audio URL for a word |
| Tool | Description |
|---|---|
duolingo_get_language_from_abbr |
Convert language abbreviation to full name (e.g. fr → French) |
duolingo_get_abbreviation_of |
Convert full language name to abbreviation (e.g. French → fr) |
npm install @udondan/duolingoimport { DuolingoClient } from '@udondan/duolingo';
const client = new DuolingoClient('your_username', 'your_jwt_token');
// Get all courses including Math, Chess, and Music
const userData = await client.getUserData();
const userId = userData.id;
const v2 = await client.getUserDataV2(userId);
for (const course of v2.courses) {
console.log(`${course.subject}: ${course.xp} XP`);
}
// Get streak info
const streak = v2.streak;
const longestStreak = v2.streakData.longestStreak?.length;
// Get friends
const friends = await client.getFollowing(userId);
for (const friend of friends) {
console.log(`${friend.username}: ${friend.totalXp} XP`);
}
// Get shop items
const items = await client.getShopItems();
const streakFreeze = items.find((i) => i.id === 'streak_freeze');
// Get hearts
const health = await client.getHealth();
console.log(`Hearts: ${health.hearts}/${health.maxHearts}`);Creates a new client instance. Results are cached per instance.
const client = new DuolingoClient('your_username', 'your_jwt_token');// Legacy API — returns language_data with skills, calendar, etc.
// Required for: language details, topics, known words, learned skills
const userData = await client.getUserData(username?);
// 2023-05-23 API — returns all courses (language + math/chess/music),
// richer streak data, subscriber level, gems, health
const userId = userData.id;
const v2 = await client.getUserDataV2(userId);
// Resolve a username to a numeric user ID
const id = await client.getUserIdByUsername('someuser');const v2 = await client.getUserDataV2(userId);
// All courses
v2.courses; // DuolingoCourse[]
// Filter by subject
const langCourses = v2.courses.filter((c) => c.subject === 'language');
const mathCourses = v2.courses.filter((c) => c.subject === 'math');
const chessCourses = v2.courses.filter((c) => c.subject === 'chess');
const musicCourses = v2.courses.filter((c) => c.subject === 'music');Each course has: id, subject, topic, xp, fromLanguage.
Language courses also have: learningLanguage, title, authorId.
const progress = await client.getUserDataById(userId, [
'xpGoal',
'xpGains',
'streakData',
]);
// progress.xpGoal — daily XP goal
// progress.xpGains — array of { xp, skillId, time } for recent lessons
// progress.streakData.updatedTimestamp — last streak update// People this user follows (= friends)
const following = await client.getFollowing(userId);
// following[0].username, .totalXp, .userScore?.score (weekly XP), .isFollowedBy
// People who follow this user
const followers = await client.getFollowers(userId);// Full shop catalogue (read-only)
const items = await client.getShopItems();
// items[0].id, .name, .type, .price, .currencyType, .lastUsedDate
// Hearts / health (authenticated user only)
const health = await client.getHealth();
// health.hearts, .maxHearts, .eligibleForFreeRefill, .secondsUntilNextHeartSegment
// Gem and lingot balances (authenticated user only)
const { gems, lingots } = await client.getCurrencies();// Current streak goal with checkpoints
const goal = await client.getStreakGoalCurrent();
// goal.hasActiveGoal, goal.streakGoal.lastCompleteGoal, .checkpoints, .nextSelectedGoal
// Available next goal options
const options = await client.getStreakGoalNextOptions();// Discover available voices for a language
const voices = await client.getLanguageVoices('es'); // ['beaes', 'juniores', ...]
// Build an audio URL
const url = await client.buildAudioUrl('hola', 'es');
const urlWithVoice = await client.buildAudioUrl('hola', 'es', 'beaes');import {
DuolingoClient,
DuolingoAuthError,
DuolingoNotFoundError,
DuolingoCaptchaError,
DuolingoClientError,
} from '@udondan/duolingo';
try {
const data = await client.getUserData('someuser');
} catch (err) {
if (err instanceof DuolingoAuthError) {
// JWT expired — extract a new one from the browser
} else if (err instanceof DuolingoNotFoundError) {
// User does not exist
} else if (err instanceof DuolingoCaptchaError) {
// Duolingo blocked the request — try again later
} else if (err instanceof DuolingoClientError) {
// Other API error
}
}git clone https://github.com/udondan/duolingo.git
cd duolingo
npm install
# Build
npm run build
# Run tests (unit + integration)
npm test
# Integration tests require credentials:
export DUOLINGO_USERNAME="your_username"
export DUOLINGO_JWT="your_jwt_token"
npm test
# Type-check without building
npm run typecheck
# Run MCP server in dev mode (no build step)
npm run devsrc/
├── index.ts # Library entry point — exports client, types, errors
├── cli.ts # `duolingo` binary
├── mcp.ts # MCP server (stdio transport), started by `duolingo mcp`
├── version.ts # Package version
├── cli/
│ ├── program.ts # CLI commands (commander)
│ └── config-file.ts # ~/.config/duolingo/config loader
├── client/
│ ├── duolingo.ts # DuolingoClient — all API methods
│ ├── types.ts # TypeScript interfaces for all API responses
│ └── errors.ts # Custom error classes
├── views/
│ ├── account.ts # Account queries: data + markdown
│ ├── language.ts # Language queries: data + markdown
│ ├── utils.ts # Language name/abbreviation lookups
│ └── view.ts # View type and rendering
└── tools/
├── account.ts # Account tools
├── language.ts # Language tools
├── shop.ts # Utility tools
└── helpers.ts # Shared utilities (error handling, Zod schemas)
| Endpoint | API Version | Used For |
|---|---|---|
/users/<username> |
Legacy | User profile, language data, skills, calendar |
/2023-05-23/users/{id} |
Current | Courses (all subjects), streak, health, gems |
/2023-05-23/friends/users/{id}/following |
Current | Friends, leaderboard |
/2023-05-23/shop-items |
Current | Shop catalogue |
/users/{id}/streak-goal-current |
Current | Streak goals |
/2017-06-30/sessions |
Current | TTS voice discovery |
MIT — see LICENSE
This is an unofficial, independent open-source project and is not affiliated with Duolingo, Inc. Duolingo is a trademark of Duolingo, Inc. The name is used only to describe compatibility. The API used is not public and may change at any time. Use at your own risk and in accordance with the Duolingo Terms and Conditions.