Build an AI chat buddy that answers your questions on the LCD

Type a question on a web page, and the ESP32 asks Claude, a large language model, then shows the answer on its LCD, two lines at a time. It remembers the last few questions, so you can have a real...
  • Level:Advanced
  • Duration: 1 h 10 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 an AI chat buddy that answers your questions on the LCD – EEMENTOR STEM™ lesson

Chatbots like Claude are large language models: AI trained on huge amounts of text, which can answer questions, explain things and hold a conversation. They run on big computers in data centres, but any device that can reach the internet can talk to them through an API. In this lesson your ESP32 becomes a chat buddy. You type a question on a little web page, the ESP32 sends it to Claude, and the answer appears on the 1602 LCD, two lines at a time, and on the page. Its system prompt makes it a friendly science buddy that keeps answers short enough for a tiny screen.

What you'll learn

  • What a large language model and an API are
  • How a system prompt shapes the answers
  • How to send a request and read the JSON answer on the ESP32
  • How a chatbot "remembers" a conversation

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)
  • A Claude API key from an adult's Anthropic account
  • A 2.4 GHz Wi-Fi network
  • The printed parts from the 3D printing box below (chat buddy), or the cardboard version described there
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Build an AI chat buddy that answers your questions on the LCD
Breadboard view. Check each connection against the wiring table below.
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

The LCD is wired as in the LCD weather station.

Get an API key

  1. Claude's API is a paid service, and an account must belong to an adult (18+). Ask a parent or teacher to create an account at console.anthropic.com, add a small amount of credit and set a low monthly spend limit under Billing.
  2. Under API keys, create a key and copy it. It starts with sk-ant-. It works like a password and a credit card in one: never share it, never post your sketch online with the key in it, and delete the key in the Console when the project is finished.
  3. Each question costs well under one cent with the settings in this sketch. Check the Console's usage page now and then.

The sketch

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

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

#include <WiFi.h>
#include <WebServer.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LiquidCrystal.h>

const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";

const char* API_KEY = "YOUR_ANTHROPIC_API_KEY";   // keep this secret!
const char* MODEL = "claude-opus-5-5";
const char* SYSTEM = "You are Sparky, a cheerful science buddy for children aged 10 to 14. "
  "Your answers appear on a tiny 16x2 screen, so answer in one or two short sentences, at most 200 characters. "
  "Use only plain English letters, numbers and simple punctuation: no emoji, no symbols, no lists. "
  "Be kind and keep every answer suitable for children.";

LiquidCrystal lcd(19, 23, 18, 17, 16, 4);   // RS, E, D4, D5, D6, D7
WebServer server(80);
const int MEMORY = 6;                        // the last 3 questions and answers
String roles[MEMORY], texts[MEMORY];
int remembered = 0;
String pending = "";                         // a question waiting to be asked

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

// Only plain letters fit on the LCD: replace anything else with a space
String plain(const String& s) {
  String out = "";
  for (unsigned int i = 0; i < s.length(); i++) {
    char ch = s[i];
    out += (ch >= 32 && ch < 127) ? ch : ' ';
  }
  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();
}

// Ask Claude. roles[] are "user" or "assistant", texts[] the messages, n how many.
String askClaude(const char* system, const String roles[], const String texts[], int n) {
  JsonDocument request;
  request["model"] = 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;
  JsonArray messages = request["messages"].to<JsonArray>();
  for (int i = 0; i < n; i++) {
    JsonObject m = messages.add<JsonObject>();
    m["role"] = roles[i];
    m["content"] = texts[i];
  }
  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", API_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 API error %d: %s\n", status, response.c_str());
    if (status == 401) return "Please check my API key.";
    if (status == 429 || status == 529) return "I'm busy, ask me again in a minute.";
    return "Sorry, I could not reach Claude.";
  }

  JsonDocument filter;                            // keep only what we need from the answer
  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();
  return text;
}

void remember(const String& role, const String& text) {
  if (remembered == MEMORY) {                // forget the oldest question and answer
    for (int i = 2; i < MEMORY; i++) { roles[i - 2] = roles[i]; texts[i - 2] = texts[i]; }
    remembered -= 2;
  }
  roles[remembered] = role;
  texts[remembered] = text;
  remembered++;
}

String safe(String s) {
  s.replace("&", "&amp;");
  s.replace("<", "&lt;");
  return s;
}

void handleRoot() {
  String html = "<!doctype html><meta name=viewport content='width=device-width'><title>Sparky</title>"
    "<style>body{font-family:sans-serif;max-width:520px;margin:20px auto;padding:0 16px}input{width:100%;padding:10px;font-size:16px;box-sizing:border-box}"
    "p.q{text-align:right;color:#1B3A7B}p.a{background:#F1F5FC;border-radius:12px;padding:10px}</style><h1>Ask Sparky</h1>";
  for (int i = 0; i < remembered; i++) html += String("<p class=") + (roles[i] == "user" ? "q>" : "a>") + safe(texts[i]) + "</p>";
  if (pending.length()) html += "<p><i>Sparky is thinking...</i></p><meta http-equiv=refresh content=3>";
  html += "<form action='/ask' method=post><input name=q maxlength=200 placeholder='Ask a question' autofocus></form>";
  server.send(200, "text/html", html);
}

void handleAsk() {
  String q = server.arg("q");
  q.trim();
  if (q.length() > 0 && pending.length() == 0) pending = q;
  server.sendHeader("Location", "/");
  server.send(303);
}

void setup() {
  Serial.begin(115200);
  lcd.begin(16, 2);
  lcd.print("Connecting...");
  WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.print("Open http://");
  Serial.println(WiFi.localIP());
  showOnLcd("Ask me at " + WiFi.localIP().toString());
  server.on("/", handleRoot);
  server.on("/ask", HTTP_POST, handleAsk);
  server.begin();
}

void loop() {
  server.handleClient();
  turnPages();
  if (pending.length() > 0) {
    lcd.clear();
    lcd.print("Thinking...");
    remember("user", pending);
    String answer = askClaude(SYSTEM, roles, texts, remembered);
    remember("assistant", answer);
    Serial.println("Q: " + pending);
    Serial.println("A: " + answer);
    pending = "";
    showOnLcd(answer);
  }
}

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.

Chat

Open the Serial Monitor at 115200 baud: after connecting, the ESP32 prints its address, such as http://192.168.1.42. Open it in a browser on a phone or computer in the same Wi-Fi.

  1. The LCD shows the address to open. Type a question, such as "Why is the sky blue?", and press Enter.
  2. The LCD says "Thinking..." for a few seconds, then shows the answer two lines at a time.
  3. Ask a follow-up, like "And why are sunsets red?". Sparky remembers what you talked about.

How it works

askClaude() sends a request to Claude's Messages API: the model's name, a system prompt that tells Claude who it is and how to answer, and the conversation so far. It is written as JSON with the ArduinoJson library and sent over HTTPS with the API key in a header. A few seconds later the answer comes back, also as JSON. Its content is a list of blocks; the sketch keeps only the text blocks, the actual answer. effort: "low" asks for quick, simple answers, which suits short questions and keeps them cheap.

Claude doesn't remember anything between requests. To hold a conversation, the sketch sends the last few questions and answers every time. That is also why the cost grows if you send a long history.

If Claude declines a request, the answer's stop_reason is refusal, and the sketch says so politely. fallbacks: "default" lets the service try a different model first.

The system prompt is the most powerful part: it turns a general AI into "Sparky", sets the tone and keeps answers short and in plain letters, because the LCD can't show emoji or accented letters. Try changing it and see how much the answers change.

The question is asked from loop(), not from the web page handler, so the page can answer at once and then show "thinking" while the ESP32 waits for Claude.

Try this next

  • Change the system prompt to make Sparky a pirate, a football coach or a quiz master.
  • Make a quiz mode: Sparky asks you a question and checks your answer.
  • Add a "new topic" button that clears the memory.

Troubleshooting

  • "Please check my API key": copy the key again from the Console; it must start with sk-ant- and have no spaces.
  • "I could not reach Claude": check the Serial Monitor for the error. Make sure the account has credit and the spend limit isn't reached.
  • The LCD lights up but shows nothing, or only black boxes: turn the potentiometer slowly until the text appears.
  • Strange characters on the LCD: check D4 to D7 on GPIO 18, 17, 16 and 4.
  • 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.
  • A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.

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