[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an Anki-Alternative Flashcard App with AI Card Generation

Build an Anki-Alternative Flashcard App with AI Card Generation

Build a flashcard app with SM-2 spaced repetition, AI card generation from notes using Claude structured outputs, keyboard-driven study sessions, and Anki-compatible export.

Data & AI#anthropic#claude#llm#tool-use#streaming
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 2 skills

Avg quality 92/100·All SAFE
$

The Problem

You are a med student with thousands of Anki cards. Making a card by hand takes longer than reading the paragraph it comes from, so the deck always lags behind the lectures. You want a web app where you paste lecture notes, get reviewable cards back in seconds, study them with the keyboard, and still be able to move your cards into Anki when you need to.

The Solution

A Next.js app with Postgres, managed through the prisma skill. The scheduler is SM-2, small enough to keep in one tested function. Cards are generated by Claude through the anthropic-sdk skill, using structured outputs so the response is always valid JSON that matches your schema. Export writes the tab-separated text format that Anki's own importer reads.

Step-by-Step Walkthrough

1. Schema and client

Prisma 7 no longer puts the connection URL in schema.prisma, and the client needs a driver adapter. Install them pinned to the 7 line, because the npm latest tag of prisma currently points at an 8.0 release candidate that does not match @prisma/client 7: npm install -D prisma@7 and npm install @prisma/client@7 @prisma/adapter-pg@7. Then set up three files.

prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id       String         @id @default(cuid())
  email    String         @unique
  decks    Deck[]
  sessions StudySession[]
}

model Deck {
  id        String         @id @default(cuid())
  userId    String
  user      User           @relation(fields: [userId], references: [id])
  name      String
  cards     Card[]
  sessions  StudySession[]
  createdAt DateTime       @default(now())

  @@index([userId])
}

model Card {
  id          String       @id @default(cuid())
  deckId      String
  deck        Deck         @relation(fields: [deckId], references: [id], onDelete: Cascade)
  front       String
  back        String
  tags        String[]     @default([])
  reviews     CardReview[]
  repetitions Int          @default(0)
  easeFactor  Float        @default(2.5)
  interval    Int          @default(0)
  nextReview  DateTime     @default(now())
  createdAt   DateTime     @default(now())

  @@index([deckId, nextReview])
}

model CardReview {
  id        String   @id @default(cuid())
  cardId    String
  card      Card     @relation(fields: [cardId], references: [id], onDelete: Cascade)
  sessionId String?
  quality   Int
  timeMs    Int?
  createdAt DateTime @default(now())
}

model StudySession {
  id           String   @id @default(cuid())
  userId       String
  user         User     @relation(fields: [userId], references: [id])
  deckId       String
  deck         Deck     @relation(fields: [deckId], references: [id], onDelete: Cascade)
  cardsStudied Int      @default(0)
  correctCount Int      @default(0)
  createdAt    DateTime @default(now())
}
typescript
// prisma.config.ts
import { defineConfig, env } from 'prisma/config'

export default defineConfig({
  schema: 'prisma/schema.prisma',
  datasource: { url: env('DATABASE_URL') },
})
typescript
// lib/db.ts
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../generated/prisma/client'

export const prisma = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
})

Run npx prisma migrate dev against a database on 127.0.0.1. There is no card "status" column: new, learning or mature follows from repetitions and interval, so it cannot drift out of sync.

2. SM-2 scheduling

The scheduler is a pure function, so you can test it without a database. The study session row is created once when the user starts studying, and every review increments it inside the same transaction as the card update.

typescript
// lib/sm2.ts
import { prisma } from './db'

export type Quality = 0 | 1 | 2 | 3 | 4 | 5

export function nextState(
  s: { repetitions: number; easeFactor: number; interval: number },
  quality: Quality,
  now = new Date(),
) {
  let { repetitions, interval } = s
  if (quality < 3) {
    repetitions = 0 // lapse: start the ladder again
    interval = 1
  } else {
    interval = repetitions === 0 ? 1 : repetitions === 1 ? 6 : Math.round(interval * s.easeFactor)
    repetitions += 1
  }
  const easeFactor = Math.max(
    1.3,
    s.easeFactor + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02),
  )
  const nextReview = new Date(now.getTime() + interval * 86_400_000)
  return { repetitions, interval, easeFactor, nextReview }
}

export async function submitReview(
  cardId: string, sessionId: string, quality: Quality, timeMs?: number,
) {
  const card = await prisma.card.findUniqueOrThrow({ where: { id: cardId } })
  const next = nextState(card, quality)
  await prisma.$transaction([
    prisma.card.update({ where: { id: cardId }, data: next }),
    prisma.cardReview.create({ data: { cardId, sessionId, quality, timeMs } }),
    prisma.studySession.update({
      where: { id: sessionId },
      data: {
        cardsStudied: { increment: 1 },
        correctCount: { increment: quality >= 3 ? 1 : 0 },
      },
    }),
  ])
  return next
}

Rating the same card 4, 4, 4, 2, 5 gives intervals of 1, 6 and 15 days, then a lapse back to 1 day with the ease factor down to 2.18. Put that sequence in a unit test. SM-2 is a 1990s algorithm; Anki itself now offers the newer FSRS scheduler, and the ts-fsrs package implements it if you want to swap nextState later.

3. AI card generation

Ask Claude for a JSON schema instead of "return only JSON" and regex-parsing the reply. With output_config.format the response conforms to the schema, and the SDK's zodOutputFormat helper turns a zod schema into the format the API accepts, so the same object also gives you the runtime check.

typescript
// lib/ai-cards.ts
import Anthropic from '@anthropic-ai/sdk'
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'
import { z } from 'zod'
import { prisma } from './db'

const client = new Anthropic() // reads ANTHROPIC_API_KEY
const Cards = z.object({
  cards: z.array(z.object({
    front: z.string(),
    back: z.string(),
    tags: z.array(z.string()),
  })),
})

const style = {
  basic: 'Write questions that test understanding, not recall of wording.',
  cloze: 'Write fill-in-the-blank cards: the front has one [...] gap, the back is the missing term.',
  definition: 'Front is a term, back is a one-sentence definition.',
}

export async function generateCards(
  text: string, deckId: string, count = 10, kind: keyof typeof style = 'basic',
) {
  const response = await client.messages.create({
    model: 'claude-sonnet-5-5',
    max_tokens: 4096,
    messages: [{
      role: 'user',
      content: `Write ${count} flashcards from the notes below. ${style[kind]}\n\n<notes>\n${text}\n</notes>`,
    }],
    output_config: {
      format: zodOutputFormat(Cards), // sends a json_schema the API accepts
    },
  })
  if (response.stop_reason === 'max_tokens') throw new Error('Output cut off; ask for fewer cards')
  const block = response.content.find((b) => b.type === 'text')
  const { cards } = Cards.parse(JSON.parse(block?.type === 'text' ? block.text : '{}'))
  await prisma.card.createMany({ data: cards.map((c) => ({ deckId, ...c })) })
  return cards
}

Show the generated cards in a review list before saving them. The model can misread a table or invent a plausible fact, and a wrong card you drill for months is worse than no card. In production, return the cards to the UI first and call createMany only for the ones the student keeps. Model names change; check the current list on the Anthropic models page and keep the name in one constant.

4. Study API

The route handlers below use the Next.js 15+ signature, where params is a promise.

typescript
// app/api/study/[deckId]/route.ts
import { prisma } from '@/lib/db'
import { submitReview, type Quality } from '@/lib/sm2'

type Ctx = { params: Promise<{ deckId: string }> }
export async function GET(req: Request, { params }: Ctx) {
  const { deckId } = await params
  const limit = Math.min(Number(new URL(req.url).searchParams.get('limit') ?? 20), 100)
  const cards = await prisma.card.findMany({
    where: { deckId, nextReview: { lte: new Date() } },
    orderBy: { nextReview: 'asc' },
    take: limit,
    select: { id: true, front: true, back: true, tags: true },
  })
  return Response.json({ cards })
}

export async function PUT(_req: Request, { params }: Ctx) {
  const { deckId } = await params
  const userId = await currentUserId() // your auth layer: returns the signed-in user's id
  const session = await prisma.studySession.create({ data: { userId, deckId } })
  return Response.json({ sessionId: session.id })
}

export async function POST(req: Request) {
  const { cardId, sessionId, quality, timeMs } = await req.json()
  if (![0, 1, 2, 3, 4, 5].includes(quality)) {
    return Response.json({ error: 'quality must be 0-5' }, { status: 400 })
  }
  return Response.json(await submitReview(cardId, sessionId, quality as Quality, timeMs))
}

Check that the deck and session belong to the signed-in user in currentUserId() before every write; the sample trusts the client's ids.

5. Keyboard-driven study screen

Keep the keys few enough to learn in a minute: Space flips the card, then 1, 2, 3, 4 rate it. Map them to SM-2 qualities: 1 (Again) sends quality 1, a lapse; 2 (Hard) sends 3; 3 (Good) sends 4; 4 (Easy) sends 5. Attach one keydown listener in the study component, and record timeMs from when the card appears to when the rating key is pressed.

6. Export to Anki

Anki's importer reads plain text with #key:value header lines (separator, html, tags column, deck). That is documented, stable and easy to get right, unlike hand-writing the SQLite schema inside an .apkg file. Use quoted, tab-separated fields so newlines in a card survive.

typescript
// lib/anki.ts
import { prisma } from './db'

const cell = (s: string) => `"${s.replaceAll('"', '""')}"`

export async function exportDeckForAnki(deckId: string): Promise<string> {
  const deck = await prisma.deck.findUniqueOrThrow({
    where: { id: deckId }, include: { cards: true },
  })
  const rows = deck.cards.map((c) =>
    [cell(c.front), cell(c.back), cell(c.tags.join(' '))].join('\t'))
  return [
    '#separator:tab',
    '#html:false',
    '#tags column:3',
    `#deck:${deck.name}`,
    ...rows,
  ].join('\n')
}

Serve it as text/plain with a .txt download name, then in Anki use File, Import. Review history does not travel with this format. Importing from Anki works the same way in reverse: export notes as text from Anki and parse the tab-separated rows into Card records.

Real-World Example

Maya, a second-year medical student, pastes the biochemistry lecture on oxidative phosphorylation into the generator and asks for 15 cards. She deletes four that are too vague, edits one with a wrong number, and saves ten. The next morning her queue shows those ten as due immediately, because new cards start with nextReview at creation time. She rates them with the keyboard in about five minutes. After a good-rated first review, a card returns in one day, then six, then about two weeks. Cards she forgets come back the next day with a lower ease factor, so they are shown more often than the ones she knows. On the weekend she exports her deck as a text file and imports it into Anki on her tablet.

Related Skills

  • anthropic-sdk - Messages API and structured outputs for card generation
  • prisma - schema, migrations and the typed client for decks, cards and reviews

Related use cases