Build a Dutch Conversation Partner

Build a real-time voice AI Dutch tutor using OpenAI Realtime API and WebRTC.

Introduction

30 Second Summary

Language learning apps are great for vocabulary flashcards, but they rarely let you practice actually speaking. Having a real conversation is the fastest way to improve, but finding a patient partner who corrects your mistakes without making you feel awkward is tough.

In this project, you will build a voice-based Dutch conversation partner that runs in your browser. You will speak Dutch into your microphone, and an AI tutor will respond in real-time with natural speech, correct your pronunciation, and guide you through real-life scenarios like ordering at a café.

What You'll Build

A browser-based Dutch conversation partner powered by OpenAI's Realtime API that uses WebRTC to stream voice between your microphone and an AI tutor in real-time.

By the end of this project, you'll have:

  • A Node.js server that securely connects your browser to OpenAI's voice-to-voice AI.
  • A web interface with a real-time transcription panel showing what you said and what the AI heard.
  • A custom system prompt that makes the AI correct pronunciation, teach Dutch expressions, and simulate real-life scenarios.
  • Secret Mission: An idiom challenge mode that teaches you authentic Dutch expressions through natural conversation.

Are there any prerequisites?

You should be comfortable using the terminal to run commands. No JavaScript experience is required. All code is provided and explained step by step.

You will also need an OpenAI account with at least $5 of credit (Tier 1 or higher). The Realtime API is not available on the free tier.

Before We Start

Before We Start...

Install Your Tools

To build a voice-based Dutch conversation partner, you need three tools working together.

First, you need Node.js to run your server. Second, you need VS Code to edit your files. Third, you need an OpenAI API account to access the Realtime API that powers the voice AI.

In this step, get ready to:
  • Install Node.js and verify it works in your terminal.
  • Install VS Code as your code editor.
  • Create an OpenAI API account with credit and an API key.
Download and install Node.js

Node.js is a runtime that lets you run JavaScript outside of a web browser. Your server will use it to handle voice connections between your browser and OpenAI.

  • Open your browser and go to nodejs.org.
  • Download the LTS (Long Term Support) version for your operating system.
  • Run the installer and accept the default options.

Once the installer finishes, verify Node.js is working by checking its version in your terminal.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

✔️ I see a version number

You should see something like v24.16.0 (the exact number may differ). Node.js is ready to go.

ⓧ Command not found

If you see an error like "node is not recognized", the installer may not have finished correctly. Try these steps:

  • Close PowerShell completely and reopen it. The installer updates your system PATH, but only new terminal windows pick up the change.
  • If it still does not work, re-download the LTS installer from nodejs.org and run it again. Make sure you check the box to add Node.js to your PATH during installation.
Download and install VS Code

VS Code is the code editor you will use to create and edit all of your project files.

  • Open your browser and go to code.visualstudio.com.
  • Click the large download button for Windows.
  • Run the installer and accept the default options.
  • When the installer asks about additional tasks, check the box for Add to PATH. This lets you open VS Code from the terminal later.

Why VS Code?

VS Code is free, lightweight, and works well for JavaScript projects. It gives you a file explorer, built-in terminal, and syntax highlighting all in one window.

Create your OpenAI API account

The OpenAI Realtime API is what powers the voice conversation. It requires a funded account with at least $5 of credit.

  • Open your browser and go to platform.openai.com.
  • Sign up for a new account (or sign in if you already have one).
  • Navigate to Settings in the left sidebar, then click Billing.
  • Add at least $5 of credit using a payment method. This is the minimum required to access the Realtime API.

Why do I need to add credit?

The OpenAI Realtime API does not have a free tier. It requires a Tier 1 account or higher, which means adding at least $5 of prepaid credit. Each 10-minute conversation costs approximately $1-3.

Now create an API key so your server can authenticate with OpenAI.

  • In the left sidebar, click API Keys.
  • Click Create new secret key.
  • Give it a name like dutch-partner and click Create secret key.
  • Copy the key immediately and paste it somewhere safe (a text file or password manager). You will not be able to see the full key again after closing this dialog.

Keep your API key private

Your API key is like a password. Never share it publicly, commit it to a Git repository, or paste it into a website. You will store it in a local file that stays on your machine.

Can't find the API Keys page?

Make sure you are on platform.openai.com (the developer platform), not chatgpt.com. The API Keys section lives under Settings in the platform sidebar.

Help me find where to create an API key on platform.openai.com.

Your tools are all set. Next up, you will create your project folder and start building the server that connects to OpenAI's voice AI.

Create Your Project

Your tools are installed and your OpenAI API key is saved. Now it's time to build the project folder where all your code will live.

Every Node.js project starts with a folder and a package.json file that tracks your project's dependencies. You'll create that structure, install a web server library, and set up the files your app needs.

In this step, get ready to:
  • Create a project folder and initialize it with npm.
  • Install Express and create the project files.
  • Configure your project settings and add your API key.
Create the project folder and initialize npm

Your project needs a dedicated folder on your Desktop so you can find it easily later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir dutch-partner
cd dutch-partner
  • Confirm you're inside the new folder by running:
ls

You should see an empty output since the folder is brand new.

Seeing an error about the folder already existing?

If you see a "file already exists" error, it means you already have a folder called dutch-partner on your Desktop. Delete it first or choose a different name.

help me fix a folder creation error in my terminal

  • Initialize your project with npm by running:
npm init -y

You should see a JSON output that starts with Wrote to followed by the path to your new package.json file.

What does this command do?

The npm init -y command creates a package.json file with default values. This file is like a recipe card for your project. It tracks the project name, version, and any libraries your project depends on.

The -y flag skips all the setup questions and uses sensible defaults.

Install Express and create your project files

Your Dutch conversation partner needs a web server to serve your page and talk to OpenAI's API. Express is a popular Node.js library that makes building web servers straightforward.

  • Install Express by running:
npm install express

You should see output showing packages being added, ending with a line like added X packages.

Seeing network or permission errors?

Make sure you have an internet connection. If you see a permissions error, try running your terminal as Administrator (right-click the terminal app and select Run as administrator).

help me fix an npm install error

  • Create the public folder (where your web page files will live) by running:
mkdir public
  • Open VS Code on your project folder by running:
code .

What does code . do?

The code . command opens VS Code with the current folder loaded. The dot (.) means "this folder".

code command not recognized?

If your terminal does not recognize code, open VS Code manually by pressing the Windows key, typing VS Code, and pressing Enter. Then click File > Open Folder and select the dutch-partner folder on your Desktop.

help me open VS Code from the terminal

VS Code opens with your project in the Explorer sidebar on the left. You should see the public folder and package.json listed.

  • In the Explorer sidebar, hover over the DUTCH-PARTNER heading.
  • Click the New File icon (it looks like a page with a small plus sign).
  • Type server.js and press Enter.
  • Repeat the same process to create a file called .env.
  • Now click the public folder in the sidebar to select it.
  • Click the New File icon again and create index.html.
  • Create another file inside public called style.css.
  • Create one more file inside public called script.js.

Your Explorer sidebar should now show this structure:

dutch-partner/
  .env
  node_modules/
  package.json
  package-lock.json
  server.js
  public/
    index.html
    script.js
    style.css

Files ended up in the wrong place?

If index.html, style.css, or script.js appear outside the public folder, drag them into the public folder in the Explorer sidebar. Make sure you click the public folder first before creating each file.

help me fix my file structure in VS Code

Configure your project and add your API key

The default package.json that npm generated needs a few changes. You'll tell Node.js to use modern JavaScript syntax and set up a start command for your server.

  • Click package.json in the Explorer sidebar to open it.
  • Select all the existing content (press Ctrl+A) and replace it with this code:
{
  "name": "dutch-conversation-partner",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env server.js"
  },
  "dependencies": {
    "express": "^5.1.0"
  }
}

What does this configuration do?

  • "type": "module" tells Node.js to use modern JavaScript import/export syntax instead of the older require() style.
  • The start script is a shortcut. Later you'll run node --env-file=.env server.js to start your server with your API key loaded.
  • The dependencies section records that your project uses Express 5.1.0.
  • Save package.json by pressing Ctrl+S.

Now you'll add your OpenAI API key so the server can authenticate with OpenAI's Realtime API.

  • Click .env in the Explorer sidebar to open it.
  • Paste the following line, replacing your-api-key-here with the actual API key you saved in Step 1:
OPENAI_API_KEY=your-api-key-here

What is a .env file?

A .env file stores sensitive values (like API keys) separately from your code. Your server reads this file at startup, but it never gets shared or uploaded anywhere. This keeps your key private.

  • Save .env by pressing Ctrl+S.

Not sure where to find your API key?

Go back to platform.openai.com and navigate to Settings > API Keys. If you did not save your key earlier, you'll need to create a new one.

help me find or create my OpenAI API key

✔️ Awesome, I've got everything!

Great. Double check you've saved both package.json and .env before moving on.

ⓧ I'd like to double check the full code

Here's what each file should contain at this point.

{
  "name": "dutch-conversation-partner",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env server.js"
  },
  "dependencies": {
    "express": "^5.1.0"
  }
}
OPENAI_API_KEY=your-api-key-here

The files server.js, public/index.html, public/style.css, and public/script.js should exist but remain empty for now.

Your project structure is ready and your API key is in place. Next up, you'll write the server code that connects your browser to OpenAI's voice AI.

Build the Server

Your project folder is set up with all the files you need. Now it's time to write the brains of your application.

The server has one critical job: act as a secure middleman between your browser and OpenAI's voice AI. Your browser will send audio connection data to your server, and the server forwards it to OpenAI along with your API key. This way, your secret key never touches the browser where anyone could see it.

In this step, get ready to:
  • Write the Express server that serves your web page files.
  • Craft a Dutch tutor system prompt that shapes the AI's personality.
  • Build the /session endpoint that connects your browser to OpenAI's Realtime API.
Set up the Express server

Express is a web server library for Node.js. It handles incoming web requests and sends back responses. Your server needs to do two things: serve the HTML/CSS/JS files in your public folder, and accept audio connection data from the browser.

  • In VS Code, open server.js from the Explorer sidebar.
  • Paste the following code into the file:
import express from "express";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const app = express();

// Serve all files in the public/ folder as a website
app.use(express.static(join(__dirname, "public")));
// Accept audio connection data (SDP format) from the browser
app.use(express.text({ type: ["application/sdp", "text/plain"] }));

What does this code do?

The first three lines import Express and some Node.js utilities for working with file paths.

The __filename and __dirname lines figure out where your project lives on disk. This lets Express find your public/ folder regardless of where you run the server from.

The two app.use() lines set up middleware. The first one tells Express to serve your HTML, CSS, and JavaScript files. The second tells Express to accept text data in SDP format, which is how WebRTC audio connections are described.

  • Save server.js.
  • You won't see a change yet. The next chunk adds the AI's personality.
Add the Dutch tutor system prompt

A system prompt is a set of instructions that shapes how an AI behaves. It's the difference between a generic chatbot and a dedicated Dutch language tutor. Your prompt will tell the AI to speak Dutch, correct pronunciation gently, teach idioms, and adapt to the learner's level.

  • In server.js, add the following code below the app.use(express.text(...)) line:
const DUTCH_TUTOR_INSTRUCTIONS = `You are a friendly and patient Dutch conversation partner called "Praatmaatje" (Chat Buddy). Your role is to help the user practice speaking Dutch.

## Core Behavior
- Speak primarily in Dutch, but switch to English briefly when explaining grammar or when the user is clearly stuck.
- Keep your responses conversational and natural. Use short sentences appropriate for a language learner.
- Adapt your Dutch complexity to the user's level. Start simple and increase difficulty as they improve.

## Pronunciation Feedback
- When the user mispronounces a Dutch word, gently correct them by repeating the word clearly and offering a tip.
- Pay special attention to common trouble sounds: the Dutch "g" (guttural), "ui" (as in "huis"), "ij/ei", and "oe".
- Do not correct every single mistake. Focus on one or two per exchange so the user does not feel overwhelmed.

## Teaching Dutch Expressions
- Sprinkle in authentic Dutch expressions naturally: "gezellig", "lekker", "doe maar normaal", "het valt mee", "tot zo".
- When you use an expression, briefly explain what it means if the user seems unfamiliar.
- Encourage the user to try using expressions they have learned.

## Conversation Style
- Be warm, encouraging, and slightly humorous. Dutch people appreciate directness with a touch of humor.
- Ask follow-up questions to keep the conversation flowing.
- If the user speaks English, gently encourage them back to Dutch: "Probeer het in het Nederlands!"
- Celebrate small wins: "Heel goed!" or "Dat klinkt al veel beter!"

## Scenarios
When the user selects a scenario, fully commit to the role:
- Greeting: You are a friendly neighbor meeting them for the first time.
- Cafe: You are a server at a cozy Dutch brown cafe (bruin cafe).
- Market: You are a vendor at an outdoor market selling cheese and stroopwafels.
- Directions: You are a local being asked for directions in Amsterdam.`;

What does this prompt do?

This is the heart of your tutor. Each section gives the AI a specific behavior:

  • Core Behavior tells it to speak Dutch primarily but switch to English when you're stuck.
  • Pronunciation Feedback focuses on tricky Dutch sounds like the guttural "g" and the "ui" vowel.
  • Teaching Dutch Expressions makes it weave in authentic phrases like "gezellig" and "lekker".
  • Scenarios lets the AI roleplay as a cafe server, market vendor, or neighbor.

Make it your own

After testing the app, try editing this prompt to match your goals. You could change the scenarios to situations you encounter often, or ask the AI to focus on specific grammar topics.

  • Save server.js.
  • You won't see a change yet. The next chunk uses this prompt to configure an actual API session.
Implement the /session endpoint

When your browser wants to start a voice conversation, it sends audio connection data (called an SDP offer) to your server. Your server packages that data with the session configuration and forwards everything to OpenAI's Realtime API. OpenAI sends back its own SDP answer, and the server passes it to the browser. From that point, audio flows directly between browser and OpenAI.

  • In server.js, add the following code below the DUTCH_TUTOR_INSTRUCTIONS constant:
const sessionConfig = JSON.stringify({
  type: "realtime",
  model: "gpt-realtime-2",
  instructions: DUTCH_TUTOR_INSTRUCTIONS,
  audio: {
    input: {
      transcription: {
        model: "whisper-1",
        language: "nl",
      },
      noise_reduction: { type: "near_field" },
    },
    output: {
      voice: "coral",
    },
  },
});

app.post("/session", async (req, res) => {
  // Package the browser's SDP offer with our session config
  const fd = new FormData();
  fd.set("sdp", req.body);
  fd.set("session", sessionConfig);

  try {
    // Forward everything to OpenAI's Realtime API
    const r = await fetch("https://api.openai.com/v1/realtime/calls", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      },
      body: fd,
    });

    if (!r.ok) {
      const error = await r.text();
      console.error("OpenAI API error:", r.status, error);
      return res.status(r.status).send(error);
    }

    // Send OpenAI's SDP answer back to the browser
    const sdp = await r.text();
    res.setHeader("Content-Type", "application/sdp");
    res.send(sdp);
  } catch (error) {
    console.error("Session creation error:", error);
    res.status(500).json({ error: "Failed to create session" });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

What does this code do?

The sessionConfig object tells OpenAI how to set up the voice session:

  • model: "gpt-realtime-2" selects OpenAI's most capable real-time voice model.
  • whisper-1 with language: "nl" enables Dutch speech transcription so you can see what was said.
  • near_field noise reduction is optimized for headphones or a close microphone.
  • voice: "coral" picks a warm, conversational voice for Praatmaatje.

The /session endpoint uses FormData to send both the browser's SDP offer and the session config to https://api.openai.com/v1/realtime/calls. Your API key goes in the Authorization header so it never reaches the browser.

Why does the server act as a middleman?

If you sent your API key directly from the browser, anyone could open DevTools and steal it. By routing through your server, the key stays on your machine. The browser only ever talks to localhost:3000.

  • Save server.js.
  • Verify the server starts by running this command in your terminal (make sure you're in the dutch-partner folder):
node --env-file=.env server.js

You should see Server running at http://localhost:3000 printed in the terminal.

  • Press Ctrl+C in the terminal to stop the server. You'll start it again once the frontend is ready.

Seeing an error when starting the server?

  • If you see a syntax error, double check that you pasted all three code chunks in order with no extra characters between them.
  • If you see a "Cannot find module" error, make sure "type": "module" is in your package.json and that you ran npm install express in the previous step.
  • If the terminal says the port is already in use, another program is using port 3000. Close any other terminals or servers you have running.

Help me debug my server startup error

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and stopped the server with Ctrl+C before moving on.

ⓧ I'd like to double check the full code

Here's the complete server.js file. Make sure yours matches exactly:

import express from "express";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const app = express();

app.use(express.static(join(__dirname, "public")));
app.use(express.text({ type: ["application/sdp", "text/plain"] }));

const DUTCH_TUTOR_INSTRUCTIONS = `You are a friendly and patient Dutch conversation partner called "Praatmaatje" (Chat Buddy). Your role is to help the user practice speaking Dutch.

## Core Behavior
- Speak primarily in Dutch, but switch to English briefly when explaining grammar or when the user is clearly stuck.
- Keep your responses conversational and natural. Use short sentences appropriate for a language learner.
- Adapt your Dutch complexity to the user's level. Start simple and increase difficulty as they improve.

## Pronunciation Feedback
- When the user mispronounces a Dutch word, gently correct them by repeating the word clearly and offering a tip.
- Pay special attention to common trouble sounds: the Dutch "g" (guttural), "ui" (as in "huis"), "ij/ei", and "oe".
- Do not correct every single mistake. Focus on one or two per exchange so the user does not feel overwhelmed.

## Teaching Dutch Expressions
- Sprinkle in authentic Dutch expressions naturally: "gezellig", "lekker", "doe maar normaal", "het valt mee", "tot zo".
- When you use an expression, briefly explain what it means if the user seems unfamiliar.
- Encourage the user to try using expressions they have learned.

## Conversation Style
- Be warm, encouraging, and slightly humorous. Dutch people appreciate directness with a touch of humor.
- Ask follow-up questions to keep the conversation flowing.
- If the user speaks English, gently encourage them back to Dutch: "Probeer het in het Nederlands!"
- Celebrate small wins: "Heel goed!" or "Dat klinkt al veel beter!"

## Scenarios
When the user selects a scenario, fully commit to the role:
- Greeting: You are a friendly neighbor meeting them for the first time.
- Cafe: You are a server at a cozy Dutch brown cafe (bruin cafe).
- Market: You are a vendor at an outdoor market selling cheese and stroopwafels.
- Directions: You are a local being asked for directions in Amsterdam.`;

const sessionConfig = JSON.stringify({
  type: "realtime",
  model: "gpt-realtime-2",
  instructions: DUTCH_TUTOR_INSTRUCTIONS,
  audio: {
    input: {
      transcription: {
        model: "whisper-1",
        language: "nl",
      },
      noise_reduction: { type: "near_field" },
    },
    output: {
      voice: "coral",
    },
  },
});

app.post("/session", async (req, res) => {
  const fd = new FormData();
  fd.set("sdp", req.body);
  fd.set("session", sessionConfig);

  try {
    const r = await fetch("https://api.openai.com/v1/realtime/calls", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      },
      body: fd,
    });

    if (!r.ok) {
      const error = await r.text();
      console.error("OpenAI API error:", r.status, error);
      return res.status(r.status).send(error);
    }

    const sdp = await r.text();
    res.setHeader("Content-Type", "application/sdp");
    res.send(sdp);
  } catch (error) {
    console.error("Session creation error:", error);
    res.status(500).json({ error: "Failed to create session" });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

Your server is ready to handle voice connections. Next up, you'll build the web interface that the learner actually sees and clicks.

Create the Web Interface

Your server is ready to handle voice sessions. Now you need a front end for the learner to actually interact with.

In this step, you will build the HTML page that holds your app's structure and the CSS that gives it a polished dark theme. These two files live in the public/ folder that your Express server already serves automatically.

In this step, get ready to:
  • Build the HTML page with a connect button, status indicator, scenario buttons, and a transcript panel.
  • Style the page with a dark colour scheme and clean layout.
Build the HTML page

The HTML file defines the skeleton of your app. It contains the heading, a connect button, four scenario buttons for roleplay practice, and a transcript area where conversation text will appear.

  • In VS Code, open public/index.html (it should be empty).
  • Paste the following code into the file:
<!DOCTYPE html>
<html lang="nl">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Nederlands Praatmaatje</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="container">
    <!-- App header with title and subtitle -->
    <header>
      <h1>🇳🇱 Nederlands Praatmaatje</h1>
      <p class="subtitle">Your Dutch Conversation Partner</p>
    </header>

    <!-- Connect button and connection status -->
    <div class="controls">
      <button id="connectBtn" class="btn btn-primary">Connect</button>
      <span id="status" class="status status-disconnected">Disconnected</span>
    </div>

    <!-- Scenario buttons for roleplay practice -->
    <div class="scenarios">
      <p class="scenarios-label">Practice a scenario:</p>
      <button class="btn btn-scenario" data-scenario="greeting">👋 Greeting</button>
      <button class="btn btn-scenario" data-scenario="cafe">☕ Café</button>
      <button class="btn btn-scenario" data-scenario="market">🧀 Market</button>
      <button class="btn btn-scenario" data-scenario="directions">🗺️ Directions</button>
    </div>

    <!-- Conversation transcript panel -->
    <div class="transcript" id="transcript">
      <p class="transcript-placeholder">Connect and start speaking Dutch!</p>
    </div>
  </div>

  <script src="script.js"></script>
</body>
</html>

What does this code do?

  • The <button id="connectBtn"> is the main control. JavaScript will later use this ID to start and stop voice sessions.
  • Each scenario button carries a data-scenario attribute (greeting, cafe, market, directions) that your script reads to know which roleplay to trigger.
  • The transcript div is where both your speech and the AI's responses will appear in real time.
  • Save public/index.html.
  • Open the public folder in Finder (macOS) or File Explorer (Windows), then double-click index.html to open it in your browser.
  • You should see unstyled text showing the heading, a Connect button, four scenario buttons, and placeholder text. It looks plain because we have not added CSS yet.

See a blank page?

  • Confirm you saved the file. Unsaved changes will not appear in the browser.
  • Check that you opened public/index.html specifically, not index.html at the project root (there is no file there).

Help me debug why my index.html page is blank in the browser.

Style the page

The CSS gives the page a dark colour scheme and clean layout. You do not need to understand every line. The key visual elements are the orange primary colour, dark card backgrounds, and colour-coded message bubbles for you and the AI.

  • In VS Code, open public/style.css (it should be empty).
  • Paste the following code into the file:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #1a1a2e;
  color: #eee;
  min-height: 100vh;
  padding: 2rem;
}

.container {
  max-width: 700px;
  margin: 0 auto;
}

header {
  text-align: center;
  margin-bottom: 2rem;
}

header h1 {
  font-size: 2rem;
  color: #ff6b35;
}

.subtitle {
  color: #aaa;
  margin-top: 0.5rem;
}

.controls {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.btn {
  border: none;
  padding: 0.6rem 1.2rem;
  border-radius: 8px;
  cursor: pointer;
  font-size: 0.9rem;
  font-weight: 600;
  transition: background 0.2s;
}

.btn-primary {
  background: #ff6b35;
  color: white;
}

.btn-primary:hover {
  background: #e55a2b;
}

.btn-primary.connected {
  background: #e74c3c;
}

.status {
  font-size: 0.85rem;
  padding: 0.3rem 0.8rem;
  border-radius: 12px;
}

.status-disconnected {
  background: #333;
  color: #888;
}

.status-connected {
  background: #1e3a1e;
  color: #4caf50;
}

.status-connecting {
  background: #3a3a1e;
  color: #f0ad4e;
}

.scenarios {
  margin-bottom: 1.5rem;
}

.scenarios-label {
  font-size: 0.85rem;
  color: #888;
  margin-bottom: 0.5rem;
}

.btn-scenario {
  background: #2a2a4a;
  color: #ccc;
  margin-right: 0.5rem;
  margin-bottom: 0.5rem;
}

.btn-scenario:hover {
  background: #3a3a6a;
  color: white;
}

.transcript {
  background: #16213e;
  border-radius: 12px;
  padding: 1.5rem;
  min-height: 300px;
  max-height: 500px;
  overflow-y: auto;
}

.transcript-placeholder {
  color: #555;
  text-align: center;
  padding-top: 2rem;
}

.message {
  margin-bottom: 1rem;
  padding: 0.8rem;
  border-radius: 8px;
}

.message-user {
  background: #1e3a5e;
  border-left: 3px solid #4a9eff;
}

.message-assistant {
  background: #2a1e3e;
  border-left: 3px solid #ff6b35;
}

.message-label {
  font-size: 0.75rem;
  color: #888;
  margin-bottom: 0.3rem;
  text-transform: uppercase;
}

.message-text {
  font-size: 0.95rem;
  line-height: 1.5;
}

What are the key styles here?

  • The page background is #1a1a2e (dark navy) with light text, creating a comfortable low-light reading experience.
  • The primary accent colour #ff6b35 (orange) draws attention to the Connect button and the AI's message border.
  • User messages get a blue left border (.message-user) while the AI's messages get an orange left border (.message-assistant), making it easy to tell who said what.
  • Save public/style.css.
  • Switch back to the browser tab where you opened index.html and refresh the page.
  • You should see a dark-themed page with an orange heading, rounded orange Connect button, dark purple scenario buttons, and a dark transcript area at the bottom.

Page still looks unstyled?

  • Make sure you saved style.css inside the public/ folder, not in the project root. The HTML references it with a relative path, so they must be in the same folder.
  • Hard-refresh the page with Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS) to clear cached styles.

Help me figure out why my CSS is not loading.

✔️ Awesome, I've got everything!

Great. Double check you have saved both public/index.html and public/style.css.

ⓧ I'd like to double check the full code

Here is the complete public/index.html:

<!DOCTYPE html>
<html lang="nl">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Nederlands Praatmaatje</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="container">
    <header>
      <h1>🇳🇱 Nederlands Praatmaatje</h1>
      <p class="subtitle">Your Dutch Conversation Partner</p>
    </header>

    <div class="controls">
      <button id="connectBtn" class="btn btn-primary">Connect</button>
      <span id="status" class="status status-disconnected">Disconnected</span>
    </div>

    <div class="scenarios">
      <p class="scenarios-label">Practice a scenario:</p>
      <button class="btn btn-scenario" data-scenario="greeting">👋 Greeting</button>
      <button class="btn btn-scenario" data-scenario="cafe">☕ Café</button>
      <button class="btn btn-scenario" data-scenario="market">🧀 Market</button>
      <button class="btn btn-scenario" data-scenario="directions">🗺️ Directions</button>
    </div>

    <div class="transcript" id="transcript">
      <p class="transcript-placeholder">Connect and start speaking Dutch!</p>
    </div>
  </div>

  <script src="script.js"></script>
</body>
</html>

And here is the complete public/style.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #1a1a2e;
  color: #eee;
  min-height: 100vh;
  padding: 2rem;
}

.container {
  max-width: 700px;
  margin: 0 auto;
}

header {
  text-align: center;
  margin-bottom: 2rem;
}

header h1 {
  font-size: 2rem;
  color: #ff6b35;
}

.subtitle {
  color: #aaa;
  margin-top: 0.5rem;
}

.controls {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.btn {
  border: none;
  padding: 0.6rem 1.2rem;
  border-radius: 8px;
  cursor: pointer;
  font-size: 0.9rem;
  font-weight: 600;
  transition: background 0.2s;
}

.btn-primary {
  background: #ff6b35;
  color: white;
}

.btn-primary:hover {
  background: #e55a2b;
}

.btn-primary.connected {
  background: #e74c3c;
}

.status {
  font-size: 0.85rem;
  padding: 0.3rem 0.8rem;
  border-radius: 12px;
}

.status-disconnected {
  background: #333;
  color: #888;
}

.status-connected {
  background: #1e3a1e;
  color: #4caf50;
}

.status-connecting {
  background: #3a3a1e;
  color: #f0ad4e;
}

.scenarios {
  margin-bottom: 1.5rem;
}

.scenarios-label {
  font-size: 0.85rem;
  color: #888;
  margin-bottom: 0.5rem;
}

.btn-scenario {
  background: #2a2a4a;
  color: #ccc;
  margin-right: 0.5rem;
  margin-bottom: 0.5rem;
}

.btn-scenario:hover {
  background: #3a3a6a;
  color: white;
}

.transcript {
  background: #16213e;
  border-radius: 12px;
  padding: 1.5rem;
  min-height: 300px;
  max-height: 500px;
  overflow-y: auto;
}

.transcript-placeholder {
  color: #555;
  text-align: center;
  padding-top: 2rem;
}

.message {
  margin-bottom: 1rem;
  padding: 0.8rem;
  border-radius: 8px;
}

.message-user {
  background: #1e3a5e;
  border-left: 3px solid #4a9eff;
}

.message-assistant {
  background: #2a1e3e;
  border-left: 3px solid #ff6b35;
}

.message-label {
  font-size: 0.75rem;
  color: #888;
  margin-bottom: 0.3rem;
  text-transform: uppercase;
}

.message-text {
  font-size: 0.95rem;
  line-height: 1.5;
}

Your interface is ready. Next up, you will write the JavaScript that connects this page to OpenAI's voice AI and makes the buttons work.

Add the Voice Connection Logic

Your server is ready to broker connections between the browser and OpenAI. Your HTML and CSS are in place, giving you a clean dark-themed interface with a connect button and transcript area.

Now you need the JavaScript that actually opens a voice channel. When the learner clicks Connect, the browser will request microphone access, establish a WebRTC peer connection, and stream audio to and from OpenAI in real time.

In this step, get ready to:
  • Write the WebRTC connection logic that streams your microphone to OpenAI.
  • Handle incoming AI events to display transcripts of both speakers.
  • Wire up scenario buttons so you can practice real-life Dutch situations.
Write the WebRTC connection logic

WebRTC is a browser technology that enables real-time audio and video communication. Your script will use it to open a direct audio channel between your microphone and OpenAI's voice model.

  • Open public/script.js in VS Code.
  • Add the setup code that grabs DOM references and defines the scenario prompts by pasting the following:
let peerConnection = null;
let dataChannel = null;
let audioElement = null;

const connectBtn = document.getElementById("connectBtn");
const statusEl = document.getElementById("status");
const transcriptEl = document.getElementById("transcript");
const scenarioButtons = document.querySelectorAll(".btn-scenario");

let currentAssistantText = "";

// Scenario prompts that set up roleplay contexts for the AI
const scenarios = {
  greeting:
    "Let's practice greeting a new neighbor. Start the conversation as if we just met in the hallway of our apartment building.",
  cafe:
    "Let's practice ordering at a Dutch cafe. You are the server. Greet me and ask what I would like to order.",
  market:
    "Let's practice shopping at an outdoor market. You are selling cheese and stroopwafels. Greet me as I approach your stand.",
  directions:
    "Let's practice asking for directions. I am going to ask you how to get to the Rijksmuseum. You are a friendly local in Amsterdam.",
};

// Toggle connection on button click
connectBtn.addEventListener("click", () => {
  if (peerConnection) {
    disconnect();
  } else {
    connect();
  }
});

// Each scenario button sends its prompt to the AI
scenarioButtons.forEach((btn) => {
  btn.addEventListener("click", () => {
    const scenario = btn.dataset.scenario;
    if (dataChannel && dataChannel.readyState === "open") {
      sendScenario(scenario);
    }
  });
});

What does this code do?

The three let variables at the top will hold the WebRTC connection, the data channel for events, and an audio element that plays the AI's voice.

The scenarios object maps each button's data-scenario attribute to a prompt that tells the AI which role to play. The event listeners check whether you're already connected before toggling or sending.

  • Below the event listeners, add the connect() function that establishes the voice channel:
async function connect() {
  setStatus("connecting");

  // Create a new WebRTC peer connection
  peerConnection = new RTCPeerConnection();

  // Set up an audio element to play the AI's voice
  audioElement = document.createElement("audio");
  audioElement.autoplay = true;
  peerConnection.ontrack = (e) => {
    audioElement.srcObject = e.streams[0];
  };

  // Request microphone access and add the audio track
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  peerConnection.addTrack(stream.getTracks()[0]);

  // Open a data channel for text-based events from OpenAI
  dataChannel = peerConnection.createDataChannel("oai-events");

  dataChannel.addEventListener("open", () => {
    setStatus("connected");
    clearTranscript();
    addMessage("assistant", "Connected! Say something in Dutch, or pick a scenario below.");
  });

  dataChannel.addEventListener("message", handleServerEvent);

  // Create an SDP offer and send it to your server
  const offer = await peerConnection.createOffer();
  await peerConnection.setLocalDescription(offer);

  const response = await fetch("/session", {
    method: "POST",
    body: offer.sdp,
    headers: { "Content-Type": "application/sdp" },
  });

  if (!response.ok) {
    console.error("Failed to create session:", response.status);
    setStatus("disconnected");
    disconnect();
    return;
  }

  // Apply OpenAI's answer to complete the WebRTC handshake
  const answerSdp = await response.text();
  await peerConnection.setRemoteDescription({ type: "answer", sdp: answerSdp });
}

What's happening here?

  • RTCPeerConnection is the browser's built-in WebRTC object. It manages the audio stream automatically.
  • getUserMedia({ audio: true }) prompts the user for microphone permission and returns an audio stream.
  • The data channel named oai-events is how OpenAI sends text events (transcripts, status updates) alongside the audio stream.
  • The SDP (Session Description Protocol) offer/answer exchange is how two WebRTC peers agree on audio formats. Your server forwards the offer to OpenAI and returns the answer.
  • Below connect(), add the disconnect() function that tears down the connection cleanly:
function disconnect() {
  // Close each resource if it exists
  if (dataChannel) {
    dataChannel.close();
    dataChannel = null;
  }
  if (peerConnection) {
    peerConnection.close();
    peerConnection = null;
  }
  if (audioElement) {
    audioElement.srcObject = null;
    audioElement = null;
  }
  currentAssistantText = "";
  setStatus("disconnected");
}

Why close each resource individually?

WebRTC connections hold open network sockets, audio hardware, and memory. Closing each piece separately prevents resource leaks. Setting them to null lets the connect button logic know the connection is fully torn down.

  • Save public/script.js.

You won't see a change in the browser yet. The script references helper functions (setStatus, clearTranscript, addMessage) that you'll define in the next substeps.

Handle incoming events from the AI

OpenAI sends text events through the data channel while audio streams separately. Your script needs to listen for three event types: streaming chunks of what the AI is saying, a signal that the AI finished speaking, and the transcription of what you said.

  • Below disconnect(), add the handleServerEvent() function:
function handleServerEvent(e) {
  const event = JSON.parse(e.data);

  // Streaming text of what the AI is currently saying
  if (event.type === "response.output_audio_transcript.delta") {
    currentAssistantText += event.delta;
    updateAssistantMessage(currentAssistantText);
  }

  // AI finished its response, reset the buffer
  if (event.type === "response.output_audio_transcript.done") {
    currentAssistantText = "";
  }

  // Transcription of what you said (from Whisper)
  if (event.type === "conversation.item.input_audio_transcription.completed") {
    if (event.transcript && event.transcript.trim()) {
      addMessage("user", event.transcript.trim());
    }
  }
}

What are these event types?

  • response.output_audio_transcript.delta arrives word-by-word as the AI speaks. You accumulate these into currentAssistantText and update the display live.
  • response.output_audio_transcript.done signals the AI finished talking. Resetting the buffer prepares for the next response.
  • conversation.item.input_audio_transcription.completed is what OpenAI's Whisper model heard you say. This lets you compare what you intended with what was understood.

Transcription is guidance, not gospel

The input transcription runs asynchronously via a separate speech-to-text model. The AI voice model hears your raw audio directly. The transcript is a helpful display for you, but the model may understand nuances that the transcript misses.

  • Save public/script.js.

You still won't see changes in the browser. The next substep adds the remaining functions.

Wire up the scenario buttons and UI helpers

The scenario buttons send a text message through the data channel that sets up a roleplay context. The AI reads this prompt and responds in character. You also need the UI helper functions that everything above depends on.

  • Below handleServerEvent(), add the sendScenario() function:
function sendScenario(scenarioKey) {
  const text = scenarios[scenarioKey];
  // Send the scenario prompt as a user message via the data channel
  const event = {
    type: "conversation.item.create",
    item: {
      type: "message",
      role: "user",
      content: [{ type: "input_text", text: text }],
    },
  };
  dataChannel.send(JSON.stringify(event));
  // Tell the AI to generate a response
  dataChannel.send(JSON.stringify({ type: "response.create" }));
  addMessage("user", `[Scenario: ${scenarioKey}]`);
}

How does this trigger the AI?

Two events are sent in sequence. conversation.item.create adds the scenario prompt to the conversation as if you typed it. response.create tells the model to generate a spoken reply. The AI then responds in character based on the scenario instructions in its system prompt.

  • Below sendScenario(), add the setStatus() function that updates the button and status badge:
function setStatus(state) {
  statusEl.className = `status status-${state}`;
  if (state === "connected") {
    statusEl.textContent = "Connected";
    connectBtn.textContent = "Disconnect";
    connectBtn.classList.add("connected");
  } else if (state === "connecting") {
    statusEl.textContent = "Connecting...";
  } else {
    statusEl.textContent = "Disconnected";
    connectBtn.textContent = "Connect";
    connectBtn.classList.remove("connected");
  }
}

What does this code do?

This function swaps CSS classes on the status badge (which triggers different background colors from your stylesheet) and toggles the button text between "Connect" and "Disconnect". The connected class turns the button red to signal you can click to disconnect.

  • Finally, add the three transcript helper functions that display messages on screen:
function clearTranscript() {
  transcriptEl.innerHTML = "";
}

function addMessage(role, text) {
  const div = document.createElement("div");
  div.className = `message message-${role}`;
  div.innerHTML = `
    <div class="message-label">${role === "user" ? "You" : "Praatmaatje"}</div>
    <div class="message-text">${text}</div>
  `;
  transcriptEl.appendChild(div);
  transcriptEl.scrollTop = transcriptEl.scrollHeight;
}

function updateAssistantMessage(text) {
  const messages = transcriptEl.querySelectorAll(".message-assistant");
  const last = messages[messages.length - 1];
  if (last && last.dataset.streaming === "true") {
    last.querySelector(".message-text").textContent = text;
  } else {
    const div = document.createElement("div");
    div.className = "message message-assistant";
    div.dataset.streaming = "true";
    div.innerHTML = `
      <div class="message-label">Praatmaatje</div>
      <div class="message-text">${text}</div>
    `;
    transcriptEl.appendChild(div);
  }
  transcriptEl.scrollTop = transcriptEl.scrollHeight;
}

Why two different message functions?

addMessage() creates a brand-new message bubble. It works for your speech transcripts and one-off system messages.

updateAssistantMessage() handles the streaming effect. As delta events arrive word-by-word, it updates the same bubble's text content instead of creating a new one each time. This gives you the live-typing appearance.

  • Save public/script.js.

Your script is now complete. You won't test it until the next step when you start the server and open the page in Chrome.

Seeing a syntax error in VS Code?

  • Check that every opening brace { has a matching closing brace }. Template literals use backticks (`), not regular quotes.
  • Make sure you pasted each block in order, one after the other. Functions should not be nested inside each other.

Help me find a syntax error in my script.js file.

✔️ Awesome, I've got everything!

Great. Double check you've saved public/script.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete public/script.js file. Compare it with yours:

let peerConnection = null;
let dataChannel = null;
let audioElement = null;

const connectBtn = document.getElementById("connectBtn");
const statusEl = document.getElementById("status");
const transcriptEl = document.getElementById("transcript");
const scenarioButtons = document.querySelectorAll(".btn-scenario");

let currentAssistantText = "";

const scenarios = {
  greeting:
    "Let's practice greeting a new neighbor. Start the conversation as if we just met in the hallway of our apartment building.",
  cafe:
    "Let's practice ordering at a Dutch cafe. You are the server. Greet me and ask what I would like to order.",
  market:
    "Let's practice shopping at an outdoor market. You are selling cheese and stroopwafels. Greet me as I approach your stand.",
  directions:
    "Let's practice asking for directions. I am going to ask you how to get to the Rijksmuseum. You are a friendly local in Amsterdam.",
};

connectBtn.addEventListener("click", () => {
  if (peerConnection) {
    disconnect();
  } else {
    connect();
  }
});

scenarioButtons.forEach((btn) => {
  btn.addEventListener("click", () => {
    const scenario = btn.dataset.scenario;
    if (dataChannel && dataChannel.readyState === "open") {
      sendScenario(scenario);
    }
  });
});

async function connect() {
  setStatus("connecting");

  peerConnection = new RTCPeerConnection();

  audioElement = document.createElement("audio");
  audioElement.autoplay = true;
  peerConnection.ontrack = (e) => {
    audioElement.srcObject = e.streams[0];
  };

  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  peerConnection.addTrack(stream.getTracks()[0]);

  dataChannel = peerConnection.createDataChannel("oai-events");

  dataChannel.addEventListener("open", () => {
    setStatus("connected");
    clearTranscript();
    addMessage("assistant", "Connected! Say something in Dutch, or pick a scenario below.");
  });

  dataChannel.addEventListener("message", handleServerEvent);

  const offer = await peerConnection.createOffer();
  await peerConnection.setLocalDescription(offer);

  const response = await fetch("/session", {
    method: "POST",
    body: offer.sdp,
    headers: { "Content-Type": "application/sdp" },
  });

  if (!response.ok) {
    console.error("Failed to create session:", response.status);
    setStatus("disconnected");
    disconnect();
    return;
  }

  const answerSdp = await response.text();
  await peerConnection.setRemoteDescription({ type: "answer", sdp: answerSdp });
}

function disconnect() {
  if (dataChannel) {
    dataChannel.close();
    dataChannel = null;
  }
  if (peerConnection) {
    peerConnection.close();
    peerConnection = null;
  }
  if (audioElement) {
    audioElement.srcObject = null;
    audioElement = null;
  }
  currentAssistantText = "";
  setStatus("disconnected");
}

function handleServerEvent(e) {
  const event = JSON.parse(e.data);

  if (event.type === "response.output_audio_transcript.delta") {
    currentAssistantText += event.delta;
    updateAssistantMessage(currentAssistantText);
  }

  if (event.type === "response.output_audio_transcript.done") {
    currentAssistantText = "";
  }

  if (event.type === "conversation.item.input_audio_transcription.completed") {
    if (event.transcript && event.transcript.trim()) {
      addMessage("user", event.transcript.trim());
    }
  }
}

function sendScenario(scenarioKey) {
  const text = scenarios[scenarioKey];
  const event = {
    type: "conversation.item.create",
    item: {
      type: "message",
      role: "user",
      content: [{ type: "input_text", text: text }],
    },
  };
  dataChannel.send(JSON.stringify(event));
  dataChannel.send(JSON.stringify({ type: "response.create" }));
  addMessage("user", `[Scenario: ${scenarioKey}]`);
}

function setStatus(state) {
  statusEl.className = `status status-${state}`;
  if (state === "connected") {
    statusEl.textContent = "Connected";
    connectBtn.textContent = "Disconnect";
    connectBtn.classList.add("connected");
  } else if (state === "connecting") {
    statusEl.textContent = "Connecting...";
  } else {
    statusEl.textContent = "Disconnected";
    connectBtn.textContent = "Connect";
    connectBtn.classList.remove("connected");
  }
}

function clearTranscript() {
  transcriptEl.innerHTML = "";
}

function addMessage(role, text) {
  const div = document.createElement("div");
  div.className = `message message-${role}`;
  div.innerHTML = `
    <div class="message-label">${role === "user" ? "You" : "Praatmaatje"}</div>
    <div class="message-text">${text}</div>
  `;
  transcriptEl.appendChild(div);
  transcriptEl.scrollTop = transcriptEl.scrollHeight;
}

function updateAssistantMessage(text) {
  const messages = transcriptEl.querySelectorAll(".message-assistant");
  const last = messages[messages.length - 1];
  if (last && last.dataset.streaming === "true") {
    last.querySelector(".message-text").textContent = text;
  } else {
    const div = document.createElement("div");
    div.className = "message message-assistant";
    div.dataset.streaming = "true";
    div.innerHTML = `
      <div class="message-label">Praatmaatje</div>
      <div class="message-text">${text}</div>
    `;
    transcriptEl.appendChild(div);
  }
  transcriptEl.scrollTop = transcriptEl.scrollHeight;
}

Your voice connection logic is in place. Next up, you'll start the server and have your first Dutch conversation.

Test Your Dutch Conversation Partner

All your code is written. You have a server that securely connects to OpenAI, a web interface with scenario buttons, and client-side logic that streams voice and transcription in real time.

Now it is time to bring it all together. You will start the server, open the app in Chrome, and have your first Dutch conversation with Praatmaatje.

In this step, get ready to:
  • Start the Node.js server.
  • Connect to the AI and have a Dutch conversation.
  • Test scenario buttons for different roleplay situations.
Start the server

Your server needs to be running before the browser can connect to it. The server handles the secure handshake between your browser and OpenAI's Realtime API.

  • In your terminal (which should already be in the dutch-partner folder), start the server by running this command:
node --env-file=.env server.js
  • You should see this message in your terminal:
Server running at http://localhost:3000

What does this command do?

The --env-file=.env flag tells Node.js to load the variables from your .env file into the process environment. This is how your server accesses your OpenAI API key without hardcoding it.

The server then starts listening on port 3000 and is ready to serve your web files and proxy requests to OpenAI.

Not seeing the server start?

  • Make sure your terminal is inside the dutch-partner folder. Run ls and confirm you see server.js listed.
  • If you see an error about .env not being found, confirm the file is named exactly .env (with the dot) and sits at the top level of your project folder, not inside public/.
  • If you see a syntax error, double check that package.json contains "type": "module". Without it, Node.js cannot process the import statements in server.js.

Help me troubleshoot my Node.js server startup error.

Connect and speak Dutch

With the server running, you can now open the app and have a real conversation in Dutch. Chrome works best here because of its strong WebRTC support.

  • Open Chrome and type http://localhost:3000 in the address bar, then press Enter.
  • You should see the Nederlands Praatmaatje interface with an orange Connect button, a status indicator showing "Disconnected", four scenario buttons, and an empty transcript area.
  • Click the Connect button.

Chrome will ask for microphone access

A browser popup will appear asking permission to use your microphone. Click Allow. Without microphone access, the app cannot send your voice to the AI.

  • Click Allow when Chrome asks for microphone permission.
  • Wait a moment. The status indicator changes to green "Connected" and the transcript area shows a welcome message from Praatmaatje.
  • Try saying "Hallo, hoe gaat het?" into your microphone.

You should hear Praatmaatje respond in Dutch through your speakers or headphones. The transcript panel updates in real time, showing both what you said (labeled "You") and what the AI said (labeled "Praatmaatje").

  • Try clicking one of the scenario buttons (for example, the Cafe button).
  • The AI switches into character as a server at a Dutch brown cafe and greets you in Dutch.
  • Practice ordering something in Dutch. Try "Mag ik een koffie?" (Can I have a coffee?).

What happens when you click a scenario button?

Each scenario button sends a hidden text prompt to the AI through the data channel. The AI reads the scenario setup and immediately commits to the role. You will not see the scenario prompt in the transcript, only the AI's in-character response.

If you want to switch scenarios mid-conversation, just click a different button. The AI transitions to the new role.

  • When you are finished testing, click the Disconnect button (the same button, now red) to end the session.

Keep sessions short while testing

The OpenAI Realtime API charges per audio token. A 10-minute conversation costs approximately $1-3. Disconnect when you are done experimenting to avoid unnecessary charges.

Connection failing or no audio?

  • If the status stays on "Connecting" and then reverts to "Disconnected", open Chrome DevTools (press F12) and check the Console tab for errors. A 401 error means your API key in .env is invalid or missing.
  • A 429 error means your OpenAI account does not have enough credit or is not on Tier 1. Add at least $5 of credit at platform.openai.com under Settings > Billing.
  • If you hear no audio response, check that your system volume is up and that Chrome is not muted. The AI's voice plays through a hidden audio element.
  • If the transcript shows your speech but the AI never responds, confirm your server terminal is not showing errors. Restart the server by pressing Ctrl+C and running the start command again.

Help me debug my Realtime API connection issue.

Secret mission

Dutch Idiom Challenge Mode

Your conversation partner is great for general practice, but real fluency means mastering idioms. Add a challenge mode that teaches you a random Dutch expression and dares you to weave it into a natural conversation.

Clean Up Your Resources

Clean Up Your Resources

The OpenAI Realtime API charges per audio token, not per hour of server uptime. You only pay when actively connected to a voice session. A 10-minute conversation costs approximately $1-3.

Cost warning

You are only charged while your browser is connected to a live voice session. Stopping the Node.js server or closing the browser tab stops all charges immediately. If you leave a session connected and walk away, it will auto-disconnect after 60 minutes (the maximum session duration).

Resources you used:

  • The dutch-partner project folder on your Desktop (contains server.js, .env, and the public/ folder).
  • OpenAI API key (if you created one specifically for this project).

Keep everything running

No action needed. Choose this if you plan to keep practicing Dutch regularly.

  • Your project files stay on your Desktop, ready for your next session.
  • You only pay when you click Connect and start speaking. No background charges.
  • To start a new practice session anytime, open your terminal, navigate to dutch-partner, and run node --env-file=.env server.js.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your files stay on disk for next time.

  • In the terminal where the server is running, press Ctrl+C to stop it.
  • No charges accrue when the server is stopped. Your API key remains active for future sessions.
  • To resume later, open your terminal, navigate to dutch-partner, and run node --env-file=.env server.js again.

Delete - I don't want to use this again

Remove all project files and optionally revoke your API key.

  • If the server is still running, press Ctrl+C in the terminal to stop it.

Delete the project folder by running this command in your terminal:

macOS

cd ~/Desktop
rm -rf dutch-partner

Windows

cd ~\Desktop
Remove-Item -Recurse -Force dutch-partner
  • To revoke your OpenAI API key, go to platform.openai.com.
  • Navigate to Settings, then API Keys.
  • Find the key you created for this project and delete it. This is optional if you plan to use the key for other projects.

Nice Work!

Nice Work!

Gefeliciteerd! You just built a real-time AI Dutch conversation partner powered by OpenAI's Realtime API.

You can open this app anytime to practice speaking Dutch with an AI tutor that corrects your pronunciation, teaches you idioms, and adapts to your level.

What you learned:

  • Built a Node.js server that securely creates OpenAI Realtime API sessions without exposing your API key to the browser.
  • Connected a browser to voice-to-voice AI using WebRTC, handling real-time audio streaming in both directions.
  • Crafted a system prompt that turns a general-purpose LLM into a dedicated Dutch language tutor with pronunciation correction and scenario-based roleplay.
  • Built an idiom challenge mode that teaches authentic Dutch expressions and challenges you to use them naturally in conversation.

Ready to quiz yourself?