Build a Gemini 3.7 Flash Agent to Control Your Smart Home
A step-by-step Python guide to building a Gemini 3.7 Flash agent that flips your lights and thermostat through the Interactions API function-calling loop: the model declares each action as a typed function, your executor is the only layer that touches hardware, and a function result closes every cycle. It also breaks down current token pricing so you can judge whether an always-on agent is worth running.
The control boundary: Gemini proposes, your hub executes
A Gemini 3.7 Flash smart-home agent should not get a network route to your lights or thermostat. The working pattern is tighter: declare the actions the model may request, let the model return a function_call with visible arguments, run those arguments through your local executor, then send a function_result back to the model. Google’s function-calling guide explicitly lists “controlling smart home devices” as a Take Actions use case, but the documented mechanism is still this mediated function-calling loop, not direct device authority [1]. The current Gemini 3.7 Flash materials put that loop in the Interactions API with client.interactions.create, returned function_call steps, previous_interaction_id, and follow-up function_result steps [2].

That separation is the difference between an agent and a demo that stops at a mocked API call. The model may reason over the request “dim the living room and set the hallway thermostat to 72,” but the house changes only after your code accepts a named function, validates its arguments, maps it to a known hub entity, sends the hub command, and reports the outcome.
| Step | Owner | What happens |
|---|---|---|
| 1. Function declaration | Your app | You expose a small list of typed actions such as set_light_values and set_thermostat_target. |
| 2. function_call | Gemini | The model chooses an allowed function name and emits arguments. |
| 3. Local executor | Your hub layer | Your code validates the request and calls Home Assistant, Homey, or your custom controller. |
| 4. function_result | Your app back to Gemini | You return success, failure, or partial outcome so the interaction has a reproducible close. |
If you want the consumer path where Google Home handles the integration and you do not write Python, use the zero-code voice route instead: set up Gemini for home voice commands. This tutorial is for the hub-owner path, where you are deliberately placing Gemini behind your own execution boundary.
Declare only the device actions you are willing to execute
Start with the grammar Google uses in its own function-calling guide: a set_light_values function with typed properties, a bounded brightness value from 0 to 100, and an enumerated color temperature [1]. The thermostat function below extends the same idea. It does not imply Gemini has native HVAC control; it gives the model a narrow request shape your executor may accept or reject.
TOOLS = [
{
"function_declarations": [
{
"name": "set_light_values",
"description": "Set brightness and color temperature for one allowed light area.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "A supported light area, for example living_room or kitchen."
},
"brightness": {
"type": "integer",
"description": "Brightness percentage from 0 to 100.",
"minimum": 0,
"maximum": 100
},
"color_temp": {
"type": "string",
"description": "Requested white color temperature.",
"enum": ["warm", "cool", "daylight"]
}
},
"required": ["location", "brightness", "color_temp"]
}
},
{
"name": "set_thermostat_target",
"description": "Set an allowed thermostat to heat, cool, or auto at a target temperature in Fahrenheit.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "A supported thermostat area, for example hallway."
},
"mode": {
"type": "string",
"enum": ["heat", "cool", "auto"]
},
"target_f": {
"type": "number",
"description": "Target temperature in degrees Fahrenheit."
}
},
"required": ["location", "mode", "target_f"]
}
}
]
}
]Keep the declarations boring. Do not expose a raw “call any Home Assistant service” function unless you enjoy discovering, at 11 p.m., that the model found a service you forgot existed. A good smart-home tool surface is a small public counter in front of a locked workshop: lights here, thermostat there, no arbitrary service names, no arbitrary entity IDs, no unbounded JSON blob.
Treat NestGrid-style verification metadata as local to your test run. Hub model, firmware version, app version, verification date, and any Confirmed/Workaround/Investigating label are placeholders until you run this exact loop against your own hub. Gemini 3.7 Flash reached general availability on August 13, 2026, so as of August 25, 2026, there has not been much time for independent end-to-end smart-home recipes to settle [3]. Older Python function-calling walkthroughs can still corroborate the declaration/call/result pattern, but examples built around the legacy generateContent flow are not the API shape used here [5].

Build the Interactions API loop
The loop below is written for a Home Assistant-style REST executor because it is easy to read and easy to replace. If you run Homey or a custom MQTT stack, keep the Gemini-facing half and swap only the hub calls. The important seam is the EXECUTORS dictionary: model-returned names must match an allowed local function, and only those local functions may touch hardware.
import os
import requests
from google import genai
MODEL = "gemini-3.7-flash"
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
HA_URL = os.environ["HA_URL"].rstrip("/")
HA_TOKEN = os.environ["HA_TOKEN"]
ALLOWED_LIGHTS = {
"living_room": "light.living_room",
"kitchen": "light.kitchen"
}
ALLOWED_THERMOSTATS = {
"hallway": "climate.hallway"
}
KELVIN_BY_TEMP = {
"warm": 2700,
"cool": 4000,
"daylight": 5000
}
class ToolError(Exception):
pass
def require_known(mapping, key, label):
if key not in mapping:
raise ToolError(f"Unsupported {label}: {key}")
return mapping[key]
def require_number_range(value, low, high, label):
if not isinstance(value, (int, float)):
raise ToolError(f"{label} must be numeric")
if value < low or value > high:
raise ToolError(f"{label} must be between {low} and {high}")
return value
def ha_service(domain, service, data):
response = requests.post(
f"{HA_URL}/api/services/{domain}/{service}",
headers={
"Authorization": f"Bearer {HA_TOKEN}",
"Content-Type": "application/json"
},
json=data,
timeout=10
)
response.raise_for_status()
return response.json() if response.text else []
def set_light_values(location, brightness, color_temp):
entity_id = require_known(ALLOWED_LIGHTS, location, "light location")
brightness = int(require_number_range(brightness, 0, 100, "brightness"))
kelvin = require_known(KELVIN_BY_TEMP, color_temp, "color_temp")
ha_service(
"light",
"turn_on",
{
"entity_id": entity_id,
"brightness_pct": brightness,
"kelvin": kelvin
}
)
return {
"ok": True,
"entity_id": entity_id,
"state_requested": {
"brightness_pct": brightness,
"color_temp": color_temp
}
}
def set_thermostat_target(location, mode, target_f):
entity_id = require_known(ALLOWED_THERMOSTATS, location, "thermostat location")
if mode not in {"heat", "cool", "auto"}:
raise ToolError(f"Unsupported thermostat mode: {mode}")
# Local policy, not model policy. Adjust for your house and equipment.
require_number_range(target_f, 60, 78, "target_f")
ha_service(
"climate",
"set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": mode}
)
ha_service(
"climate",
"set_temperature",
{"entity_id": entity_id, "temperature": target_f}
)
return {
"ok": True,
"entity_id": entity_id,
"state_requested": {
"mode": mode,
"target_f": target_f
}
}
EXECUTORS = {
"set_light_values": set_light_values,
"set_thermostat_target": set_thermostat_target
}
def execute_function_call(function_call):
name = function_call.name
args = dict(function_call.args or {})
if name not in EXECUTORS:
raise ToolError(f"Function is not allowed: {name}")
return EXECUTORS[name](**args)
def run_agent_turn(user_text):
interaction = client.interactions.create(
model=MODEL,
input=user_text,
tools=TOOLS
)
while True:
calls = [
step.function_call
for step in interaction.steps
if getattr(step, "function_call", None)
]
if not calls:
# No pending tool calls. The model may have returned final text.
print(getattr(interaction, "output_text", ""))
return interaction
function_results = []
for call in calls:
try:
result = execute_function_call(call)
except Exception as exc:
result = {
"ok": False,
"error_type": type(exc).__name__,
"message": str(exc)
}
function_results.append(
{
"function_result": {
"name": call.name,
"result": result
}
}
)
interaction = client.interactions.create(
model=MODEL,
previous_interaction_id=interaction.id,
input=function_results,
tools=TOOLS
)
if __name__ == "__main__":
run_agent_turn(
"Set the living room lights to warm at 35 percent and cool the hallway to 72."
)The exact response object may be dict-like or attribute-based depending on the SDK version you install, so treat the accessors around interaction.steps as the small adaptation point. The architectural invariant is not small: every request goes through client.interactions.create; every returned function call is executed locally or rejected locally; every result is sent back with previous_interaction_id so the model sees what actually happened [2].
The executor is the safety device
Most AI tutorials under-explain the executor because it is less glamorous than the model call. In a smart home, it is the part that matters. The executor decides whether living_room maps to a real entity, whether 35 is a valid brightness, whether 72 degrees is inside your household policy, and whether the hub accepted the service call. The model can ask; the executor owns consequences.
- Name matching should be exact. If the model returns a function name outside
EXECUTORS, reject it. - Entity mapping should be local. The model should say
living_room, notlight.living_roomunless you intentionally expose entity IDs. - Ranges should be enforced twice: once in the function declaration so the model has a schema, and again in Python because hardware does not care that the schema was well-intentioned.
- Hub errors should become function results. A timeout, authentication failure, or missing entity is part of the interaction, not a reason to pretend the action succeeded.
This is also where ordinary home-automation reliability still applies. If your devices do not reconnect after a power outage, the Gemini layer will not fix the broken device path; it will only produce a cleaner record of the failed command. For that class of problem, debug the hub layer first: smart devices not reconnecting after a power outage.
Return function_result even when the command fails
The result payload is not just bookkeeping. It is how the model learns whether the requested action landed. A useful smart-home result says what was requested, which entity was targeted, and whether the hub accepted the command. It should not claim a final physical state unless your hub has read that state back from the device.
# Good: reports what your executor actually knows.
{
"ok": True,
"entity_id": "light.living_room",
"state_requested": {
"brightness_pct": 35,
"color_temp": "warm"
}
}
# Also good: failure is explicit and can be shown to the model.
{
"ok": False,
"error_type": "ToolError",
"message": "Unsupported thermostat location: bedroom"
}If you want stronger confirmation, add a post-command read from your hub and return both state_requested and state_observed. That makes failures less mysterious when a cloud bridge is slow, a Zigbee bulb is asleep, or a thermostat rejects a mode change.
Add parallel and compositional calls only after the basic loop works
Gemini function calling supports parallel calls and compositional calling, including the common “check the weather, then set the thermostat” style of chain [1]. That is useful, but it is not a different architecture. The model may return more than one call, or it may call one tool and use that result to decide the next call. Your side still runs the same executor and returns the same kind of function result.
# Add this only if you want the model to reason from an external reading.
# The weather tool returns information; it does not touch house hardware.
def get_outdoor_weather(location):
if location != "home":
raise ToolError("Only the home weather location is supported")
# Replace with your weather provider or hub sensor.
return {
"ok": True,
"location": "home",
"condition": "hot",
"outdoor_temp_f": 94
}
EXECUTORS["get_outdoor_weather"] = get_outdoor_weatherThe difference between a weather tool and a thermostat tool should remain visible in code. A sensor-read tool can be permissive because it observes. A thermostat-write tool should be narrow because it changes comfort, energy use, and sometimes equipment behavior. If you are building more advanced thermostat behavior, such as pre-cooling before a heat wave, keep that policy in your hub layer or a dedicated automation and let the model request the policy by name; do not make the model invent HVAC limits on the fly. For a concrete automation pattern, see smart thermostat heat-wave pre-cooling.
Operational guardrails that matter in a real hub
Tool choice is not a decoration. Google’s guide documents modes for controlling whether the model may call functions and recommends keeping the active tool list to roughly 10 to 20 tools for quality [1]. For a smart-home agent, that usually means loading a room-specific or task-specific tool surface instead of every automation you have ever written.
| Guardrail | Practical use |
|---|---|
| Small active tool set | Expose the tools relevant to the current room, mode, or conversation instead of the whole hub. |
| Explicit tool choice mode | Use stricter modes for command screens and looser modes for general chat or explanation. |
| Local policies | Keep temperature limits, room allowlists, nighttime rules, and safety exceptions in Python or the hub. |
| Result logging | Store user text, function_call arguments, hub response, and function_result for replay. |
The malformed-call path also deserves a real handler. Google documents Malformed_Function_Call cases and an update()-function workaround in the function-calling guide [1]. In a hub recipe, treat a malformed call like an untrusted packet: do not guess the missing argument, do not patch it into a device command, and do return a structured failure so the interaction can recover.
def safe_call_to_result(call):
try:
if not getattr(call, "name", None):
raise ToolError("Missing function name")
if getattr(call, "args", None) is None:
raise ToolError("Missing function arguments")
return execute_function_call(call)
except Exception as exc:
return {
"ok": False,
"error_type": type(exc).__name__,
"message": str(exc)
}For model-selection context beyond this recipe, the useful comparison is not “which chatbot sounds smarter,” but which model is cheapest and most reliable for the specific command loop you are running. If you route multiple models through Home Assistant, keep that discussion separate from the executor boundary; this OpenAI vs. Anthropic Home Assistant comparison is the better place for task-routing and cost context.
What Gemini 3.7 Flash changes for this project
The model facts are attractive for a resident smart-home agent. Gemini 3.7 Flash has a 1,048,576-token input limit, a 65,536-token output limit, and low, medium, and high thinking levels, with medium as the default [3]. Google also frames it as its “most intelligent workhorse model yet for coding and agents,” and the Interactions API is part of that launch material [2].
That does not prove it will control your house reliably. The launch blog says Gemini 3.7 Flash scored 65.3% with DeepSWE v1.1 in agentic coding, which supports the claim that the model is meant for tool-heavy coding and agent workflows [4]. It is not a smart-home hardware certification, and it should not be treated as evidence that a thermostat command landed. Your executor logs are the evidence for that.
The 1M-token context does make a different design feel less absurd than it did a few model generations ago. You can keep house rules, room names, resident preferences, recent failures, and selected automation descriptions in context. Still, long context is not a garbage drawer. Send the current relevant state, not every event your hub has emitted since breakfast.
Cost math for an always-on agent
Google’s published Gemini 3.7 Flash introductory pricing is $0.75 per 1 million input tokens and $3.75 per 1 million output tokens through December 31, 2026. After that, the listed rates rise to $1.50 per 1 million input tokens and $7.50 per 1 million output tokens [2]. Use those as the baseline. If you buy through a reseller, treat that reseller’s price sheet as a separate contract rather than blending it into this estimate.
The practical formula is simple:
daily_cost = (daily_input_tokens / 1_000_000 * input_rate) + (daily_output_tokens / 1_000_000 * output_rate)| Hypothetical usage pattern | Tokens per day | Daily cost through Dec. 31, 2026 | Daily cost after intro pricing |
|---|---|---|---|
| Quiet background agent: 20,000 input and 1,000 output tokens per hour | 480,000 input / 24,000 output | $0.45 | $0.90 |
| Heavier resident agent: 200,000 input and 10,000 output tokens per hour | 4,800,000 input / 240,000 output | $4.50 | $9.00 |
| Command-only use: 20 turns per day at 5,000 input and 500 output tokens per turn | 100,000 input / 10,000 output | $0.11 | $0.23 |
Those rows are not usage claims; they are sizing examples so you can plug in your own logs. The output side is more expensive, but smart-home control usually should not produce long prose. The input side grows when you keep repeating state snapshots, tool declarations, and long conversation history. If the agent runs for days, cost control comes from trimming the active context and tools, not from hoping the model somehow becomes free because it is “always on.”
A useful logging line for your own estimate is: timestamp, user text token estimate, tool declaration token estimate, state snapshot token estimate, model output token estimate, function names called, and whether a hub command was sent. After a weekend, you will know whether your agent is command-only cheap, background-agent plausible, or too chatty to leave running.
Where to stop before calling it certified
This recipe gives you a working pattern, not a blanket compatibility certificate. Before you label your setup Confirmed, fill in your own test matrix: hub platform, hub model, firmware or app version, Gemini SDK version, date tested, devices touched, commands attempted, commands accepted by the hub, and commands verified by device state readback. If you are still deciding whether Gemini belongs at the model layer or whether you want the Google Home consumer path, the boundary discussion belongs in Gemini 3.7 Flash and Google Home compatibility.
Gemini 3.7 Flash can plausibly sit behind a smart-home agent in Q3 2026, and the introductory pricing makes the experiment cost-plausible. The safe unit is still smaller than the phrase “AI controls my home”: a declared function, a visible function_call, a hub-owned executor, and a function_result that closes the cycle. Let the model propose actions. Let your executor perform only allowed ones.
References
- Function calling, Google AI for Developers
- What’s new in Gemini 3.7 Flash, Google AI for Developers
- Gemini 3.7 Flash, Google AI for Developers
- Introducing Gemini 3.7 Flash, Google Blog
- Gemini Function Calling Explained with Python: Step-by-Step Guide
