We will walk through a complete, working project: an agentic farm advisory assistant built with Gemma 4 through Google AI Studio's Gemini API. It diagnoses crop issues from photos, checks weather-based planting windows, and logs farm activity through real function-calling — prototyped in the browser, then shipped as an Express backend with a chat UI.
An advisory chatbot for smallholder farmers and agro-logistics platforms that can:
The model never guesses market prices or weather data — every factual answer comes from an actual function call against a backend, not the model's own assumptions.
Before writing any code, the entire agent was designed inside aistudio.google.com:
gemma-4-31b-it
for its multimodal (image) support
You are a friendly, practical farm advisory assistant for smallholder farmers
in Nigeria. Always use the provided tools for weather checks, market prices,
and activity logging — never guess prices or weather data. When a farmer
uploads a crop photo, examine it carefully before giving diagnosis and
next steps. Keep responses short, practical, and in plain language. Reply
in the same language or Pidgin the farmer writes in.
check_weather_window
, get_market_price
, log_farm_activity
, and diagnose_crop_image
— each with a JSON schema and a scoped descriptioncheck_weather_window
instead of answering from general knowledge@google/genai
Testing the image diagnosis directly in the browser first was the most valuable step — it's far easier to spot a vague description problem ("model just said 'looks unhealthy'") in a live chat than after it's buried in server logs.
// tools.js
export const tools = [{
functionDeclarations: [
{
name: "check_weather_window",
description: "Use when the farmer asks if it's safe or a good time to plant, spray, or harvest. Never guess weather.",
parameters: {
type: "OBJECT",
properties: {
location: { type: "STRING", description: "e.g. Port Harcourt, Owerri" },
activity: { type: "STRING", description: "planting, spraying, or harvesting" }
},
required: ["location", "activity"]
}
},
{
name: "get_market_price",
description: "Use ONLY when the farmer asks for the current price of a crop. Never guess a price.",
parameters: {
type: "OBJECT",
properties: {
crop: { type: "STRING", description: "e.g. cassava, maize, tomato" },
market: { type: "STRING", description: "e.g. Mile 1 Market, Port Harcourt" }
},
required: ["crop"]
}
},
{
name: "log_farm_activity",
description: "Use when the farmer reports completing an activity like planting, spraying, or harvesting.",
parameters: {
type: "OBJECT",
properties: {
farmerId: { type: "STRING" },
activity: { type: "STRING", description: "planting, spraying, harvesting" },
crop: { type: "STRING" },
notes: { type: "STRING" }
},
required: ["farmerId", "activity", "crop"]
}
},
{
name: "diagnose_crop_image",
description: "Use when the farmer uploads a photo of a crop showing signs of disease, pest damage, or poor health.",
parameters: {
type: "OBJECT",
properties: {
crop: { type: "STRING", description: "e.g. maize, tomato, cassava" },
symptomDescription: { type: "STRING", description: "Visible symptoms described from the image" }
},
required: ["crop", "symptomDescription"]
}
}
]
}];
const weatherData = {
"port harcourt": { rainChance: 20, condition: "clear, light wind" },
"owerri": { rainChance: 70, condition: "heavy rain expected" }
};
const marketPrices = {
cassava: { pricePerBag: 18500, currency: "NGN", market: "Mile 1 Market" },
maize: { pricePerBag: 22000, currency: "NGN", market: "Mile 1 Market" },
tomato: { pricePerBasket: 15000, currency: "NGN", market: "Mile 1 Market" }
};
const activityLog = [];
export async function check_weather_window({ location, activity }) {
const key = location.toLowerCase();
const weather = weatherData[key];
if (!weather) return { error: "Location not found in weather data" };
const safe = activity === "spraying" ? weather.rainChance < 40 : true;
return { location, activity, ...weather, recommendation: safe ? "Safe to proceed" : "Wait — rain expected, risk of runoff" };
}
export async function get_market_price({ crop, market }) {
const price = marketPrices[crop.toLowerCase()];
return price ? { crop, ...price } : { error: `No price data for ${crop}` };
}
export async function log_farm_activity({ farmerId, activity, crop, notes }) {
const entry = { farmerId, activity, crop, notes: notes || "", date: "2026-07-07" };
activityLog.push(entry);
return { logged: true, ...entry };
}
export async function diagnose_crop_image({ crop, symptomDescription }) {
// In production, this would call a vision model or trained classifier.
// Here Gemma 4's own multimodal reasoning already produced symptomDescription
// from the uploaded image before calling this tool.
const knownIssues = {
"brown spots on leaves": "Likely leaf blight — recommend copper-based fungicide, improve drainage",
"wilting despite watering": "Possible bacterial wilt or root rot — check soil drainage and remove affected plants"
};
const match = Object.keys(knownIssues).find(k => symptomDescription.toLowerCase().includes(k.split(" ")[0]));
return {
crop,
diagnosis: match ? knownIssues[match] : "Symptoms noted but inconclusive — recommend local extension officer visit",
};
}
export const toolFunctions = { check_weather_window, get_market_price, log_farm_activity, diagnose_crop_image };
The core of the backend is a loop that keeps resolving tool calls — including image-based diagnosis — until Gemma 4 returns a final plain-text answer:
// server.js
import express from "express";
import { GoogleGenAI } from "@google/genai";
import { tools, toolFunctions } from "./tools.js";
const app = express();
app.use(express.json({ limit: "10mb" })); // allow base64 image payloads
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const MODEL = "gemma-4-31b-it";
const MAX_STEPS = 4;
const SYSTEM_INSTRUCTION = `You are a friendly, practical farm advisory assistant...`;
const sessions = new Map();
app.post("/api/chat", async (req, res) => {
const { sessionId = "default", message, imageBase64 } = req.body;
if (!sessions.has(sessionId)) {
sessions.set(sessionId, client.chats.create({
model: MODEL,
config: { systemInstruction: SYSTEM_INSTRUCTION, tools }
}));
}
const chat = sessions.get(sessionId);
const parts = imageBase64
? [{ inlineData: { mimeType: "image/jpeg", data: imageBase64 } }, { text: message }]
: message;
let response = await chat.sendMessage({ message: parts });
let steps = 0;
while (response.functionCalls?.length && steps < MAX_STEPS) {
const call = response.functionCalls[0];
const result = await toolFunctions[call.name](call.args);
response = await chat.sendMessage({
message: [{ functionResponse: { name: call.name, response: result } }]
});
steps += 1;
}
res.json({ reply: response.text, toolStepsUsed: steps });
});
app.listen(3000, () => console.log("Agro advisory agent running on port 3000"));
With the server running (npm start
) and a GEMINI_API_KEY
from AI Studio set in .env
, here's what real requests return.
Request — weather check before spraying:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId":"farmer1","message":"Is it safe to spray my maize in Owerri today?"}'
Response:
{
"reply": "Not today — Owerri has a 70% chance of rain, which could wash off the spray before it works. Wait for a drier day.",
"toolStepsUsed": 1
}
Request — market price check:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId":"farmer1","message":"How much is cassava selling for now?"}'
Response:
{
"reply": "Cassava is currently going for NGN 18,500 per bag at Mile 1 Market.",
"toolStepsUsed": 1
}
Request — crop photo diagnosis (image + text):
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId":"farmer1","message":"My tomato plant looks sick, see photo","imageBase64":"<base64 jpeg data>"}'
Response:
{
"reply": "I see wilting and yellowing leaves on your tomato plant. This looks like bacterial wilt or root rot. Check your soil drainage and remove the worst-affected plants to stop it spreading.",
"toolStepsUsed": 1
}
Request — Pidgin, activity logging:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId":"farmer1","message":"I don finish plant my maize for farm today, farmer ID FARM-002"}'
Response:
{
"reply": "Good work! I don log say you plant maize today for your farm record (FARM-002). E dey saved.",
"toolStepsUsed": 1
}
toolFunctions
, not the model's own guess, so a farmer never gets rain-safety advice invented on the spottoolStepsUsed
on every response makes it easy to log exactly what the agent did, useful for tracking advisory accuracy over a growing seasonSwap the mock weather and price data in tools.js
for a real weather API and a live market-price feed (e.g., from a state agriculture board or a partner logistics platform), move the activity log from an in-memory array to MongoDB, and replace the rule-based diagnose_crop_image
matching with a fine-tuned vision classifier once you have enough labeled crop-disease photos. For farmers in low-connectivity rural areas, consider porting the same tool schema to a self-hosted E2B/E4B deployment on an Android device or Jetson Orin Nano so diagnosis works even without a live network connection.