Build a door greeter that welcomes visitors with ChatGPT and Claude

A motion sensor notices when someone walks up, a chime plays, and ChatGPT and Claude take turns writing a fresh welcome for the LCD, using the time of day, the temperature and how many visitors came today. If the internet...
  • Level:Advanced
  • Duration: 1 h 15 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Build a door greeter that welcomes visitors with ChatGPT and Claude – EEMENTOR STEM™ lesson

A sign on the door always says the same thing. Your greeter never does. A PIR motion sensor notices when someone walks up to the door of your room or classroom, a little chime plays, and the LCD says hello straight away. Meanwhile the ESP32 tells an AI what it knows: the day and time, the temperature, and that this is visitor number 5 today. ChatGPT and Claude take turns writing a fresh, friendly welcome, such as "Good evening, visitor number 5! It's a cosy 23 degrees in here.", and the LCD shows who wrote it. You'll also learn how to keep a gadget that uses AI cheap and reliable.

What you'll learn

  • How a sensor event can trigger a question to an AI
  • How to give an AI context: time, temperature and a count
  • Why a cooldown keeps the cost down
  • How a fallback keeps the gadget working when the AI can't answer

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • 1602 LCD display, 10 kΩ potentiometer and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
  • HC-SR501 PIR motion sensor (ESP32 IoT Starter Kit)
  • DHT11 temperature and humidity module (ESP32 IoT Starter Kit)
  • Passive buzzer (ESP32 IoT Starter Kit)
  • An OpenAI API key (for ChatGPT) and a Claude API key, from an adult's accounts
  • A 2.4 GHz Wi-Fi network
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Build a door greeter that welcomes visitors with ChatGPT and Claude
Breadboard view. Check each connection against the wiring table below.

Build it step by step

Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.

Step 1 of 5: Power rails: 5V and GND from the ESP32
Step 1. Power rails: 5V and GND from the ESP32
Step 2 of 5: 1602 LCD: RS, E and D4–D7 to the ESP32, plus power, contrast knob and backlight
Step 2. 1602 LCD: RS, E and D4–D7 to the ESP32, plus power, contrast knob and backlight
Step 3 of 5: DHT11: S → GPIO 13, + → 3V3, − → − rail
Step 3. DHT11: S → GPIO 13, + → 3V3, − → − rail
Step 4 of 5: Passive buzzer: + → GPIO 26, − → − rail
Step 4. Passive buzzer: + → GPIO 26, − → − rail
Step 5 of 5: PIR motion sensor: VCC → 5V, OUT → GPIO 27, GND → − rail
Step 5. PIR motion sensor: VCC → 5V, OUT → GPIO 27, GND → − rail
From To
LCD VSS, RW and K GND
LCD VDD 5V
LCD V0 Middle pin (W) of the potentiometer
LCD RS ESP32 GPIO 19
LCD E ESP32 GPIO 23
LCD D4, D5, D6, D7 ESP32 GPIO 18, 17, 16, 4
LCD D0 to D3 Not connected
LCD A (backlight +) 220 Ω resistor, then 5V
Potentiometer outer pins 5V and GND
PIR VCC ESP32 5V
PIR OUT ESP32 GPIO 27
PIR GND GND
DHT11 S (data) ESP32 GPIO 13
DHT11 + ESP32 3V3
DHT11 − GND
Passive buzzer + ESP32 GPIO 26
Passive buzzer − GND

The LCD is wired as in the AI chat buddy. It runs on 5V but only listens to the ESP32, so its 5V never reaches the ESP32's pins. The PIR sensor runs on 5V too, but its OUT pin only gives 3.3 V, which is safe. The DHT11 runs on 3V3 instead, because it talks back to the ESP32.

Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.

Set the PIR's two orange knobs as in the motion alert lesson: time fully anticlockwise (shortest) and sensitivity in the middle. Point it at the space just in front of the door.

Get the two API keys

ChatGPT is made by OpenAI and Claude by Anthropic. You know them as chat apps, but a board can reach the same AI models through each company's API. Both APIs are paid services that charge a tiny amount per question, and both accounts must belong to an adult (18+), so ask a parent or teacher to do these steps with you.

  1. For ChatGPT: create an account at platform.openai.com, add a small amount of credit under Billing and set a low monthly budget in the settings. Under API keys, create a new secret key and copy it. It starts with sk-.
  2. For Claude: create an account at console.anthropic.com, add a small amount of credit and set a low monthly spend limit under Billing. Under API keys, create a key and copy it. It starts with sk-ant-.
  3. An API key works like a password and a credit card in one. Never share it, never post your sketch online with a key in it, and delete the keys when the project is finished.

Only have one key? The sketch still works: just use that AI. The other one answers "Please check my API key."

The sketch

In the Arduino IDE, open Library Manager and install the ArduinoJson library by Benoit Blanchon and the DHT sensor library by Adafruit. LiquidCrystal comes with the Arduino IDE.

Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).

Paste your two keys into OPENAI_KEY and CLAUDE_KEY, and set TIME_ZONE to yours (search for "POSIX time zone" and your city; the example is for Central Europe). WHO chooses the writer: 0 for always ChatGPT, 1 for always Claude, 2 to take turns.

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LiquidCrystal.h>
#include <DHT.h>
#include <time.h>

const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TIME_ZONE = "CET-1CEST,M3.5.0,M10.5.0/3";

const char* OPENAI_KEY = "YOUR_OPENAI_API_KEY";      // for ChatGPT: keep it secret!
const char* OPENAI_MODEL = "gpt-6-luna";
const char* CLAUDE_KEY = "YOUR_ANTHROPIC_API_KEY";   // for Claude: keep it secret!
const char* CLAUDE_MODEL = "claude-opus-5-5";
const char* SYSTEM = "You are the friendly door greeter of a family home or a classroom. Someone has just walked up to the door. "
  "Write a short, warm welcome using only the facts you are given: the time of day, the indoor temperature and the visitor number. "
  "You don't know who the visitor is, so never use a name. Be kind and suitable for children. "
  "Your words appear on a tiny 16x2 screen: one or two short sentences, at most 110 characters, "
  "plain English letters, numbers and simple punctuation only, no emoji.";

const int WHO = 2;                                // 0 = always ChatGPT, 1 = always Claude, 2 = take turns
const int PIR_PIN = 27, DHT_PIN = 13, BUZZER_PIN = 26;
const unsigned long QUIET_MS = 60000;             // at most one greeting a minute
const unsigned long SHOW_MS = 25000;              // how long a greeting stays on the LCD
const char* SPARE[] = {"Welcome! Come on in.", "Hello there, nice to see you!", "Hi! Make yourself at home."};

LiquidCrystal lcd(19, 23, 18, 17, 16, 4);         // RS, E, D4, D5, D6, D7
DHT dht(DHT_PIN, DHT11);
int visitorsToday = 0, today = -1;
bool claudeTurn = false, wasMotion = false;
unsigned long lastGreeting = 0, showUntil = 0, lastDayCheck = 0;
String lastWords = "";

String pageText = "";
int pageStart = 0;
unsigned long nextPage = 0;

// Only plain letters fit on the LCD: swap curly quotes and long dashes for plain ones,
// and replace any other symbol with one space
String plain(const String& s) {
  String out = "";
  for (unsigned int i = 0; i < s.length(); i++) {
    unsigned char ch = s[i];
    if (ch >= 32 && ch < 127) out += (char)ch;
    else if (ch == 0xE2 && i + 2 < s.length() && (unsigned char)s[i + 1] == 0x80) {
      unsigned char c = s[i + 2];
      out += (c == 0x98 || c == 0x99) ? '\'' : (c == 0x9C || c == 0x9D) ? '"' : (c == 0x93 || c == 0x94) ? '-' : ' ';
      i += 2;
    }
    else if (ch < 0x80 || ch >= 0xC0) out += ' ';   // the extra bytes of a symbol (0x80-0xBF) are skipped
  }
  return out;
}

// One LCD line of up to 16 characters starting at 'from', breaking at a space
int fitLine(const String& s, int from, String& line) {
  int end = min((int)s.length(), from + 16);
  if (end < (int)s.length()) {
    int space = s.lastIndexOf(' ', end);
    if (space > from) end = space;
  }
  line = s.substring(from, end);
  while (end < (int)s.length() && s[end] == ' ') end++;
  return end;
}

void showPage() {
  String one, two;
  int next = fitLine(pageText, pageStart, one);
  int after = fitLine(pageText, next, two);
  lcd.clear();
  lcd.print(one);
  lcd.setCursor(0, 1);
  lcd.print(two);
  pageStart = after >= (int)pageText.length() ? 0 : after;   // start again at the end
}

void showOnLcd(const String& text) {
  pageText = plain(text);
  pageStart = 0;
  nextPage = 0;
}

void turnPages() {
  if (pageText.length() == 0 || millis() < nextPage) return;
  nextPage = millis() + 3500;
  showPage();
}

bool aiOk = false;                               // did the last question get a real answer?

String errorText(const char* who, int status) {
  if (status == 401) return "Please check my API key.";
  if (status == 429) return "Too many questions, or no credit left. Wait a minute.";
  if (status == 503 || status == 529) return String(who) + " is busy. Try again in a minute.";
  return String("Sorry, I could not reach ") + who + ".";
}

// Ask ChatGPT through OpenAI's Responses API
String askChatGPT(const char* system, const String& question) {
  aiOk = false;
  JsonDocument request;
  request["model"] = OPENAI_MODEL;
  request["instructions"] = system;              // the system prompt
  request["input"] = question;                   // one question from the user
  request["max_output_tokens"] = 300;            // short answers only
  request["reasoning"]["effort"] = "none";       // answer straight away, no thinking
  request["store"] = false;                      // don't keep the conversation on the server
  String body;
  serializeJson(request, body);

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(30000);
  http.begin(client, "https://api.openai.com/v1/responses");
  http.addHeader("Content-Type", "application/json");
  http.addHeader("Authorization", String("Bearer ") + OPENAI_KEY);
  int status = http.POST(body);
  String response = http.getString();
  http.end();
  if (status != 200) {
    Serial.printf("ChatGPT error %d: %s\n", status, response.c_str());
    return errorText("ChatGPT", status);
  }

  JsonDocument filter;                           // keep only what we need from the answer
  filter["output"][0]["type"] = true;
  filter["output"][0]["content"][0]["type"] = true;
  filter["output"][0]["content"][0]["text"] = true;
  JsonDocument answer;
  if (deserializeJson(answer, response, DeserializationOption::Filter(filter))) return "Sorry, I got a strange answer.";
  String text = "";
  for (JsonObject item : answer["output"].as<JsonArray>()) {
    if (item["type"] != "message") continue;     // skip other kinds of item
    for (JsonObject part : item["content"].as<JsonArray>()) {
      if (part["type"] == "refusal") return "Sorry, I can't help with that one.";
      if (part["type"] == "output_text") text += part["text"].as<String>();
    }
  }
  text.trim();
  aiOk = text.length() > 0;
  return aiOk ? text : "Sorry, I got an empty answer.";
}

// Ask Claude through Anthropic's Messages API
String askClaude(const char* system, const String& question) {
  aiOk = false;
  JsonDocument request;
  request["model"] = CLAUDE_MODEL;
  request["max_tokens"] = 2000;                  // room for its thinking and a short answer
  request["output_config"]["effort"] = "low";    // quick, simple answers
  request["fallbacks"] = "default";              // if this model declines, try another one
  request["system"] = system;                    // the system prompt
  JsonObject m = request["messages"].to<JsonArray>().add<JsonObject>();
  m["role"] = "user";                            // one question from the user
  m["content"] = question;
  String body;
  serializeJson(request, body);

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(60000);                        // thinking can take a few seconds
  http.begin(client, "https://api.anthropic.com/v1/messages");
  http.addHeader("content-type", "application/json");
  http.addHeader("x-api-key", CLAUDE_KEY);
  http.addHeader("anthropic-version", "2023-06-01");
  http.addHeader("anthropic-beta", "server-side-fallback-2026-07-01");
  int status = http.POST(body);
  String response = http.getString();
  http.end();
  if (status != 200) {
    Serial.printf("Claude error %d: %s\n", status, response.c_str());
    return errorText("Claude", status);
  }

  JsonDocument filter;
  filter["stop_reason"] = true;
  filter["content"][0]["type"] = true;
  filter["content"][0]["text"] = true;
  JsonDocument answer;
  if (deserializeJson(answer, response, DeserializationOption::Filter(filter))) return "Sorry, I got a strange answer.";
  if (answer["stop_reason"] == "refusal") return "Sorry, I can't help with that one.";
  String text = "";
  for (JsonObject block : answer["content"].as<JsonArray>()) {
    if (block["type"] == "text") text += block["text"].as<String>();   // skip the other kinds of block
  }
  text.trim();
  aiOk = text.length() > 0;
  return aiOk ? text : "Sorry, I got an empty answer.";
}

void chime() {
  tone(BUZZER_PIN, 880, 150);
  delay(200);
  tone(BUZZER_PIN, 660, 300);
}

void showIdle() {
  pageText = "";
  lcd.clear();
  lcd.print("Hello, door!");
  lcd.setCursor(0, 1);
  lcd.print("Visitors: " + String(visitorsToday));
}

// A new day: start counting again
void checkNewDay(const struct tm& local) {
  if (local.tm_yday == today) return;
  today = local.tm_yday;
  visitorsToday = 0;
  if (pageText.length() == 0) showIdle();
}

void greet() {
  time_t now = time(nullptr);
  struct tm local;
  localtime_r(&now, &local);
  checkNewDay(local);
  visitorsToday++;
  chime();
  lcd.clear();
  lcd.print("Hello! Welcome!");                    // say something at once...
  lcd.setCursor(0, 1);
  lcd.print("...");

  char when[32];
  strftime(when, sizeof(when), "%A, %H:%M", &local);
  String facts = String("Day and time: ") + when + ". Visitor number today: " + visitorsToday + ".";
  float t = dht.readTemperature();
  if (!isnan(t)) facts += " Indoor temperature: " + String(t, 0) + " C.";
  if (lastWords.length() > 0) facts += " Your last welcome was: \"" + lastWords + "\". Say something different.";
  Serial.println(facts);

  bool claude = WHO == 1 || (WHO == 2 && claudeTurn);
  claudeTurn = !claudeTurn;
  String who = claude ? "Claude" : "ChatGPT";
  String words = claude ? askClaude(SYSTEM, facts) : askChatGPT(SYSTEM, facts);   // ...then ask the AI
  Serial.println(who + ": " + words);
  if (aiOk) {
    lastWords = words;
    showOnLcd(words + " -" + who);
  } else {
    showOnLcd(SPARE[random(3)]);                  // no answer: use a spare greeting
  }
  showUntil = millis() + SHOW_MS;
}

void setup() {
  Serial.begin(115200);
  pinMode(PIR_PIN, INPUT);
  dht.begin();
  lcd.begin(16, 2);
  lcd.print("Connecting...");
  WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  configTzTime(TIME_ZONE, "pool.ntp.org");
  while (time(nullptr) < 1700000000) delay(200);  // wait for the internet time
  showIdle();
  Serial.println("Ready. Walk up to the sensor.");
}

void loop() {
  bool motion = digitalRead(PIR_PIN) == HIGH;
  if (motion && !wasMotion && (lastGreeting == 0 || millis() - lastGreeting > QUIET_MS)) {
    lastGreeting = millis();
    greet();
  }
  wasMotion = motion;

  if (pageText.length() > 0 && millis() > showUntil) showIdle();
  turnPages();

  if (millis() - lastDayCheck > 60000) {          // once a minute: is it a new day?
    lastDayCheck = millis();
    time_t now = time(nullptr);
    struct tm local;
    localtime_r(&now, &local);
    checkNewDay(local);
  }
}

client.setInsecure() keeps the connection encrypted but skips checking the server's certificate. That keeps the sketch short and is fine for a learning project. For a device you leave running for years, load the server's root certificate with client.setCACert() instead.

Greet your first visitors

  1. Upload the sketch and give the PIR sensor a minute to settle: it can trigger by itself just after switching on. The LCD shows Hello, door! and Visitors: 0.
  2. Walk up to the sensor. The chime plays and the LCD shows "Hello! Welcome!" at once. A few seconds later the AI's welcome scrolls past, ending with -ChatGPT.
  3. Walk away, wait a minute and come back. This time Claude writes the welcome. The Serial Monitor at 115200 baud shows the facts the ESP32 sent and each greeting.
  4. Unplug the Wi-Fi router for a moment, or put a wrong key in the sketch: the greeter still says hello with one of its spare greetings.

What you should see

LCD screens Hello, door! Visitors: 3, then Hello! Welcome! with the 880 Hz and 660 Hz chime, then Claude's welcome ending -Claude, and the facts and greeting in the Serial Monitor

How it works

A PIR signal timeline where only greetings 60 s apart count, and the four steps of greet(): hello at once, facts, ChatGPT and Claude taking turns, and a spare greeting if the AI fails

Both AIs are large language models that run in big data centres. The ESP32 only sends them text and gets text back. askChatGPT() and askClaude() do the same job, but each company's API wants the request in its own shape:

ChatGPT (OpenAI Responses API) Claude (Anthropic Messages API)
Address api.openai.com/v1/responses api.anthropic.com/v1/messages
Key goes in Authorization: Bearer sk-... x-api-key: sk-ant-...
System prompt instructions system
The question input messages, with the role user
Length and speed max_output_tokens: 300, reasoning.effort: "none" max_tokens: 2000, effort: "low"
The answer is in output → message → output_text content → text blocks

The ArduinoJson library builds the request and reads the answer. The answer contains much more than the text, so a filter keeps only the parts the sketch needs and saves memory. If an AI refuses a question, both functions say so politely, and if something goes wrong the Serial Monitor shows the error the API sent back.

When to ask. The PIR's OUT pin goes HIGH when it sees movement. The sketch only reacts to the moment it changes from LOW to HIGH, and then stays quiet for QUIET_MS, one minute. Without that cooldown, someone standing at the door could trigger dozens of questions, and each one costs money. Asking the AI only when something happens, and not too often, is the most important rule for any gadget that uses a paid AI.

What to tell it. The AI has no clock and no thermometer, so the ESP32 writes the facts into the question: "Day and time: Friday, 16:05. Visitor number today: 5. Indoor temperature: 23 C." It also sends the last welcome and asks for something different, so the greetings don't repeat. The time comes from the internet with NTP.

Never leave a visitor waiting. The LCD says hello before the AI is even asked, because the answer can take a few seconds. If the answer doesn't come, aiOk is false and the greeter picks a spare greeting from SPARE. Good AI products always have a plan B like this.

Try this next

  • Add a "Do not disturb" mode: a button that makes the greeter say "Please come back later" without asking an AI.
  • Give the greeter a theme for special days: tell the AI it is someone's birthday or the first day of the holidays.
  • Log each greeting with its writer to the Serial Monitor for a day, then pick your favourite five.

Troubleshooting

  • "Please check my API key": copy the key again. The OpenAI key starts with sk-, the Claude key with sk-ant-; make sure it is in the right line and has no spaces.
  • "Too many questions, or no credit left": wait a minute, and check that the account has credit and the monthly limit isn't reached.
  • "Sorry, I could not reach…": open the Serial Monitor at 115200 baud: it prints the error the API sent back. A message about the model means the model name has changed: look up a current one on the company's models page and put it in OPENAI_MODEL or CLAUDE_MODEL.
  • It greets nobody, or greets all the time: turn the PIR's sensitivity knob, keep it away from radiators and sunny windows, and give it a minute to settle after switching on.
  • The time is an hour wrong: check TIME_ZONE.
  • The greetings never mention the temperature: the facts in the Serial Monitor have no "Indoor temperature". Check that the DHT11's S goes to GPIO 13 and + to 3V3, not 5V.
  • No chime: use the passive buzzer (no sticker, green board visible underneath), with + on GPIO 26.
  • The LCD lights up but shows nothing, or only black boxes: turn the potentiometer slowly until the text appears.
  • It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit