ChatGPT and Claude are the two best-known AI chatbots. Most people type questions into them, but they can't see or feel anything about the world around you. In this lesson you give them senses. A DHT11 sensor measures the temperature and humidity in your room, and the ESP32 shows the readings on a 1602 LCD. Press the left button and the ESP32 sends the readings to ChatGPT; press the right one and it asks Claude. A few seconds later a tip appears on the LCD, such as "It's a bit dry in here. A bowl of water near the radiator will help." Ask both and see how two AIs from two different companies answer the same question.
What you'll learn
- How a board talks to ChatGPT and Claude through their APIs
- How to turn sensor readings into a question for an AI
- How a system prompt keeps answers short enough for a tiny screen
- How the two APIs are alike and how they differ
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)
- DHT11 temperature and humidity module (ESP32 IoT Starter Kit)
- 2 push buttons
- 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

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.





| 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 |
| DHT11 S (data) | ESP32 GPIO 27 |
| DHT11 + | ESP32 3V3 |
| DHT11 − | GND |
| "Ask ChatGPT" button | ESP32 GPIO 32 and GND |
| "Ask Claude" button | ESP32 GPIO 33 and 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 DHT11 runs on 3V3 instead, because it talks back to the ESP32.
Put the ChatGPT button (GPIO 32) on the left and the Claude button (GPIO 33) on the right, to match the arrows on the LCD.
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
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.
-
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-. -
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-. - 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.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LiquidCrystal.h>
#include <DHT.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
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 a friendly room coach in a child's bedroom. "
"You get readings from sensors in the room. Give one practical tip to make the room more comfortable "
"for sleeping, reading or homework, based only on those readings. "
"Your answer appears on a tiny 16x2 screen: one or two short sentences, at most 140 characters. "
"Use only plain English letters, numbers and simple punctuation: no emoji, no symbols.";
const int DHT_PIN = 27, GPT_BUTTON = 32, CLAUDE_BUTTON = 33;
const unsigned long SHOW_MS = 30000; // how long a tip stays on the LCD
LiquidCrystal lcd(19, 23, 18, 17, 16, 4); // RS, E, D4, D5, D6, D7
DHT dht(DHT_PIN, DHT11);
unsigned long showUntil = 0, nextReading = 0;
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 showReadings() {
float t = dht.readTemperature(), h = dht.readHumidity();
lcd.clear();
if (isnan(t) || isnan(h)) lcd.print("No DHT11 reading");
else lcd.printf("%.1f%cC %.0f%% hum", t, 223, h); // 223 is the degree sign on the LCD
lcd.setCursor(0, 1);
lcd.print("<ChatGPT Claude>");
}
void ask(bool claude, int button) {
while (digitalRead(button) == LOW) delay(10); // wait until the button is let go
float t = dht.readTemperature(), h = dht.readHumidity();
if (isnan(t) || isnan(h)) {
showOnLcd("I can't read the DHT11. Check its wires.");
showUntil = millis() + 8000;
return;
}
String who = claude ? "Claude" : "ChatGPT";
lcd.clear();
lcd.print("Asking " + who);
lcd.setCursor(0, 1);
lcd.print("Please wait...");
String readings = "Room temperature: " + String(t, 1) + " C. Relative humidity: " + String(h, 0) + " %.";
Serial.println(readings);
unsigned long start = millis();
String tip = claude ? askClaude(SYSTEM, readings) : askChatGPT(SYSTEM, readings);
Serial.printf("%s (%lu ms): %s\n", who.c_str(), millis() - start, tip.c_str());
showOnLcd(who + ": " + tip);
showUntil = millis() + SHOW_MS;
}
void setup() {
Serial.begin(115200);
pinMode(GPT_BUTTON, INPUT_PULLUP);
pinMode(CLAUDE_BUTTON, INPUT_PULLUP);
dht.begin();
lcd.begin(16, 2);
lcd.print("Connecting...");
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println("Connected. Press a button to ask for a tip.");
}
void loop() {
if (digitalRead(GPT_BUTTON) == LOW) ask(false, GPT_BUTTON);
else if (digitalRead(CLAUDE_BUTTON) == LOW) ask(true, CLAUDE_BUTTON);
if (pageText.length() > 0 && millis() > showUntil) { // the tip has been shown long enough
pageText = "";
nextReading = 0;
}
if (pageText.length() > 0) turnPages();
else if (millis() >= nextReading) { // no tip: show the readings
showReadings();
nextReading = millis() + 3000;
}
}
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.
Ask for tips
- Upload the sketch. The LCD says "Connecting...", then shows the temperature and humidity, such as
22.5°C 48% hum, and<ChatGPT Claude>under it. If the text is hard to read, turn the potentiometer. - Press the left button. The LCD says "Asking ChatGPT", and a few seconds later the tip scrolls past, two lines at a time. The Serial Monitor at 115200 baud shows the readings, the whole tip and how many milliseconds the answer took.
- Press the right button to ask Claude. Compare the two tips: do they agree? Which one is more useful?
- Breathe gently on the DHT11 for a few seconds. The humidity goes up. Ask again and see if the tips change.
What you should see

How it works

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.
Notice the split of jobs. The facts come from the sensor: the AI never sees your room, only the sentence the ESP32 writes, such as "Room temperature: 22.5 C. Relative humidity: 48 %." The words come from the AI. The system prompt says "based only on those readings" so neither AI makes up things the sensor didn't measure, and "at most 140 characters" so the tip fits on a few LCD pages.
Each question costs less than a cent. The answer usually takes a few seconds, sometimes up to 15: the request travels to a data centre, the model writes the answer word by word, and the whole answer travels back.
Try this next
- Press both buttons one after the other ten times and count which AI gave the tips you liked best. The last lesson of this set turns that into a fair blind test.
- Change the system prompt: make the coach a grumpy pirate, or ask for tips for a reptile tank instead of a bedroom.
- Add a third button that asks both AIs and shows both tips one after the other.
Troubleshooting
-
"Please check my API key": copy the key again. The OpenAI key starts with
sk-, the Claude key withsk-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_MODELorCLAUDE_MODEL. - "No DHT11 reading": check that S goes to the right GPIO and + to 3V3, not 5V.
- The LCD lights up but shows nothing, or only black boxes: turn the potentiometer slowly until the text appears.
- Nothing happens when you press a button: the button must connect its GPIO to GND. Turn it a quarter turn on the breadboard: two of its legs are always connected.
- 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.