Build a talking plant that tells you how it feels with AI

Sensors measure the soil and air around a houseplant, and Claude turns the numbers into a message in the plant's own voice. Three times a day, and whenever it gets thirsty, the plant sends you a note through ntfy.
  • 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 a talking plant that tells you how it feels with AI – EEMENTOR STEM™ lesson

Plants can't talk, but with a bit of AI they can send you messages. The ESP32 measures how wet the soil is and the temperature and humidity around the plant. Three times a day it gives those numbers to Claude together with a description of the plant's personality, and Claude writes a short message in the plant's voice: "Good morning! My roots are nicely damp, but it's a bit chilly by the window." When the soil gets dry, the plant asks for water straight away. The messages arrive on your phone through ntfy, and the plant's eyes glow green when it is happy and red when it is thirsty.

What you'll learn

  • How to give a language model data and let it write about it
  • How a personality in the system prompt changes the style
  • How to send messages on a schedule with internet time
  • Why the facts come from sensors and the words from the AI

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • Soil moisture module with probe (45-in-1 Sensor Kit)
  • DHT11 temperature and humidity sensor (ESP32 IoT Starter Kit)
  • Green and red LEDs and 2 resistors, 220 Ω (ESP32 IoT Starter Kit)
  • A Claude API key from an adult's Anthropic account, and the ntfy app on a phone
  • A potted plant
  • A 2.4 GHz Wi-Fi network
  • The printed parts from the 3D printing box below (plant buddy stake), or the cardboard version described there
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Build a talking plant that tells you how it feels with AI
Breadboard view. Check each connection against the wiring table below.
From To
Soil module AO ESP32 GPIO 34
Soil module VCC and GND 3V3 and GND
DHT11 S (data) ESP32 GPIO 27
DHT11 + and − 3V3 and GND
ESP32 GPIO 25 220 Ω resistor, then the green LED's long leg
ESP32 GPIO 26 220 Ω resistor, then the red LED's long leg
Both LED short legs GND

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

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.

Set up ntfy

Install the ntfy app and subscribe to a long, random topic name, as in the motion alert lesson. Topics on the free server are public, so never use a name that is easy to guess.

The sketch

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

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

Change TIME_ZONE to your own time zone. The example is for Central Europe; search for "POSIX time zone" and your city to find yours (for example EET-2EEST,M3.5.0/3,M10.5.0/4 for Estonia or GMT0BST,M3.5.0/1,M10.5.0 for the UK).

Calibrate the probe as in the plant monitor: the reading in air goes into DRY_RAW, in water into WET_RAW. Give your plant a name and a personality.

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.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* NTFY_TOPIC = "YOUR_SECRET_TOPIC";

const char* API_KEY = "YOUR_ANTHROPIC_API_KEY";   // keep this secret!
const char* MODEL = "claude-opus-5-5";
const char* PLANT_NAME = "Basil";
const char* SYSTEM = "You are Basil, a cheerful, slightly dramatic basil plant on a kitchen window sill, "
  "writing a short note to the family who looks after you. You get your sensor readings. "
  "Write one or two sentences (under 250 characters) about how you feel, based only on those readings. "
  "If the soil is dry, ask for water. Be funny and kind, never rude. No hashtags.";

const int SOIL_PIN = 34, DHT_PIN = 27, GREEN_PIN = 25, RED_PIN = 26;
const int DRY_RAW = 3300, WET_RAW = 1300;
const int THIRSTY = 30;                     // soil moisture % that means "water me"
const int TALK_HOURS[3] = {8, 13, 19};      // when the plant sends its notes

DHT dht(DHT_PIN, DHT11);
bool thirstySent = false;
int lastTalkHour = -1;

// 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;
}

int soilPercent() {
  long sum = 0;
  for (int i = 0; i < 20; i++) sum += analogRead(SOIL_PIN);
  return constrain(map(sum / 20, DRY_RAW, WET_RAW, 0, 100), 0, 100);
}

void notify(const String& text) {
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.begin(client, String("https://ntfy.sh/") + NTFY_TOPIC);
  http.addHeader("Title", PLANT_NAME);
  http.addHeader("Tags", "seedling");
  http.POST(text);
  http.end();
}

void talk(const char* reason) {
  int soil = soilPercent();
  float t = dht.readTemperature(), h = dht.readHumidity();
  time_t now = time(nullptr);
  struct tm local;
  localtime_r(&now, &local);
  char when[32];
  strftime(when, sizeof(when), "%A %H:%M", &local);
  String readings = String("Time: ") + when + ". Reason for writing: " + reason +
                    ". Soil moisture: " + soil + " % (below " + THIRSTY + " % is dry). ";
  if (!isnan(t)) readings += "Air temperature: " + String(t, 1) + " C. Humidity: " + String(h, 0) + " %.";
  Serial.println(readings);
  String roles[1] = {"user"};
  String texts[1] = {readings};
  String message = askClaude(SYSTEM, roles, texts, 1);
  Serial.println(message);
  notify(message);
}

void setup() {
  Serial.begin(115200);
  pinMode(GREEN_PIN, OUTPUT);
  pinMode(RED_PIN, OUTPUT);
  dht.begin();
  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);
  talk("I was just switched on");
}

void loop() {
  int soil = soilPercent();
  bool thirsty = soil < THIRSTY;
  digitalWrite(GREEN_PIN, !thirsty);
  digitalWrite(RED_PIN, thirsty);

  if (thirsty && !thirstySent) {            // ask for water once, right away
    talk("my soil just got dry");
    thirstySent = true;
  }
  if (soil > THIRSTY + 10) thirstySent = false;   // watered: ready to ask again next time

  time_t now = time(nullptr);
  struct tm local;
  localtime_r(&now, &local);
  for (int h : TALK_HOURS) {
    if (local.tm_hour == h && local.tm_min == 0 && lastTalkHour != h) {
      lastTalkHour = h;
      talk("my regular note to the family");
    }
  }
  delay(20000);
}

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.

Meet your plant

  1. Upload the sketch. Within a few seconds your phone gets the plant's first note.
  2. Pull the probe out of the soil: the eyes turn red and the plant sends a thirsty message. Put it back.
  3. Change the personality in SYSTEM, for example a grumpy cactus or a royal orchid, and see how the messages change.

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.

Notice the split of jobs. The facts come from the sensors: the ESP32 measures, decides when the plant is thirsty, and only then asks Claude. Claude's job is the words: turning the numbers into a message with personality. The system prompt says "based only on those readings", so Claude doesn't make up facts the sensors didn't measure. This is how good AI products work: measured data and clear rules in code, and the language model for the language.

Three notes a day cost a few cents per month.

Try this next

  • Send the readings of the whole day with the evening note, so the plant can say how its day went.
  • Let a second plant write to the first one.
  • Add the light sensor so the plant can complain about the dark.

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.
  • No messages on the phone: check the ntfy topic letter by letter, in the sketch and in the app.
  • It is always thirsty: calibrate DRY_RAW and WET_RAW again.
  • 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