CLI AI Assistant with Basic Tools
You've mastered function calling (3.1), multi-tool usage (3.2), and structured outputs (3.3). You've also built CLI assistants in tutorials 1.7 and 2.4. Now let's combine these skills into one practical CLI assistant that maintains conversation history while using essential tools.
This tutorial brings together the core concepts from the structured interactions module into a clean, focused assistant that you'd actually use in your daily development work.
What We're Building
We're creating a streamlined CLI assistant that combines:
From Previous Tutorials:
- ā Conversation History (2.4) - Remembers context throughout the session
- ā Streaming Responses (2.4) - Real-time response delivery
- ā Function Calling (3.1) - Calculator and file operations
- ā Multiple Tools (3.2) - AI chooses the right tools for tasks
New Advanced Features:
- š Tool Chaining - AI can call multiple tools in sequence
- š Token Usage Tracking - Monitor API consumption
- š Essential Tools Only - Calculator, file saver, weather checker
- š Clean Interface - Simple, focused user experience
Here's what the complete experience looks like:
š AI Assistant Ready! Type 'exit' to quit.
You: Calculate my freelance rate for 40 hours at $75/hour and save it to my rates file
š¤ AI: I'll calculate your freelance earnings and save that information for you.
š§ Found 2 function call(s) to execute:
Calling: calculate
Arguments: {"operation":"multiply","a":40,"b":75}
Result: 3000
Calling: saveNote
Arguments: {"filename":"rates","content":"Freelance calculation: 40 hours Ć $75/hour = $3,000"}
Result: "Successfully saved to rates.txt"
AI: Your freelance rate calculation: 40 hours Ć $75/hour = $3,000. I've saved this information to rates.txt for your records.
š Messages: 2 | Tools used: 2 | Tokens: 156
You: What was my rate calculation again?
š¤ AI: Based on our previous conversation, your freelance rate calculation was 40 hours Ć $75/hour = $3,000, which I saved to your rates.txt file.
š Messages: 4 | Tools used: 2 | Tokens: 234
Environment Setup
Build on your existing setup from previous tutorials:
# You should already have this from previous tutorials
npm install @google/genai dotenv
npm install -D typescript @types/node ts-node
Make sure your .env file contains your Gemini API key:
GEMINI_API_KEY=your_api_key_here
Working Code Example
We'll build this assistant step by step, explaining each component and how they work together.
Step 1: Core Setup and Interfaces
import { GoogleGenAI, FunctionDeclaration, Type } from "@google/genai";
import * as readline from "readline";
import * as fs from "fs";
import * as dotenv from "dotenv";
dotenv.config();
interface Message {
role: "system" | "user" | "assistant";
content: string;
}
interface SessionStats {
messagesCount: number;
toolsUsed: number;
tokensUsed: number;
}
We define clean interfaces for messages and session statistics. This keeps our code organized and type-safe.
Step 2: Initialize Core Components
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.error("ā GEMINI_API_KEY not found");
process.exit(1);
}
const genAI = new GoogleGenAI({ apiKey });
const messages: Message[] = [];
const stats: SessionStats = {
messagesCount: 0,
toolsUsed: 0,
tokensUsed: 0,
};
We initialize our AI client and create arrays to store conversation history and track usage statistics.
Step 3: Conversation Management Functions
function addMessage(role: "system" | "user" | "assistant", content: string) {
messages.push({ role, content });
if (role !== "system") {
stats.messagesCount++;
}
}
function formatMessagesForAPI() {
return messages.map((msg) => {
if (msg.role === "system") return { text: msg.content };
return { text: `${msg.role}: ${msg.content}` };
});
}
function initializeAssistant() {
addMessage(
"system",
"You are a helpful AI assistant. You can perform calculations, save files, and check weather. Keep responses practical and reference previous conversation context when relevant. When using tools, briefly explain what you're doing."
);
console.log("š AI Assistant Ready! Type 'exit' to quit.\n");
}
These functions handle conversation management. The formatMessagesForAPI() function is crucial - it converts our message history into the format the AI expects, maintaining conversation context.
Step 4: Define Tool Schemas
// Calculator for mathematical operations
const calculatorFunction: FunctionDeclaration = {
name: "calculate",
description: "Perform mathematical calculations",
parameters: {
type: Type.OBJECT,
properties: {
operation: {
type: Type.STRING,
enum: ["add", "subtract", "multiply", "divide", "percentage"],
description: "Mathematical operation to perform",
},
a: { type: Type.NUMBER, description: "First number" },
b: { type: Type.NUMBER, description: "Second number" },
},
required: ["operation", "a", "b"],
},
};
// File saver for important information
const fileFunction: FunctionDeclaration = {
name: "saveNote",
description: "Save information or notes to a text file",
parameters: {
type: Type.OBJECT,
properties: {
filename: {
type: Type.STRING,
description: "Name of the file (without extension)",
},
content: {
type: Type.STRING,
description: "Content to save to the file",
},
},
required: ["filename", "content"],
},
};
// Weather checker for quick reference
const weatherFunction: FunctionDeclaration = {
name: "getWeather",
description: "Get current weather information for a city",
parameters: {
type: Type.OBJECT,
properties: {
city: {
type: Type.STRING,
description: "City name (e.g., tokyo, london, new-york)",
},
},
required: ["city"],
},
};
const allTools = [calculatorFunction, fileFunction, weatherFunction];
These schemas define exactly what tools the AI can use and how to use them. Clear descriptions help the AI choose the right tool for each task.
Step 5: Implement Tool Functions
function calculate(operation: string, a: number, b: number): number {
switch (operation) {
case "add":
return a + b;
case "subtract":
return a - b;
case "multiply":
return a * b;
case "divide":
if (b === 0) throw new Error("Cannot divide by zero");
return a / b;
case "percentage":
return (a * b) / 100;
default:
throw new Error(`Unknown operation: ${operation}`);
}
}
function saveNote(filename: string, content: string): string {
try {
const timestamp = new Date().toISOString().split("T")[0];
const fullContent = `[${timestamp}] ${content}`;
fs.writeFileSync(`${filename}.txt`, fullContent);
return `Successfully saved to ${filename}.txt`;
} catch (error) {
throw new Error(`Failed to save file: ${error}`);
}
}
// Simple weather data for demonstration
const weatherData: Record<string, any> = {
tokyo: { temp: 22, condition: "sunny", humidity: 65 },
london: { temp: 15, condition: "rainy", humidity: 80 },
"new-york": { temp: 18, condition: "cloudy", humidity: 70 },
"san-francisco": { temp: 20, condition: "foggy", humidity: 75 },
};
function getWeather(city: string) {
const cityKey = city.toLowerCase().replace(/\s+/g, "-");
const weather = weatherData[cityKey];
if (!weather) {
throw new Error(
`Weather data not available for ${city}. Available: tokyo, london, new-york, san-francisco`
);
}
return weather;
}
These are the actual implementations of our tools. Notice how saveNote adds a timestamp - this makes the saved files more useful for real-world use.
Step 6: Tool Execution Helper
// Helper: run a single tool call
function runTool(functionCall: any): any {
switch (functionCall.name) {
case "calculate": {
const { operation, a, b } = functionCall.args;
return calculate(operation, a, b);
}
case "saveNote": {
const { filename, content } = functionCall.args;
return saveNote(filename, content);
}
case "getWeather": {
const { city } = functionCall.args;
return getWeather(city);
}
default:
throw new Error(`Unknown function: ${functionCall.name}`);
}
}
This helper function centralizes tool execution, making it easy to add new tools later. It extracts the arguments and calls the appropriate function.
Step 7: Advanced Streaming Chat with Tool Chaining
async function streamingChatWithTools(userMessage: string): Promise<void> {
try {
addMessage("user", userMessage);
// Phase 1: initial streamed response to surface tool calls
const initialStream = await genAI.models.generateContentStream({
model: "gemini-2.5-flash",
contents: formatMessagesForAPI(),
config: { tools: [{ functionDeclarations: allTools }], temperature: 0.7 },
});
let firstCalls: any[] = [];
let initialText = "";
let chunkUsage: any = null;
process.stdout.write("š¤ AI: ");
for await (const chunk of initialStream) {
const t = chunk.text || "";
process.stdout.write(t);
initialText += t;
if (chunk.functionCalls && chunk.functionCalls.length > 0) {
firstCalls.push(...chunk.functionCalls);
}
if (chunk.usageMetadata) chunkUsage = chunk.usageMetadata;
}
console.log();
// If no tool calls, we're done
if (firstCalls.length === 0) {
addMessage("assistant", initialText);
if (chunkUsage) {
stats.tokensUsed +=
(chunkUsage.promptTokenCount || 0) +
(chunkUsage.candidatesTokenCount || 0);
}
return;
}
console.log(`\nš§ Found ${firstCalls.length} function call(s) to execute:`);
// Build transcript with the user's prompt
const transcript: any[] = [
{ role: "user", parts: [{ text: userMessage }] },
];
This is the heart of our assistant. It starts by streaming the AI's initial response and collecting any function calls. If there are no function calls, it's a simple text response and we're done.
Step 8: Execute Function Calls and Handle Chaining
// Execute first round of calls
const firstResults: any[] = [];
for (const call of firstCalls) {
console.log(`\nCalling: ${call.name}`);
console.log(`Arguments: ${JSON.stringify(call.args)}`);
stats.toolsUsed++;
try {
const result = runTool(call);
console.log(`Result: ${JSON.stringify(result)}`);
firstResults.push({ call, result: { result } });
} catch (error) {
console.log(`Error: ${error}`);
firstResults.push({
call,
result: {
error: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
// Seed transcript with tool calls + results
for (const { call, result } of firstResults) {
transcript.push({
role: "model",
parts: [{ functionCall: call } as any],
});
transcript.push({
role: "function",
parts: [{ functionResponse: { name: call.name, response: result } } as any],
});
}
Here we execute the first round of function calls and build a transcript that includes both the calls and their results. This transcript becomes the context for any follow-up calls.
Step 9: Handle Follow-up Tool Calls
// Resolve follow-up calls until none remain
while (true) {
let moreCalls: any[] = [];
let textOut = "";
const follow = await genAI.models.generateContentStream({
model: "gemini-2.5-flash",
contents: transcript,
config: {
tools: [{ functionDeclarations: allTools }],
temperature: 0.7,
},
});
for await (const c of follow) {
const ct = c.text || "";
process.stdout.write(ct);
textOut += ct;
if (c.functionCalls && c.functionCalls.length > 0) {
moreCalls.push(...c.functionCalls);
}
}
if (moreCalls.length === 0) {
console.log();
addMessage("assistant", textOut);
break;
}
console.log(
`\n\nš§ Found ${moreCalls.length} additional function call(s):`
);
const roundResults: any[] = [];
for (const call of moreCalls) {
console.log(`\nCalling: ${call.name}`);
console.log(`Arguments: ${JSON.stringify(call.args)}`);
stats.toolsUsed++;
try {
const result = runTool(call);
console.log(`Result: ${JSON.stringify(result)}`);
roundResults.push({ call, result: { result } });
} catch (error) {
console.log(`Error: ${error}`);
roundResults.push({
call,
result: {
error: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
for (const { call, result } of roundResults) {
transcript.push({
role: "model",
parts: [{ functionCall: call } as any],
});
transcript.push({
role: "function",
parts: [
{ functionResponse: { name: call.name, response: result } } as any,
],
});
}
process.stdout.write("\nAI: ");
}
if (chunkUsage) {
stats.tokensUsed +=
(chunkUsage.promptTokenCount || 0) +
(chunkUsage.candidatesTokenCount || 0);
}
} catch (error) {
console.error("\nā Error:", error);
addMessage("assistant", "Sorry, I encountered an error. Please try again.");
}
}
This is the advanced part - tool chaining! The AI can call tools, see the results, and then decide to call more tools based on those results. The loop continues until the AI doesn't need any more tools.
Step 10: Main Application Loop
function startAssistant(): void {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const askQuestion = (): void => {
rl.question("You: ", async (input) => {
const userInput = input.trim();
if (userInput.toLowerCase() === "exit") {
console.log("\nš Thanks for using the AI Assistant!");
console.log(
`š Final Stats - Messages: ${stats.messagesCount} | Tools used: ${stats.toolsUsed} | Tokens: ${stats.tokensUsed}`
);
rl.close();
return;
}
if (userInput === "") {
console.log("Please enter a message.\n");
askQuestion();
return;
}
await streamingChatWithTools(userInput);
// Show simple stats after each interaction
console.log(
`š Messages: ${stats.messagesCount} | Tools used: ${stats.toolsUsed} | Tokens: ${stats.tokensUsed}\n`
);
askQuestion();
});
};
askQuestion();
}
// Graceful shutdown
process.on("SIGINT", () => {
console.log("\n\nš Session ended!");
console.log(
`š Final Stats - Messages: ${stats.messagesCount} | Tools used: ${stats.toolsUsed} | Tokens: ${stats.tokensUsed}`
);
process.exit(0);
});
// Start the assistant
initializeAssistant();
startAssistant();
The main loop handles user input, processes commands, and maintains the conversation flow. It also provides helpful statistics after each interaction.
Testing Your Assistant
Here are practical scenarios to test:
Basic Calculations:
You: Calculate 15% of 500
AI: [Uses calculator tool to compute 75]
File Operations:
You: Save my project budget of $5000 to budget file
AI: [Saves the information to budget.txt with timestamp]
Tool Chaining:
You: Calculate 20 * 30 and save the result to my calculations file
AI: [First calculates 600, then saves it to calculations.txt]
Conversation Memory:
You: Calculate 100 + 50
AI: [Returns 150]
You: Save that result to my math file
AI: [Remembers the 150 result and saves it]
Key Features Explained
Tool Chaining: The AI can call multiple tools in sequence, using results from one tool to inform the next tool call.
Conversation Context: The assistant remembers all previous interactions, so it can reference earlier calculations or saved files.
Real-time Streaming: Responses appear word by word, even when using tools.
Error Handling: If a tool fails, the error is captured and the conversation continues gracefully.
Token Tracking: Monitor API usage to understand costs and optimize usage.
Understanding Tool Chaining
The most advanced feature is tool chaining. Here's how it works:
- Initial Response: AI streams its first response and identifies needed tools
- Execute Tools: Run all identified function calls
- Build Context: Create a transcript with calls and results
- Check for More: AI analyzes results and decides if more tools are needed
- Repeat: Continue until AI has all the information it needs
- Final Response: AI provides the complete answer
This allows for complex workflows like "calculate my rate, save it to a file, then tell me the weather" all in one request.
Common Pitfalls to Avoid
Tool Overuse: Don't call functions for simple text responses the AI can handle naturally.
Poor Context Management: Always maintain conversation history so the AI can reference previous interactions.
Error Handling: Provide clear error messages when tools fail.
Token Monitoring: Keep an eye on token usage, especially with tool chaining.
Infinite Loops: The while loop for tool chaining is safe because it only continues if new function calls are found.
FAQ
Summary
You've built a sophisticated CLI assistant that combines conversation management with advanced tool capabilities. This assistant demonstrates: seamless integration of conversation history with function calling, real-time streaming responses while using multiple tools, advanced tool chaining that allows complex multi-step workflows, token usage tracking for cost awareness, and clean, maintainable code structure that's easy to extend. This represents the culmination of all structured interactions concepts, creating a practical tool that showcases mastery of conversation management, function calling, streaming responses, and advanced AI orchestration patterns.
Complete Code
You can find the complete, runnable code for this tutorial on GitHub: https://github.com/avestalabs/academy/tree/main/3-structured-interactions/cli-ai-assistant-calculations