Gemini function calling makes tool use look simple.
You describe a function, provide its input schema, and let the model decide whether the user's request requires it. A traveler asks, "Find hotels in Paris under $250," Gemini requests search_hotels
, your application executes it, and the model turns the result into a useful answer.
That is an important capability, but it is not an agent runtime.
Function calling tells your application what the model proposes to do. It does not decide whether the action is authorized, whether the arguments are trustworthy, whether the same action already succeeded, or whether a retry would make the situation worse.
The model proposes. The runtime disposes.
With the Google Gen AI SDK, a function declaration can look like this:
import { GoogleGenAI, Type } from "@google/genai";
const ai = new GoogleGenAI({
apiKey: process.env.GEMINI_API_KEY,
});
const searchHotels = {
name: "search_hotels",
description: "Search available hotels in a city under an optional nightly price",
parameters: {
type: Type.OBJECT,
properties: {
city: {
type: Type.STRING,
description: "City and country, for example Paris, France",
},
maxNightlyPriceUsd: {
type: Type.NUMBER,
description: "Maximum nightly price in US dollars",
},
},
required: ["city"],
},
};
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: "Find hotels in Paris under $250 per night",
config: {
tools: [{ functionDeclarations: [searchHotels] }],
},
});
const proposedCall = response.functionCalls?.[0];
The returned function call contains a name and structured arguments. Google is explicit about the next boundary: the model does not execute your business function. Your application is responsible for executing it and returning the result.
That boundary is where production engineering begins.
User request
│
▼
Gemini proposes a function call
│
▼
Schema validation → authorization → policy → budget
│ │
│ └── block / request confirmation
▼
Idempotent tool executor with timeout
│
▼
Normalized result + decision record
│
▼
Result returned to Gemini for the final response
The tool schema helps Gemini produce better arguments, but it should not replace runtime validation. Model-generated arguments cross the same trust boundary as an HTTP request.
import { z } from "zod";
const SearchHotelsArgs = z.object({
city: z.string().trim().min(2).max(120),
maxNightlyPriceUsd: z.number().positive().max(10_000).optional(),
});
type ExecutionContext = {
runId: string;
userId: string;
permissions: Set<string>;
};
function authorizeSearch(context: ExecutionContext) {
if (!context.permissions.has("hotel:read")) {
throw new Error("User is not authorized to search hotel inventory");
}
}
Validation answers, "Are these arguments structurally acceptable?" Authorization answers, "May this user perform this operation?" Policy answers a different question: "Should this operation happen in this workflow, at this moment?"
A read-only travel assistant may use search_hotels
but must never invoke book_hotel
. A booking agent may require confirmation before a purchase. A notification agent may suppress a technically valid message because the user is sleeping or has already received the same alert.
These are product rules, not model responsibilities.
A useful tool registry makes risk visible in code:
type ToolDefinition<T> = {
risk: "read" | "write" | "irreversible";
timeoutMs: number;
parse: (value: unknown) => T;
execute: (args: T, context: ExecutionContext) => Promise<unknown>;
};
const tools = {
search_hotels: {
risk: "read",
timeoutMs: 3_000,
parse: (value: unknown) => SearchHotelsArgs.parse(value),
execute: searchHotelInventory,
},
book_hotel: {
risk: "irreversible",
timeoutMs: 8_000,
parse: (value: unknown) => BookHotelArgs.parse(value),
execute: bookHotel,
},
} satisfies Record<string, ToolDefinition<unknown>>;
Before executing a write or irreversible tool, the runtime can require a recent user confirmation, an approval token, and an idempotency key. Deny by default when a tool is unknown or the workflow does not allow it.
Agent loops make duplicate work surprisingly easy. A response may time out after the downstream system completed the action. The model may ask again because the result never reached its context. A worker may restart after committing the operation but before recording success.
For read operations, retrying may be harmless. For bookings, payments, notifications, and account updates, it may be disastrous.
async function executeOnce<T>(
key: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = await idempotencyStore.get<T>(key);
if (previous) return previous;
const result = await operation();
await idempotencyStore.put(key, result, { ttlSeconds: 1_800 });
return result;
}
Build the key from stable business inputs, not the model's wording. For example: userId + itineraryId + toolName + requestVersion
.
A retry budget belongs in the runtime too. An agent should not turn a downstream outage into an unbounded loop of model calls and tool executions.
An empty array is ambiguous. Did the tool succeed and find nothing? Did it fail? Should the agent broaden the search or repeat the same call?
Return a discriminated result:
type SearchResult =
| { status: "ok"; matches: Hotel[] }
| { status: "no_match"; reason: string; retrySameInput: false }
| { status: "temporary_error"; retryAfterMs: number }
| { status: "blocked"; reason: string };
This is not only an API-design improvement. It prevents the model from interpreting operational uncertainty as permission to improvise.
When you send the result back to Gemini, preserve the model's original function-call content and return the matching function-call ID. This is especially important for current thinking models, where the SDK preserves the context needed to continue the tool-calling turn correctly.
You do not need hidden chain-of-thought to debug an agent. You need an application-owned explanation of each consequential transition.
await recordDecision({
runId: context.runId,
kind: "tool_execution_allowed",
tool: proposedCall.name,
policy: "read_only_travel_search",
reasonCode: "USER_REQUEST_MATCHED_TOOL_SCOPE",
idempotencyKey,
});
Prefer bounded reason codes and safe summaries over raw prompts. They are easier to aggregate, safer to retain, and more useful in dashboards.
Before allowing a Gemini-proposed function call to affect the world, ask:
Gemini function calling provides a clean, structured bridge between a model and your application. That bridge is powerful precisely because your code still controls what crosses it.
The model can select the next action. A production runtime must decide whether that action is allowed to become real.