Home automation
Command a simulated five-room house in plain language: "turn off the lights in
Showcase — Home automation
Command a simulated five-room house in plain language: "turn off the lights in
every unoccupied room", "cool anything warmer than 24 down to 21". Conditional
commands force the read-then-act pattern — the model must call get_state,
reason over what came back, and only then issue the set_light and
set_temperature calls that qualify. The reply appends a change list computed
from the state diff in Python — not the model's own account of what it did —
plus the final house state, so what you see is what actually happened.
Run
bash bootstrap-secrets.sh # reads ../../../../.env, writes secrets/
docker compose up --build # default: PROVIDER=openai
Open http://localhost:3000.
To run against Gemini instead:
PROVIDER=gemini docker compose up --build
What's where
backend/ai_openai.py— the house state, three tools (one read, two writes), a per-request dispatch closure, and the bounded loopbackend/ai_gemini.py— same house, Gemini spellingbackend/main.py— identical FastAPI loader; reads PROVIDER and dispatchesfrontend/app/page.tsx— textarea + resultdocker-compose.yml— two services, secrets mounted from./secrets/
Every request gets a fresh copy of the house (copy.deepcopy), so runs are
repeatable and two users can't trample each other's lights. The "Changes
applied" section is a Python diff of before-vs-after state — models sometimes
misreport their own side effects, so the report never relies on the model's
memory of what it called.
Stop
docker compose down
Run locally
Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on localhost:3000.
unzip home-automation.zip
cd home-automation
bash bootstrap-secrets.sh # one-time: pulls API keys into ./secrets
docker compose up --build # default provider: openai
# or: PROVIDER=gemini docker compose up --build
Type some input, pick a provider, and run the same code shown in Source against the live API. Sign-in required.
The same modules the Run button hits. The whole project (frontend, Dockerfile, compose) is in the ZIP under README.
backend/ai_openai.py
"""Week 6 - Showcase 2 (OpenAI): a simulated smart home, read-then-act.
Conditional commands force real chaining: "turn off lights in rooms
warmer than 22" can't be done blind. The model reads state, decides which
rooms qualify, then acts — and the tool results after each write confirm
what actually changed. Each request gets a fresh copy of the house, so
runs are repeatable.
"""
import copy
import json
from openai import OpenAI
_client = OpenAI()
_MODEL = "gpt-5.4-nano"
# The house resets per request — a session-scoped world, not shared state.
_INITIAL = {
"living room": {"light": "on", "temperature_c": 23.5, "occupied": True},
"kitchen": {"light": "on", "temperature_c": 21.0, "occupied": False},
"bedroom": {"light": "off", "temperature_c": 24.0, "occupied": False},
"office": {"light": "on", "temperature_c": 26.0, "occupied": True},
"bathroom": {"light": "on", "temperature_c": 22.0, "occupied": False},
}
_TOOLS = [
{
"type": "function",
"name": "get_state",
"description": "Current state of every room: light, temperature_c, occupied.",
"parameters": {"type": "object", "properties": {}},
},
{
"type": "function",
"name": "set_light",
"description": "Switch one room's light on or off.",
"parameters": {
"type": "object",
"properties": {
"room": {"type": "string",
"enum": ["living room", "kitchen", "bedroom", "office", "bathroom"]},
"state": {"type": "string", "enum": ["on", "off"]},
},
"required": ["room", "state"],
},
},
{
"type": "function",
"name": "set_temperature",
"description": "Set one room's thermostat target in Celsius (10-30).",
"parameters": {
"type": "object",
"properties": {
"room": {"type": "string",
"enum": ["living room", "kitchen", "bedroom", "office", "bathroom"]},
"celsius": {"type": "number"},
},
"required": ["room", "celsius"],
},
},
]
def _make_dispatch(house: dict) -> dict:
def get_state() -> dict:
return house
def set_light(room: str, state: str) -> dict:
house[room]["light"] = state
return {"room": room, "light": state}
def set_temperature(room: str, celsius: float) -> dict:
if not 10 <= celsius <= 30:
return {"error": "celsius must be between 10 and 30"}
house[room]["temperature_c"] = celsius
return {"room": room, "temperature_c": celsius}
return {"get_state": get_state, "set_light": set_light,
"set_temperature": set_temperature}
def run(command: str) -> str:
house = copy.deepcopy(_INITIAL)
dispatch = _make_dispatch(house)
input_list = [{"role": "user", "content": command.strip()}]
response = None
for _ in range(8): # bounded — never ship an open loop
response = _client.responses.create(
model=_MODEL,
instructions="You control a smart home. For conditional commands, "
"read the state first, then act only on rooms that "
"match. Your final summary must list every set_light "
"and set_temperature call you made in this conversation, "
"with the room and value — repeat them from the tool "
"results, never from memory.",
input=input_list,
tools=_TOOLS,
)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
input_list += response.output
for call in calls:
args = json.loads(call.arguments)
fn = dispatch.get(call.name)
result = fn(**args) if fn else {"error": f"unknown tool {call.name}"}
input_list.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result),
})
# Report side effects from the state diff, not from the model's memory —
# the model narrates, but the source of truth is computed.
changes = []
for room, before in _INITIAL.items():
for key, old in before.items():
new = house[room][key]
if new != old:
changes.append(f"- {room}: {key} {old} -> {new}")
changed = "\n".join(changes) if changes else "(none)"
summary = response.output_text if response else ""
return (f"{summary}\n\nChanges applied (computed from state):\n{changed}"
f"\n\nFinal state:\n{json.dumps(house, indent=2)}")
backend/ai_gemini.py
"""Week 6 - Showcase 2 (Gemini): a simulated smart home, read-then-act."""
import copy
import json
import os
from google import genai
from google.genai import types
_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
_MODEL = "gemini-3.1-flash-lite"
_INITIAL = {
"living room": {"light": "on", "temperature_c": 23.5, "occupied": True},
"kitchen": {"light": "on", "temperature_c": 21.0, "occupied": False},
"bedroom": {"light": "off", "temperature_c": 24.0, "occupied": False},
"office": {"light": "on", "temperature_c": 26.0, "occupied": True},
"bathroom": {"light": "on", "temperature_c": 22.0, "occupied": False},
}
_ROOMS = ["living room", "kitchen", "bedroom", "office", "bathroom"]
_CONFIG = types.GenerateContentConfig(
system_instruction="You control a smart home. For conditional commands, "
"read the state first, then act only on rooms that "
"match. Your final summary must list every set_light "
"and set_temperature call you made in this conversation, "
"with the room and value — repeat them from the tool "
"results, never from memory.",
tools=[types.Tool(function_declarations=[
types.FunctionDeclaration(
name="get_state",
description="Current state of every room: light, temperature_c, occupied.",
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
),
types.FunctionDeclaration(
name="set_light",
description="Switch one room's light on or off.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"room": types.Schema(type=types.Type.STRING, enum=_ROOMS),
"state": types.Schema(type=types.Type.STRING, enum=["on", "off"]),
},
required=["room", "state"],
),
),
types.FunctionDeclaration(
name="set_temperature",
description="Set one room's thermostat target in Celsius (10-30).",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"room": types.Schema(type=types.Type.STRING, enum=_ROOMS),
"celsius": types.Schema(type=types.Type.NUMBER),
},
required=["room", "celsius"],
),
),
])],
)
def _make_dispatch(house: dict) -> dict:
def get_state() -> dict:
return house
def set_light(room: str, state: str) -> dict:
house[room]["light"] = state
return {"room": room, "light": state}
def set_temperature(room: str, celsius: float) -> dict:
if not 10 <= celsius <= 30:
return {"error": "celsius must be between 10 and 30"}
house[room]["temperature_c"] = celsius
return {"room": room, "temperature_c": celsius}
return {"get_state": get_state, "set_light": set_light,
"set_temperature": set_temperature}
def run(command: str) -> str:
house = copy.deepcopy(_INITIAL)
dispatch = _make_dispatch(house)
contents = [types.Content(role="user", parts=[types.Part(text=command.strip())])]
response = None
for _ in range(8): # bounded — never ship an open loop
response = _client.models.generate_content(
model=_MODEL, contents=contents, config=_CONFIG,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
fn = dispatch.get(fc.name)
result = fn(**fc.args) if fn else {"error": f"unknown tool {fc.name}"}
parts.append(types.Part.from_function_response(name=fc.name, response=result))
contents.append(types.Content(role="user", parts=parts))
# Report side effects from the state diff, not from the model's memory —
# the model narrates, but the source of truth is computed.
changes = []
for room, before in _INITIAL.items():
for key, old in before.items():
new = house[room][key]
if new != old:
changes.append(f"- {room}: {key} {old} -> {new}")
changed = "\n".join(changes) if changes else "(none)"
summary = (response.text or "") if response else ""
return (f"{summary}\n\nChanges applied (computed from state):\n{changed}"
f"\n\nFinal state:\n{json.dumps(house, indent=2)}")
Project files
.gitignoreREADME.mdbackend/Dockerfilebackend/ai_gemini.pybackend/ai_openai.pybackend/main.pybackend/requirements.txtbootstrap-secrets.shdocker-compose.ymlfrontend/Dockerfilefrontend/app/layout.tsxfrontend/app/page.tsxfrontend/next.config.tsfrontend/package.jsonfrontend/tsconfig.json