Reading time: about 20 minutes
Here we build a real agent, from an empty folder to watching it reason on your screen. You do not need to know how to program. You need to be able to copy, paste and read what comes out, which is a different skill and the only one required.
The thirteen steps
- What you need (15 minutes, once)
- The folder and the key
- Talking to the model once, with no agent
- The data: a CSV you already have
- The two tools
- Describing them to the model
- The loop: thirty lines
- Running it and reading what it does
- Breaking it on purpose, four times
- The five terminal errors
- What you can and cannot ask it
- Making it run by itself every morning
- What you have and what you are missing
1. What you need
Three things, all free. You do this once in your life.
| What | What for | Cost |
|---|---|---|
| Node.js (version 20 or newer) | Running the program on your computer | Free. nodejs.org, big button, next-next |
| A text editor | Writing the files. Visual Studio Code is fine | Free |
| A Google AI Studio key | Talking to the model | Free with a daily cap. You saw this in the AI Studio lesson |
To check Node is installed, open the terminal — on Windows, "Command Prompt"; on Mac, "Terminal" — and type:
$ node --version
v22.11.0
If a number appears, you are set. If it says the command is not recognised, it is not
installed: back to nodejs.org.
2. The folder and the key
Create a folder — call it agent — and inside it three empty files:
agent.js, .env and stock.csv. From the terminal, go
into it:
$ cd agent
$ npm init -y
That creates a package.json. Open it and add one line, "type":
"module", so you can use the modern way of writing JavaScript:
{
"name": "agent",
"version": "1.0.0",
"type": "module",
"main": "agent.js"
}
Now the key. It goes in the .env file, alone, on one line:
GEMINI_API_KEY=AIza...your_key_hereagent.js. This is not fussiness:
the day you push that folder to GitHub — and you will — the key is published, and there
are bots crawling GitHub looking for exactly that. A leaked key gets spent in hours and the
bill is yours. If you are using Git, also add a .gitignore file with the line
.env in it.
3. Talking to the model once, with no agent
Before building any loop you have to check the key works. This program asks one question and prints the answer. Nothing else.
// Reads the .env file with nothing installed: Node 20+ ships with this.
import { readFileSync } from 'node:fs';
const env = readFileSync('.env', 'utf8');
const KEY = env.split('=')[1].trim();
const MODEL = 'gemini-2.5-flash-lite';
const URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
const res = await fetch(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': KEY },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: 'Say "it works" and nothing else.' }] }],
}),
});
const data = await res.json();
console.log(JSON.stringify(data, null, 2));Save it and run it:
$ node agent.js
If all is well you get a long block with the answer inside. What matters is that
"text": "it works" appears somewhere. If instead you get an error
saying API key not valid, the key was copied wrong.
4. The data: a CSV you already have
Your dispensing software exports stock to CSV. For the test, making one up is fine — the
agent cannot tell the difference. Put this in stock.csv:
code;name;stock;minimum;monthly_sales
653321;IBUPROFEN 600MG 40 TABS;12;30;96
712004;PARACETAMOL 1G 40 TABS;58;40;120
889012;OMEPRAZOLE 20MG 28 CAPS;4;25;71
451188;AMOXICILLIN 500MG 24 CAPS;31;20;28
990341;DEXKETOPROFEN 25MG 20 TABS;0;15;445. The two tools
A tool is an ordinary function. These two are nothing special: they read the CSV and return data.
function readStock() {
const lines = readFileSync('stock.csv', 'utf8').trim().split('\n');
return lines.slice(1).map((l) => {
const [code, name, stock, minimum, monthly_sales] = l.split(';');
return { code, name, stock: +stock, minimum: +minimum, monthly_sales: +monthly_sales };
});
}
// Tool 1 — find a product by name
function find_product({ text }) {
const t = String(text || '').toLowerCase();
const hits = readStock().filter((p) => p.name.toLowerCase().includes(t));
if (!hits.length) {
// ⚠️ We do NOT throw an error: we return it as data.
return { found: false, reason: `No product contains "${text}".` };
}
return { found: true, products: hits.map((p) => ({ code: p.code, name: p.name })) };
}
// Tool 2 — status of one product, or of everything below its minimum
function stock_status({ code, only_below_minimum }) {
let rows = readStock();
if (code) rows = rows.filter((p) => p.code === String(code));
if (only_below_minimum) rows = rows.filter((p) => p.stock < p.minimum);
if (!rows.length) return { found: false, reason: 'No row matches that.' };
return {
found: true,
total: rows.length,
rows: rows.map((p) => ({
code: p.code, name: p.name, stock: p.stock, minimum: p.minimum,
monthly_sales: p.monthly_sales,
days_of_cover: Math.round((p.stock / (p.monthly_sales / 30)) * 10) / 10,
})),
};
}
const TOOLS = { find_product, stock_status };{ found: false, reason: "..." } when there is nothing,
instead of failing. It is the single most important rule in this lesson and the
reason half of all agents get stuck: if a tool throws, the agent stops; if it returns the
reason in writing, the model reads it, understands what happened and looks elsewhere.
An error you can read is data.
days_of_cover: the tool calculates it, not the
model. Any sum that can be done with a formula is done in your code — it is exact, it is
free and it always gives the same answer — and the model is left with what it is actually
good at: deciding what to look at and explaining it. Asking a model to divide is handing it
the chance to be wrong about the one thing that should never be wrong.
6. Describing them to the model
The model cannot see that code. It sees this — and only this:
const DECLARATIONS = [
{
name: 'find_product',
description: 'Finds catalogue products by part of their name and returns their code. '
+ 'ALWAYS use it before stock_status if you only have a name: codes are not made up.',
parameters: {
type: 'object',
properties: { text: { type: 'string', description: 'Part of the name, e.g. "ibuprofen"' } },
required: ['text'],
},
},
{
name: 'stock_status',
description: 'Returns stock, minimum, monthly sales and days of cover. With a code, for that '
+ 'product. With only_below_minimum=true, for everything below its minimum. With neither, '
+ 'for the whole catalogue (avoid this: it is a lot of rows).',
parameters: {
type: 'object',
properties: {
code: { type: 'string', description: 'Exact code obtained from find_product' },
only_below_minimum: { type: 'boolean', description: 'true to list only what is short' },
},
},
},
];7. The loop: thirty lines
history is an array that only ever grows: the model's request and the tool's result, every turn. And every turn sends it whole. That is why turn 3 costs three times turn 1, why there is a MAX_TURNS, and why a tool that returns three thousand rows is not expensive once: it is expensive for every turn that is left.Here is the whole agent. It is shorter than it looks because most of it is already written.
const INSTRUCTIONS = `You are a pharmacy stock assistant.
You answer about stock using ONLY the tools; you never invent figures.
If you need a code, get it from find_product: never construct one.
If a tool fails or finds nothing, try another route before giving up
and SAY SO in your final answer.
Answer briefly and quote the exact figures you were given.`;
const MAX_TURNS = 8; // ⚠️ mandatory
async function agent(job) {
const history = [{ role: 'user', parts: [{ text: job }] }];
for (let turn = 1; turn <= MAX_TURNS; turn++) {
const res = await fetch(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': KEY },
body: JSON.stringify({
systemInstruction: { parts: [{ text: INSTRUCTIONS }] },
contents: history,
tools: [{ functionDeclarations: DECLARATIONS }],
}),
});
const data = await res.json();
const parts = data?.candidates?.[0]?.content?.parts || [];
const requests = parts.filter((p) => p.functionCall);
if (!requests.length) {
const text = parts.map((p) => p.text).filter(Boolean).join('');
return { text, turns: turn };
}
history.push({ role: 'model', parts }); // what it asked for
const answers = [];
for (const { functionCall } of requests) {
const { name, args } = functionCall;
console.log(` turn ${turn} ASKS ${name}`, JSON.stringify(args));
const fn = TOOLS[name];
const out = fn
? fn(args || {})
: { found: false, reason: `There is no tool called ${name}.` };
answers.push({ functionResponse: { name, response: out } });
}
history.push({ role: 'user', parts: answers }); // what we returned
}
return { text: 'I ran out of turns without reaching an answer.', turns: MAX_TURNS };
}
const r = await agent(process.argv[2] || 'What am I running low on?');
console.log('\n' + r.text + `\n\n(${r.turns} turns)`);That is all of it. No library, no framework, no third-party service. And now the part that matters: knowing what each piece does.
-
The history
It is the context from the previous lesson, made real. An array everything gets appended to. It is sent in full on every turn — which is why the cost grew.
-
The
forwith a capEight turns and not one more. If it uses them up, it says it did not get there. It is not elegant and it is exactly right: a failure should show and stop, rather than keep spending.
-
The fork
parts.filter(p => p.functionCall). If the model asked for no tool, it has answered: return and finish. Thatifis the natural stopping rule, and it takes three lines. -
The two
pushcallsThe first stores what the model asked for; the second, what the tools returned. Both are mandatory. Skip the first and the model sees a result without remembering asking for it, and goes haywire — it is the most common mistake when writing this for the first time.
-
The
console.loginsideThat line is your log. It looks like a detail and it is the difference between debugging and guessing: without it you do not know what it asked for, or with which arguments.
-
The tool that does not exist
If the model invents a tool name — it happens — we do not blow up: we return "does not exist" as data and the model corrects itself on the next turn.
8. Running it and reading what it does
$ node agent.js "what am I running low on?"
turn 1 ASKS stock_status {"only_below_minimum":true}
You are below minimum on three products:
- DEXKETOPROFEN 25MG 20 TABS: 0 units (minimum 15). Out of stock.
- OMEPRAZOLE 20MG 28 CAPS: 4 units (minimum 25), 1.7 days of cover.
- IBUPROFEN 600MG 40 TABS: 12 units (minimum 30), 3.8 days of cover.
(2 turns)One turn with a tool and one to answer. Now a question that forces it to chain:
$ node agent.js "how much dexketoprofen is left and how much do I sell?"
turn 1 ASKS find_product {"text":"dexketoprofen"}
turn 2 ASKS stock_status {"code":"990341"}
There is none left of DEXKETOPROFEN 25MG 20 TABS: 0 units, against a minimum
of 15. You sell 44 a month, which is a little over 1.4 a day.
(3 turns)9. Break it on purpose, four times
This is not optional. The failures from the previous lesson only land properly when you cause them yourself somewhere nothing is at stake.
-
Remove the turn cap
Set
MAX_TURNSto 200 and ask about a product that is not in the CSV. You will watch the same request repeat until you get bored of looking. That is the infinite loop, and that is where you understand why the cap is not negotiable. -
Make a tool fail for real
Replace
return { found: false, ... }withthrow new Error('does not exist'). The whole program falls over on the first odd question. Put it back and compare: with the error returned as data, the agent keeps working and tells you about it. -
Make a description worse
Remove "codes are not made up" from
find_productand ask about a product by name. Sooner or later you will seestock_status {"code":"123456"}with a code that does not exist. Not one line of logic changed: you changed one sentence. -
Return too much
Remove the filter from
stock_statusso it always returns the whole catalogue, and duplicate the CSV a few times. You will see two things at once: it gets slower, and the model starts getting confused. Extra context is not extra information: it is noise you paid for.
10. The five errors you will see in the terminal
All of them. Without exception. They are here so that when they show up you do not lose the afternoon: once you recognise the message, the fix takes a minute.
| What appears | What is really happening | Fix |
|---|---|---|
API key not valid |
The key is copied wrong, or you copied the GEMINI_API_KEY= too |
Copy it again from AI Studio, whole and with no spaces |
429 RESOURCE_EXHAUSTED |
You have used up the day's free quota, or you are calling too fast | Wait. And check how many extra turns you are running |
ENOENT: no such file'stock.csv' |
You are running it from a different folder | cd into the agent folder and run it again |
Cannot read properties ofundefined |
The response did not contain what you expected — almost always because there was an error inside it | Print the whole data before reading it. Always |
400 INVALID_ARGUMENT |
The body you are sending has something that model does not accept | Read the message: it names the field. Usually a badly built history |
{ "error": ... } instead of
candidates, so the real failure is written one line above where you are
looking. Hence the habit of printing the raw response from step 3: the useful message is
almost never the one JavaScript gives you, it is the one Google gives you inside the JSON.
11. What you can and cannot ask it
An agent only knows what its tools let it know. With these two, this is exactly what it covers — and it is worth being clear about before showing it to anybody:
| Question | Can it? | Why |
|---|---|---|
| "What am I running low on?" | Yes | That is literally only_below_minimum |
| "How many days of omeprazole do I have?" | Yes | The cover is calculated by the tool |
| "What should I order today, and how much?" | Halfway | It can suggest, but the quantity is your call: nothing in the CSV says how long restocking takes |
| "Why did the dexketoprofen run out?" | Not fully | It has no delivery history. With a third tool, yes |
| "How much did I turn over this month?" | No | It has no tool that knows. And it will answer anyway unless you forbid it |
12. Making it run by itself every morning
An agent you have to launch by hand gets used for three days. Two small changes turn it into something that is waiting for you already done:
import { appendFileSync } from 'node:fs';
const r = await agent(process.argv[2] || 'What am I running low on?');
const line = `\n\n===== ${new Date().toISOString()} =====\n${r.text}\n(${r.turns} turns)`;
appendFileSync('report.txt', line);
console.log(r.text);And then let the computer launch it:
- On Mac or Linux:
crontab -eand a line0 8 * * 1-6 cd /path/agent && /usr/local/bin/node agent.js— 8:00 am, Monday to Saturday. - On Windows: Task Scheduler, action "start a program", program
node, argumentsagent.js, and the agent folder as the start directory.
node is not fussiness. When the system
launches it rather than you, your terminal's shortcuts do not exist: that is the number one
cause of "it works by hand and not on a schedule", and number two is forgetting the
cd — which is the ENOENT error from the table above, again.
13. What you have and what you are missing
| You already have | You are missing |
|---|---|
| The whole loop, with a cap | Tools reading your real database, not a CSV |
| Two tools that read | Tools that write, with everything that forces (lesson 3) |
| Errors returned as data | A stored log, not a console.log
that disappears |
| A log on screen | Not running only on your laptop (lesson 4) |
- It has answered from my own data at least once.
- I have seen in the log that it chains two tools on its own.
- I have caused the infinite loop and put the cap back.
- I have checked that a
throwkills the agent and areturndoes not. - My key is in
.envand not in the code.
In the next lesson we cover the only thing standing between this and something that touches your pharmacy for real: how to write a tool that writes.