Examples

Build an AI Flashcard Generator with Exabase

Upload PDFs. Get study flashcards. This tutorial walks through how the AI Flashcard Generator example uses Exabase to store documents, extract content automatically, and persist generated flashcards as searchable memories.


What it does

A user uploads PDF files into an Exabase Base. Exabase processes each PDF and generates a summary automatically. When the user requests flashcards on a topic, the app:

  1. Lists all processed documents and their summaries

  2. Uses an LLM to pick the most relevant PDFs for the topic

  3. Extracts the full text from those PDFs

  4. Generates question/answer flashcard pairs

  5. Saves each flashcard back to Exabase as a memory

The result is a set of searchable, retrievable flashcards grounded in real source material.


How Exabase fits in

Exabase handles three things here: file storage, automatic content extraction, and memory persistence. Let's look at each.

Creating a workspace

Every demo session gets its own Base — an isolated container for files and memories:

const api = getExabase();
const base = await api.bases.create({
  title: "Flashcards demo",
});
const api = getExabase();
const base = await api.bases.create({
  title: "Flashcards demo",
});
const api = getExabase();
const base = await api.bases.create({
  title: "Flashcards demo",
});

Uploading files

The upload flow uses presigned URLs — the app gets a signed upload URL from Exabase, PUTs the file directly to storage, then registers it:

// 1. Get a presigned upload URL
const presigned = await api.uploads.getUrl(
  { filename: uniqueName, size },
  { baseId },
);

// 2. PUT the file bytes directly to storage
await fetch(presigned.url, {
  method: "PUT",
  headers: presigned.headers,
  body: buffer,
});

// 3. Register the file in Exabase
const created = await api.files.create(
  {
    attachment: { path, filename: uniqueName },
    parentId: asParentId(spaceId),
    mimeType: "application/pdf",
  },
  { baseId },
);
// 1. Get a presigned upload URL
const presigned = await api.uploads.getUrl(
  { filename: uniqueName, size },
  { baseId },
);

// 2. PUT the file bytes directly to storage
await fetch(presigned.url, {
  method: "PUT",
  headers: presigned.headers,
  body: buffer,
});

// 3. Register the file in Exabase
const created = await api.files.create(
  {
    attachment: { path, filename: uniqueName },
    parentId: asParentId(spaceId),
    mimeType: "application/pdf",
  },
  { baseId },
);
// 1. Get a presigned upload URL
const presigned = await api.uploads.getUrl(
  { filename: uniqueName, size },
  { baseId },
);

// 2. PUT the file bytes directly to storage
await fetch(presigned.url, {
  method: "PUT",
  headers: presigned.headers,
  body: buffer,
});

// 3. Register the file in Exabase
const created = await api.files.create(
  {
    attachment: { path, filename: uniqueName },
    parentId: asParentId(spaceId),
    mimeType: "application/pdf",
  },
  { baseId },
);

Once registered, Exabase automatically starts processing the PDF in the background — extracting text and generating a summary (caption). The created.stateProcessing field tells you whether that's still in progress or complete.

Listing documents and their summaries

After uploading PDFs, Exabase processes them in the background. The app lists resources and reads the auto-generated caption (summary) for each:

const res = await api.resources.filter(
  {
    kindExclude: [Kind.Folder, Kind.Notepad],
    limit: 80,
  },
  { baseId },
);

// Each resource has a .data.caption field once processing completes
const caption = resource.data?.caption;
const res = await api.resources.filter(
  {
    kindExclude: [Kind.Folder, Kind.Notepad],
    limit: 80,
  },
  { baseId },
);

// Each resource has a .data.caption field once processing completes
const caption = resource.data?.caption;
const res = await api.resources.filter(
  {
    kindExclude: [Kind.Folder, Kind.Notepad],
    limit: 80,
  },
  { baseId },
);

// Each resource has a .data.caption field once processing completes
const caption = resource.data?.caption;

These captions let the LLM decide which documents are relevant to the user's topic — without reading every full PDF upfront.

Letting the LLM pick relevant documents

With multiple PDFs uploaded, the app doesn't just use all of them. It passes the summaries to an LLM and asks it to select up to 3 documents that best match the user's topic:

const { object } = await generateObject({
  model: openai(modelId),
  schema: z.object({
    documentIds: z.array(z.string()).max(3),
  }),
  temperature: 0,
  system: "You pick the most relevant documents for flashcard generation.",
  prompt: `User focus: ${topic}\n\nCandidates:\n\n${candidates}`,
});
const { object } = await generateObject({
  model: openai(modelId),
  schema: z.object({
    documentIds: z.array(z.string()).max(3),
  }),
  temperature: 0,
  system: "You pick the most relevant documents for flashcard generation.",
  prompt: `User focus: ${topic}\n\nCandidates:\n\n${candidates}`,
});
const { object } = await generateObject({
  model: openai(modelId),
  schema: z.object({
    documentIds: z.array(z.string()).max(3),
  }),
  temperature: 0,
  system: "You pick the most relevant documents for flashcard generation.",
  prompt: `User focus: ${topic}\n\nCandidates:\n\n${candidates}`,
});

Only then does the app download and extract the full text of the selected PDFs. This two-pass approach (summaries first, full text second) keeps token usage manageable even with large document collections.

Saving flashcards as memories

Once the LLM generates flashcard pairs, each one is stored as an Exabase memory with infer: false (store verbatim, no additional processing):

await api.memories.create(
  {
    source: "text",
    content: "What is photosynthesis?\nThe process by which plants convert sunlight into energy.",
    infer: false,
  },
  { baseId },
);
await api.memories.create(
  {
    source: "text",
    content: "What is photosynthesis?\nThe process by which plants convert sunlight into energy.",
    infer: false,
  },
  { baseId },
);
await api.memories.create(
  {
    source: "text",
    content: "What is photosynthesis?\nThe process by which plants convert sunlight into energy.",
    infer: false,
  },
  { baseId },
);

This means flashcards are durable and searchable. A future version could retrieve them semantically — "find me cards about biology" — using api.memories.search.


Key Exabase APIs used in this example:

API

Purpose

bases.create

Create an isolated workspace

uploads.getUrl

Get a presigned URL for direct file upload

files.create

Register an uploaded file and trigger background processing

resources.filter

List uploaded files and their processing state

resources.get

Fetch file details including download URL

memories.create

Store each flashcard as a searchable memory


Run it yourself

git clone https://github.com/futurebrowser/exabase-examples.git
cd

git clone https://github.com/futurebrowser/exabase-examples.git
cd

git clone https://github.com/futurebrowser/exabase-examples.git
cd

Add EXABASE_API_KEY and OPENAI_API_KEY to .env.local, open http://localhost:3000, and click New base to start.


FAQ

Why store flashcards as memories instead of in a database?

Memories are semantically searchable out of the box. You get hybrid keyword + vector search without managing embeddings or a separate search index.


Why infer: false?

By default, Exabase can run inference on new memories (extracting metadata, generating embeddings from richer context). Here the flashcard content is already clean and final, so we skip that step and store it as-is.


Can I use a model other than OpenAI?

The example uses the AI SDK, so swapping to another provider (Anthropic, Google, etc.) is a model config change. Exabase itself is model-agnostic — it's the storage and retrieval layer.


What happens if the PDF is still processing when I try to generate flashcards?

The app checks each resource's stateProcessing field. Only documents with stateProcessing: "completed" are included as candidates. If nothing is ready yet, the app returns an error asking you to wait. Processing typically takes a few seconds per PDF.


How does the app retrieve flashcards for display?

Flashcards are stored as memories, so the app uses api.memories.search to list them, then filters for entries that look like question/answer pairs. It detects the format by checking if the content has exactly two lines where the first ends with a question mark. This means flashcards are just memories with a convention — no special Exabase type needed.


What file types are supported?

The example only accepts PDFs, but Exabase supports other file types too. The PDF restriction is in the app code (isPdfUpload check), not in Exabase. You could extend this to support other document types by adjusting the upload validation and text extraction logic.