Johnny Fekete
Johnny Fekete

Learn a Language with Claude

1,200 days of Duolingo, and I still couldn't order a coffee in Catalan.

So I stopped, and spent half an hour building the replacement instead. No code. Claude does the teaching, and a Notion workspace is where it keeps what it knows about me: what I've learned, and what I keep getting wrong. It reads those pages before every lesson and writes back to them after. That connection is the whole trick.

Everything I used is below, free to copy. Swap the language and it works for whatever you're learning.

1,200 Days of Duolingo Taught Me Nothing. Claude Fixed It.

Setup

Four steps, and the longest one is answering Claude's questions honestly.

  1. 1 Connect Notion in Claude's settings and approve the permissions.
  2. 2 Create a project, and paste the setup prompt into a chat inside it.
  3. 3 Paste the project instructions into the field of the same name.
  4. 4 Optional: run the widget script in Scriptable if you want the streak on your home screen.

The first prompt is the builder. Paste it into a chat inside your new project and it asks you five questions: which language, where you are today, why you're learning it, how many minutes a day you'll give it, and where in Notion to put everything. Then it writes the pages itself.

1 · Notion setup prompt
Raw
# Language Tutor — Notion Setup (for an AI agent)

You are an AI agent with a Notion connector. Build a Notion system for a
language student. Then use those pages every day to run short lessons and to
record progress.

---

## Step 1 — Ask the student first

Ask these questions before you create anything. Ask one at a time. Wait for each
answer.

1. Which language do you want to learn?
2. What is your level today? (Example: complete beginner, or "I read a little
   but I cannot speak".)
3. Why do you learn it? (Example: to talk to neighbours, for work, for a trip.)
4. How many minutes per day? (10 to 15 minutes works well.)
5. Where in Notion do I put the pages?

Use the answers in the templates below. Replace `LANGUAGE`, `LEVEL`, `GOAL` and
`MINUTES` with the real values.

---

## Step 2 — Build the hub and its four pages

```
📄 LANGUAGE                      <- the hub: agent instructions, profile, rules
   ├── 🗺️ Curriculum & Progress  <- the path, and where the student is on it
   ├── ⚠️ Mistakes Log           <- every error, newest at the top
   ├── 📚 Vocabulary Tracker     <- every word, with a confidence level
   └── 📅 Lesson Log             <- one line per session, and the streak
```

Four pages instead of one big page, because you read them separately. When a
lesson starts you pull the mistakes and the vocabulary, not the whole history of
the student's learning.

Set the emoji as the page **icon**. Do not put the emoji in the title.

---

## Step 3 — The hub page

Create a page titled `LANGUAGE`. It holds how to teach, not what has been
taught. Add these sections.

### 🤖 Agent Instructions

```markdown
# 🤖 Agent Instructions

> **Every session, start here:**
> 1. Fetch this page (ID: `HUB_PAGE_ID`) — profile and rules
> 2. Fetch Curriculum & Progress (ID: `CURRICULUM_PAGE_ID`) — what comes next
> 3. Fetch Mistakes Log (ID: `MISTAKES_PAGE_ID`) — errors for the warm-up
> 4. Fetch Vocabulary Tracker (ID: `VOCAB_PAGE_ID`) — words due for review
> Do not fetch the Lesson Log to start a lesson. That is history, and reading
> it makes the session slow. You only touch it when the lesson ends.
> Do not search Notion. These four fetches are enough.

**Student:**
- Level: LEVEL
- Goal: GOAL
- Session length: MINUTES minutes. Give one task at a time. Wait for the answer.

**Session format:**
1. Warm-up — a quick verb drill, then 2 or 3 sentences from the Mistakes Log.
2. New material — one curriculum lesson, with examples.
3. Exercises — 8 to 10 short tasks, one at a time.
4. Role-play — a short scene that uses the new material.
5. Close — summarise, then write all four pages (see Step 8).
```

### 📖 Quick Reference

One small block per session, newest first. One line per rule.

```markdown
# 📖 Quick Reference
## Session N — <topic>
- **<word or phrase>** = <meaning>
- ⚠️ **<trap>** — <the rule>
```

### 🎮 Verb Drill Bank

The verb list for the warm-up. Mark irregular verbs with ⚡.

```markdown
# 🎮 Verb Drill Bank
- <verb> (<meaning>) — form · form · form · form · form · form
```

---

## Step 4 — Curriculum & Progress

A child page of the hub. Title: `Curriculum & Progress`. Icon: 🗺️.

The path through the language, plus where the student is on it. Without this,
every session starts from zero and the student gets taught "hello" for the
fourth time.

A simple checklist. Mark a finished lesson with `[x]` and the date. Follow it in
order. Do not jump.

```markdown
# 🗺️ Curriculum
- [ ] Greetings and farewells
- [ ] Introductions
- [ ] Numbers, dates, time
- [ ] Polite phrases and asking for help
- [ ] Directions and transport
- [ ] Food and drink
- [ ] Shopping, prices, quantities
- [ ] Present tense — regular verbs
- [ ] Present tense — irregular verbs
- [ ] Daily routine
- [ ] Making plans
- [ ] Past tense
- [ ] Free conversation
```

Change the list for `LANGUAGE` and `LEVEL`.

Below the checklist, keep a short status block:

```markdown
# 📍 Where we are
- **Next theme:** <the topic for the next session>
- **Notes for next session:** <see below>
```

The note holds four parts:

- **Covered:** the topic of the session.
- **Strong:** what the student said correctly.
- **Fix:** the errors to drill next time, with the number of sessions.
- **Tutor error:** any correction the tutor got wrong. Never repeat it.

Keep the 3 most recent notes. Delete older notes.

---

## Step 5 — The Mistakes Log

A child page of the hub. Title: `Mistakes Log`. Icon: ⚠️.

Every error, with the correction. This is the page that matters most. It is what
makes tomorrow's lesson different from today's.

One line per error. The newest line goes at the **top**.

```markdown
**YYYY-MM-DD** | "<what the student said>" | **<correct form>** | <rule>
```

- Write `RECURRING (3rd)` when the same error comes back. A high count becomes
  the next warm-up.
- Add `✅` when the student fixes the error on a second try.
- Put the errors of one sentence on one line.
- If the tutor corrects something that was already correct, log it as
  `⚠️ TUTOR ERROR`. Never make that correction again.

---

## Step 6 — The Vocabulary Tracker

A child page of the hub. Title: `Vocabulary Tracker`. Icon: 📚.

Header:

```markdown
Confidence: 🔴 New · 🟡 Learning · 🟢 Known
Category: N=Noun, V=Verb, Adj=Adjective, Adv=Adverb, Ph=Phrase, Prep=Preposition
```

Then one table per session, with a heading `## Session N — <topic> (date)`:

| Word | Translation | Category | Confidence | Last Reviewed | Next Review |
| --- | --- | --- | --- | --- | --- |
| <word> | <meaning> | Ph | 🔴 New | 2026-01-01 | 2026-01-04 |

- New words start at 🔴 New.
- Move a word up when the student uses it correctly without help. Move it down
  after an error.
- Next Review is 3 days later for 🔴 and 🟡. Use `—` for 🟢.
- Add new tables at the **end**. Do not change the old tables.

---

## Step 7 — The Lesson Log

A child page of the hub. Title: `Lesson Log`. Icon: 📅.

What happened in each session, and the date. **The home-screen streak widget
reads this page**, so its top block has to stay exactly as written below.

Put the streak block at the very top of the page, as plain bulleted lines.
Keep these three field names exactly. The widget finds them with a text match,
and it only reads blocks at the top level of the page, so do not put them inside
a toggle, a column, or a callout.

```markdown
- **Last session:** YYYY-MM-DD
- **Session count:** 0
- **Streak count:** 0
```

Below that block, one line per session, newest at the **top**:

```markdown
**YYYY-MM-DD** | Session N | <topic> | <what happened in one sentence>
```

**Streak rule, after every session:**

- Read **Last session** and **Streak count** from the block above.
- Last session was yesterday: add 1 to the streak.
- Last session was today, or 2 or more days ago: set the streak to 1.
- Write the new **Last session** date, **Session count** and **Streak count**.
- Keep the streak honest. Do not add a day that did not happen.

---

## Step 8 — Save the page IDs

Notion gives you five page IDs. Write them into the Agent Instructions block, in
place of `HUB_PAGE_ID`, `CURRICULUM_PAGE_ID`, `MISTAKES_PAGE_ID` and
`VOCAB_PAGE_ID`.

Give the student the **Lesson Log** page ID separately, and tell them that is
the one the widget script needs.

---

## Step 9 — Update the pages after each lesson

Update at the **end** of the lesson, not during it.

| Page | How | Where |
| --- | --- | --- |
| Mistakes Log | insert content | start (newest first) |
| Vocabulary Tracker | insert content | end (new table) |
| Curriculum & Progress | replace text | in place, with old/new text pairs |
| Lesson Log | insert content, then replace text | new line at the start, streak block in place |

- Fetch a page before you write to it. The content can differ from your memory.
- If a text replacement does not match, fetch the page again, copy the exact
  text, and try again.

---

## Rules that make this work

- One task at a time. Never paste a list of exercises.
- Start with the student's own errors, not with new material.
- Record every word. Words that nobody records are words that nobody reviews.
- Let the student stop early. A short daily lesson beats a long weekly lesson.
- If the student says a correction is wrong, check the rule again before you
  insist.

You end up with a hub page and four pages under it. Curriculum & Progress is the path through the language and where you are on it. The Mistakes Log is every error with its correction, and it's the one that matters most, because it's what makes tomorrow's lesson different from today's. Vocabulary is the words you've met and how well you know each. The Lesson Log is one line per session, and the streak reads from there.

Four pages instead of one big page, because Claude reads them separately. A lesson pulls your mistakes and the words that are due, not the whole history of your learning.

The second prompt goes in the project's instructions field, so every new chat already knows how to teach you before you've typed anything.

2 · Project instructions
Raw
You act as a [LANGUAGE] teacher and help me get better at speaking [LANGUAGE].

I will come to you daily and ask for a quick lesson, and you generate it on the
spot: some new material, an explanation, and exercises. Some to rehearse the
previous days' material, some to practise the new things.

Before you generate anything, read my notes in Notion:
- Curriculum & Progress, for where I am and what comes next
- the Mistakes Log, for the errors to put in the warm-up
- the Vocabulary Tracker, for the words that are due for review

Don't read the Lesson Log to start a lesson. That one is history, and reading it
just makes the session slow.

Every day I want to spend [7-10] minutes on this. Don't show me everything in one
message. Guide me through the lesson one step at a time, and wait for my answer
before you move on.

Don't jump between topics. Follow the curriculum in order.

Keep the logs up to date: the words I already know, the new ones I don't, and how
well I know each one. Whenever I make a mistake, write it to the Mistakes Log so
I can rehearse it next time.

When the lesson ends, write all four pages, and add today's line to the Lesson
Log along with the new streak count. Do that at the end of the session, not
during it.

Swap out the parts in [BRACKETS]: the language, and the number of minutes you want a lesson to take.

That's the system. From then on you open a new chat in the project and ask for today's lesson. Claude reads your notes, walks you through it one step at a time, and writes the session back when you're done.

Putting the streak on your home screen

This part is optional, and iPhone only. Scriptable is a free app that runs small JavaScript files as home screen widgets. Paste the script below into a new one and it reads your Lesson Log in Notion, so the streak sits next to your app icons. Which turns out to be the only part of Duolingo I actually missed. Fair warning: iOS decides when widgets refresh, somewhere between 15 minutes and an hour, so the number won't flip the second you finish a lesson.

3 · Scriptable widget script
Raw
const NOTION_TOKEN = "ntn_" // create one in Notion's Developer tools > Connections
const PAGE_ID = "" // your Lesson Log page ID

const CONFIG = {
  lastSessionField: "Last session",
  sessionCountField: "Session count",
  streakCountField: "Streak count",
  dateFormat: "YYYY-MM-DD", // options: YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, DD.MM.YYYY, DD MMM YYYY, MMM DD, YYYY
  gradientDone:    { from: "#4CAF72", to: "#82C26E" },
  gradientPending: { from: "#F05454", to: "#FBAD52" },
  gradientLost:    { from: "#3B1F8C", to: "#F0A876" },
}

// ================================ //

async function fetchBlocks(blockId) {
  const req = new Request(`https://api.notion.com/v1/blocks/${blockId}/children?page_size=100`)
  req.headers = {
    "Authorization": `Bearer ${NOTION_TOKEN}`,
    "Notion-Version": "2022-06-28"
  }
  const res = await req.loadJSON()
 
  if (res.object !== "list") {
    console.log("❌ Unexpected response:")
    console.log(JSON.stringify(res))
    return null
  }
 
  return res.results
}
 
function normalizeText(text) {
  return text
    .toLowerCase()
    .replace(/[-:]/g, "")
    .replace(/\s+/g, " ")
    .trim()
}
 
function extractValue(text, fieldName) {
  const normalizedText = normalizeText(text)
  const normalizedField = normalizeText(fieldName)
 
  if (!normalizedText.startsWith(normalizedField)) return null
 
  return text
    .slice(text.toLowerCase().indexOf(fieldName.toLowerCase()) + fieldName.length)
    .replace(/^[\s:|-]+/, "")
    .trim()
}
 
// Extract plain text from a Notion rich_text array
function richTextToPlain(richTextArray) {
  return (richTextArray || []).map(t => t.plain_text).join("").trim()
}
 
// Parse a streak value like "1 day 🔥" or "3 days" → just the number
function parseStreakValue(val) {
  const match = val.match(/\d+/)
  return match ? parseInt(match[0]) : null
}
 
async function parseBlocks(blocks) {
  let lastSession = null
  let sessionCount = null
  let streakCount = null
 
  for (const block of blocks) {
    const type = block.type
 
    // ── Table blocks ──────────────────────────────────────────────
    if (type === "table") {
      console.log(`📊 Found table block, fetching rows...`)
      const rows = await fetchBlocks(block.id)
      if (!rows) continue
 
      for (const row of rows) {
        if (row.type !== "table_row") continue
        const cells = row.table_row?.cells || []
        if (cells.length < 2) continue
 
        const key = richTextToPlain(cells[0])
        const val = richTextToPlain(cells[1])
        if (!key || !val) continue
 
        console.log(`  Row: "${key}" → "${val}"`)
 
        if (normalizeText(key) === normalizeText(CONFIG.lastSessionField)) {
          lastSession = val
          console.log(`📅 Last session: ${lastSession}`)
        } else if (normalizeText(key) === normalizeText(CONFIG.sessionCountField)) {
          sessionCount = parseInt(val)
          console.log(`🔢 Session count: ${sessionCount}`)
        } else if (normalizeText(key) === normalizeText(CONFIG.streakCountField)) {
          streakCount = parseStreakValue(val)
          console.log(`🔥 Streak count: ${streakCount}`)
        }
      }
      continue
    }
 
    // ── Text-based blocks (paragraph, heading, bulleted_list_item, etc.) ──
    const richText = block[type]?.rich_text || []
    const text = richText.map(t => t.plain_text).join("")
 
    const lastSessionValue = extractValue(text, CONFIG.lastSessionField)
    if (lastSessionValue) {
      lastSession = lastSessionValue
      console.log(`📅 Last session: ${lastSession}`)
    }
 
    const sessionCountValue = extractValue(text, CONFIG.sessionCountField)
    if (sessionCountValue) {
      sessionCount = parseInt(sessionCountValue)
      console.log(`🔢 Session count: ${sessionCount}`)
    }
 
    const streakCountValue = extractValue(text, CONFIG.streakCountField)
    if (streakCountValue) {
      streakCount = parseStreakValue(streakCountValue)
      console.log(`🔥 Streak count: ${streakCount}`)
    }
  }
 
  if (!lastSession) console.log("⚠️ Could not find Last session")
  if (sessionCount == null) console.log("⚠️ Could not find Session count")
  if (streakCount == null) console.log("⚠️ Could not find Streak count")
 
  return { lastSession, sessionCount, streakCount }
}
 
function parseDate(dateStr) {
  const MONTHS = ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"]
  const fmt = CONFIG.dateFormat
  const s = dateStr.trim()
 
  if (fmt === "YYYY-MM-DD") {
    const [y, m, d] = s.split("-").map(Number)
    return { y, m, d }
  }
  if (fmt === "DD/MM/YYYY") {
    const [d, m, y] = s.split("/").map(Number)
    return { y, m, d }
  }
  if (fmt === "MM/DD/YYYY") {
    const [m, d, y] = s.split("/").map(Number)
    return { y, m, d }
  }
  if (fmt === "DD.MM.YYYY") {
    const [d, m, y] = s.split(".").map(Number)
    return { y, m, d }
  }
  if (fmt === "DD MMM YYYY") {
    const parts = s.split(" ")
    const d = Number(parts[0])
    const m = MONTHS.indexOf(parts[1].toLowerCase()) + 1
    const y = Number(parts[2])
    return { y, m, d }
  }
  if (fmt === "MMM DD, YYYY") {
    const parts = s.replace(",", "").split(" ")
    const m = MONTHS.indexOf(parts[0].toLowerCase()) + 1
    const d = Number(parts[1])
    const y = Number(parts[2])
    return { y, m, d }
  }
 
  console.log(`⚠️ Unknown date format: ${fmt}`)
  return null
}
 
function isDoneToday(lastSession) {
  const today = new Date()
  const y = today.getFullYear()
  const m = today.getMonth() + 1
  const d = today.getDate()
 
  console.log(`📆 Today is: ${y}-${String(m).padStart(2,"0")}-${String(d).padStart(2,"0")}`)
 
  const parsed = parseDate(lastSession)
  if (!parsed) {
    console.log("⚠️ Could not parse last session date")
    return false
  }
 
  const done = parsed.y === y && parsed.m === m && parsed.d === d
  console.log(done ? "✅ Session done today!" : "⏳ No session yet today")
  return done
}
 
function isStreakLost(lastSession) {
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  const parsed = parseDate(lastSession)
  if (!parsed) return false
  const last = new Date(parsed.y, parsed.m - 1, parsed.d)
  const diffDays = Math.floor((today - last) / (1000 * 60 * 60 * 24))
  const lost = diffDays > 1
  console.log(lost ? `💔 Streak lost — last session was ${diffDays} days ago` : "✅ Streak still alive")
  return lost
}
 
function buildWidget(done, lastSession, sessionCount, streakCount, streakLost) {
  const colors = done ? CONFIG.gradientDone : streakLost ? CONFIG.gradientLost : CONFIG.gradientPending
 
  const widget = new ListWidget()
 
  const gradient = new LinearGradient()
  gradient.locations = [0, 1]
  gradient.colors = [new Color(colors.from), new Color(colors.to)]
  widget.backgroundGradient = gradient
  widget.setPadding(16, 16, 16, 16)
 
  // Top left status
  const statusLabel = done ? "✅ Done today!" : streakLost ? "🌱 New streak" : "⚠️ Not done yet"
  const statusText = widget.addText(statusLabel)
  statusText.font = Font.boldSystemFont(13)
  statusText.textColor = new Color("#FFFFFF", 0.9)
  statusText.leftAlignText()
 
  widget.addSpacer(6)
 
  // Big streak number with fire emoji
  const streakDigits = String(streakCount ?? "–").length
  const streakFontSize = streakDigits < 2 ? 40 : streakDigits < 3 ? 36 : 32
  const streakText = widget.addText(streakLost ? `🌱 0` : `🔥 ${streakCount ?? "–"}`)
  streakText.font = Font.boldSystemFont(streakFontSize)
  streakText.textColor = new Color("#FFFFFF")
  streakText.leftAlignText()
 
  widget.addSpacer(4)
 
  // Total sessions
  const countText = widget.addText(`${sessionCount ?? "–"} total sessions`)
  countText.font = Font.systemFont(13)
  countText.textColor = new Color("#FFFFFF", 0.8)
  countText.leftAlignText()
 
  widget.addSpacer()
 
  // Last session date bottom
  const dateText = widget.addText(`Last: ${lastSession ?? "unknown"}`)
  dateText.font = Font.systemFont(11)
  dateText.textColor = new Color("#FFFFFF", 0.6)
  dateText.leftAlignText()
 
  return widget
}
 
async function run() {
  const blocks = await fetchBlocks(PAGE_ID)
  if (!blocks) return
 
  console.log(`✅ Got page content! Found ${blocks.length} blocks`)
 
  const { lastSession, sessionCount, streakCount } = await parseBlocks(blocks)
  const done = isDoneToday(lastSession)
  const streakLost = !done && isStreakLost(lastSession)
 
  const widget = buildWidget(done, lastSession, sessionCount, streakCount, streakLost)
 
  if (config.runsInWidget) {
    Script.setWidget(widget)
  } else {
    await widget.presentMedium()
  }
 
  Script.complete()
}
 
await run()

Two values go at the top, and both come from Notion.

  • Integration token. In Notion, open any page, hit the three dots, then Connections, then the developer portal. Create a connection, copy the token that starts with ntn_, and give it access to your hub page. The four pages under it inherit that access.
  • Page ID. The last part of your Lesson Log's URL. That's the page with the streak on it, not the hub.

Press play. If it errors, the message names the field that didn't match, and you can paste that straight back to Claude. Then long-press your wallpaper and add a Scriptable widget.

The CONFIG names match what the setup prompt writes into Notion. Rename a field there and you have to rename it here too.

I wrote up the reasoning behind all of it on my newsletter. Read that version →

If you build a version for your own language, I want to see it.