Traditional LLM integrations turn developers into state managers. You keep a growing history array, you persist it across services, and you write the glue that runs tools and feeds their results back. The Gemini Interactions API moves that state to the server. Each request creates an interaction with its own ID, and the next request continues from it with previous_interaction_id.
In this codelab you build an on-call agent for Orbital 7, an ageing space station that has just lost main power. The agent has to work out what is wrong from telemetry it cannot fully trust, recover from hardware that fails when it is used, spend limited coolant and power wisely, and decide when to hand over to a human. Nothing in the tools tells it the order of operations: it has to reason.
What you'll learn
- Run a multi-step tool loop with
client.interactions, typedfunction_callsteps andfunction_resultinputs matched by call ID. - Design tools that make a model reason: typed arguments, enums, limited resources and errors it can recover from.
- Read the agent's thought summaries, and measure what each turn sends compared with what the model reads.
- Get a machine-readable incident report with structured output, and check the agent's claims against ground truth.
- Resume a session from a different process using only its interaction ID.
- Branch a session from an earlier turn to test how the agent handles a different failure.
- Apply production practices and know the trade-offs of server-side state.
What you'll need
- A modern web browser.
- A Google Account to create a free Gemini API key in Google AI Studio.
- A Google Cloud project, used only to open Cloud Shell.
- Intermediate Python: functions, dictionaries, classes and reading JSON.
Billing does not need to be enabled. Cloud Shell is the coding environment, and the Interactions API traffic goes through Google AI Studio with your API key.
Workshop plan (90 minutes)
| Block | Time | Steps |
|---|---|---|
| The Interactions API model | 10 min | Overview, Understand the Interactions API |
| Setup | 10 min | Set up your environment, project structure |
| Build the agent | 25 min | Station simulator, tools, agent loop, first run |
| Trust, but verify | 10 min | Structured incident report, scenarios 2 and 3 |
| State in action | 25 min | Measure, resume from a new process, branch the timeline |
| Production and wrap-up | 10 min | Production practices, trade-offs, clean up |
With generateContent, every request is independent. To hold a conversation, your code keeps the history and sends all of it, every turn, in the exact order the model expects. With the Interactions API, each call to client.interactions.create() produces an interaction resource that is stored on the server (store defaults to True). The response holds the new steps the model produced: thoughts, function calls and its output. To continue, you send only the new input and the ID of the interaction to continue from.
Stateless vs stateful
| Feature | generateContent | Interactions API |
|---|---|---|
| Where history lives | In your code | On the server, linked by interaction ID |
| What you send each turn | The whole history, every time | Only the new input or tool results |
| What the model reads | The whole history | The whole history too, loaded on the server, with implicit caching |
| Tool calls and results | functionCall and functionResponse parts | function_call steps and function_result inputs matched by call_id |
| Response shape | Candidates with parts | Typed steps: thought, function_call, model_output |
| Status | Finish reason | completed, requires_action, in_progress, failed, cancelled, incomplete |
What carries over, and what does not
previous_interaction_id carries over the conversation history only. The model, tools, system_instruction and generation_config apply to a single interaction, so you send them on every request. Forgetting this is the most common bug: the agent quietly loses its instructions after the first turn.
The Interactions API requires the unified Google Gen AI SDK (google-genai >= 2.3.0) and uses gemini-3.8-flash as the recommended default model. Legacy SDKs (google-generativeai, google-cloud-aiplatform) are not supported for the Interactions API, and deprecated model families (gemini-2.5-*, gemini-2.0-*, gemini-1.5-*) should not be used.
The trade-offs
- Retention: Stored interactions are kept for 1 day on the free tier and up to 55 days on paid tiers. Long-lived sessions need a plan.
- Opting out:
store=Falsekeeps nothing on the server, but it also turns offprevious_interaction_idand background execution. - Context still costs: The model still reads the full history on every turn. You stop managing it, and implicit caching lowers the cost, but long sessions still grow.
- Your environment is still yours: The server remembers the conversation, not the state of the systems your tools change. You will see why this matters when you branch a session.
When continuing a session with previous_interaction_id, what is automatically carried over by the server?
You will work entirely in Google Cloud Shell, a free browser-based Linux terminal with Python, the google-genai SDK and an editor already installed. Nothing needs to be installed on your machine.
Launch Cloud Shell
- Open the Google Cloud Console.
- Select your project in the top-left dropdown.
- Click the Activate Cloud Shell icon (
>_) in the top-right toolbar. If you are asked to authorise the terminal, click Authorize.
Update the system packages
The google-genai SDK is pre-installed in Cloud Shell. Update the system packages so you are on the latest release (this can take a few minutes):
If you are running locally or want to ensure an isolated Python environment with google-genai >= 2.3.0 and pydantic, you can create and activate a virtual environment inside your project directory: python3 -m venv .venv && source .venv/bin/activate && .venv/bin/pip install -U "google-genai>=2.3.0" pydantic
Create a Gemini API key
- Open Google AI Studio in a new browser tab and sign in.
- Click Create API key and copy the key.
- Back in Cloud Shell, export the key. The SDK reads
GEMINI_API_KEYautomatically:
Environment variables are cleared when Cloud Shell restarts or when you open a new tab. If you see an authentication error later, run the export command again.
Project Structure
Create a folder for the project and the files you will fill in:
simulator.py: The station. It holds the true state, the hidden faults and averdict()method that reports ground truth. In production, this is your infrastructure.tools.py: The tool declarations the model sees, and a dispatcher that runs them and turns failures into results the model can read.agent.py: The model configuration, the orchestration loop and the structured incident report.main.py: Runs an incident from start to finish and saves the session tosession.json.resume.py: A separate process that continues a stored session using only its ID.branch.py: Rewinds a session to an earlier turn and replays it with a different outcome.
To edit the files, click Open Editor in the Cloud Shell toolbar and open the orbital7-agent folder.
Orbital 7's reactor overheated and its breakers cut main power. The scrubbers stopped, so oxygen is falling, and the telemetry array went dark. The simulator models this incident with problems the agent has to discover for itself:
- Stale telemetry: With no power, the dashboard replays its last sync. It shows a safe core temperature and healthy oxygen, and both are wrong.
- A jammed valve: One of the two coolant valves does not respond. The agent only finds out by trying it.
- Limited coolant: There is enough for two vents, which is exactly what the core needs. A wasted vent cannot be undone.
- An overloaded configuration: The power settings saved before the blackout ask for 100 MW, more than the reactor can supply. Restarting the bus as it is will trip it again.
- A clock: Every action takes time, and oxygen drops by 1% per action until the scrubbers restart. Below 65% the station is lost.
- Trade-offs: Life support, telemetry and the hydroponics algae farm all need power, and the algae freeze if they stay unpowered for too long. Something has to be shed.
You can run the simulator with one of three scenarios: 1 (Thermal Trip), 2 (Ghost Sensor, which adds a faulty oxygen sensor) and 3 (random faults, reproducible with --seed).
Add the code
Open simulator.py, paste the following code and save the file:
How simulator.py works
- Scenario configuration (
SCENARIOSand__init__): Each scenario configures which coolant valve is jammed ("A"or"B"), whether the oxygen sensor is stuck at97.0%, and the reactor's maximum output (60–70MW). The station starts at1040°C(SAFE_CORE_TEMPis800°C), with100%coolant reserve,84.0%true oxygen, and a pre-blackout sector allocation totaling100MW. - The ticking clock (
_advance_timeand_gate): Every public tool method callsself._gate()first. Unless the session has already ended,_gate()calls_advance_time(), incrementingself.tick, droppingself.oxygenby1.0%per action while life support is offline, and incrementingself.hydroponics_unpowered_ticks(freezing the algae permanently if unpowered for more than 15 actions). - Dashboard vs local diagnostics (
get_station_statusvsrun_diagnostic): While the power bus is offline orcommshas less than10MW,get_station_status()returns a"DEGRADED"snapshot with stale values (790°Cand95.0%oxygen). By contrast,run_diagnostic(subsystem)reads local battery-backed sensors for a single subsystem (reactor,power,life_support, orcomms)—includingo2_sensor_self_test("FAIL"when the O2 sensor is faulty) andco2_ppm(which rises as true oxygen falls). - Physical constraints (
vent_coolant,set_sector_power,restart_power_bus,restart_life_support): Each successful coolant vent uses40%coolant and drops the core temperature by180°C, so two vents bring the reactor from1040°Cdown to680°C(below the800°Cbreaker lock). Violating any physical constraint raises aValueErrororRuntimeErrorwith a descriptive message. - Ground truth (
verdict): Theverdict()method is never exposed to the agent as a tool. It inspects the station's true internal state at the end of a run so you can verify whether the agent's report matches reality.
If a tool description says "run this before that", the model follows instructions instead of reasoning. These tools are designed the other way round:
- Describe capability and constraints, never order: Each description says what the tool does and what it needs. The agent works out the sequence.
- Use typed arguments and enums:
run_diagnostic(subsystem),vent_coolant(valve)andset_sector_power(sector, megawatts)give the model real choices, and enums stop it inventing values. - Offer cheap and expensive ways to know:
get_station_statusis broad but can be stale.run_diagnosticis accurate but covers one subsystem per action. - Return errors as data: A failed action comes back as a
function_resultwithis_errorset toTrueand a message that explains why, so the agent can change its plan instead of the script crashing. - Give it an exit:
escalate_to_humanlets the agent stop when there is no safe way forward, which is better than guessing.
The Interactions API does not yet support automatic function calling from Python functions, so each tool is declared explicitly as a JSON Schema definition.
Add the code
Open tools.py, paste the following code and save the file:
How tools.py works
TOOL_DECLARATIONS: Defines the seven tools in the JSON Schema format expected byclient.interactions.create(..., tools=TOOL_DECLARATIONS). Strict"enum"lists (["reactor", "power", "life_support", "comms"],["A", "B"]) and numeric bounds ("minimum": 0, "maximum": 60) constrain the model's arguments at generation time.TOOL_NAMESallowlist: Builds a set of valid tool names fromTOOL_DECLARATIONS.execute_tool()checksif name not in TOOL_NAMESbefore callinggetattr(station, name), ensuring the model can never invoke internal helper methods such as_advance_timeorverdict.- Turning exceptions into observations (
execute_tool): When a tool raises aValueError,RuntimeError, orTypeError(for example, when Valve A is jammed or the power bus trips),execute_tool()catches the exception and returns(f"ERROR: {error}", True). The agent loop sends that string back to the model with"is_error": Trueso the model can adapt its plan instead of crashing the Python process.
agent.py holds everything that talks to the model: the configuration sent with every request, the orchestration loop and the structured incident report you will use later.
Add the code
Open agent.py, paste the following code and save the file:
How agent.py works
- Principles, not steps (
SYSTEM_INSTRUCTION): The system instruction tells the agent how to behave (verify, conserve, protect life support, escalate) but not what sequence of tools to call. REQUEST_CONFIGon every call: Becausemodel,tools,system_instructionandgeneration_configare interaction-scoped,REQUEST_CONFIGgroups them in a single dictionary that is unpacked (**REQUEST_CONFIG) on everyclient.interactions.create()call. Only the conversation history comes fromprevious_interaction_id.- Thought summaries (
print_thoughts): Setting"thinking_summaries": "auto"ingeneration_configincludesthoughtsteps ininteraction.steps.print_thoughts()iterates overstep.summaryblocks and prints the model's reasoning before its tool calls. - One result per call (
run_agent): Thecreateresponse contains only this turn's newly generated model steps, so everyfunction_callininteraction.stepsis new. The loop executes each call viaexecute_tool(), records the call insession["turns"], and builds afunction_resultdict with a matchingcall_idandname. When the model requests multiple tool calls in a single turn, all of their results are sent back together in the nextclient.interactions.create()call. - Measurement and safety limits: Each turn logs
payload_bytes(the UTF-8 byte length of the request payload your code sent) alongsideinteraction.usage.total_input_tokensandtotal_cached_tokens.MAX_TURNSstops any run that does not reachcompleted, and any status other thancompletedorrequires_actionexits the loop cleanly. - Structured incident report (
IncidentReportandgenerate_report): Uses Pydantic'sIncidentReport.model_json_schema()insideresponse_formatto request a strictly typed JSON summary from the completed session, and parsesinteraction.output_textback into anIncidentReportinstance withmodel_validate_json().
main.py wires the simulator and the agent together: it parses command-line flags, starts the first interaction, runs the orchestration loop, requests the structured incident report, and compares it against the simulator's ground-truth verdict.
Add the code
Open main.py, paste the following code and save the file:
How main.py works
- Initialising the environment and audit log: Parses
--scenario(1,2, or3) and--seed, creates theStationinstance, and initialises thesessiondictionary that will be saved tosession.json. - Starting Turn 1: Calls
client.interactions.create(input=first_input, **REQUEST_CONFIG)with the initial alert prompt and passes the resultinginteractionintorun_agent(). - Chaining the structured report: Once
run_agent()finishes withinteraction.status == "completed",main.pypassesinteraction.idtogenerate_report(). Because the server still holds the full incident history under that ID,generate_report()needs only a one-line prompt and the JSON Schema to produce the structured report. - Comparing claims, ground truth, and token usage: Prints the agent's
IncidentReport, followed bystation.verdict()and the turn-by-turnPAYLOAD VS CONTEXTsummary.
Run scenario 1
Watch the 💭 lines as well as the actions. As you watch, look for the answers to these questions:
- Did the agent trust the stale dashboard, or did it check with a diagnostic first?
- What did it do when a valve failed? Did it retry the same valve or switch?
- Did it notice the saved power configuration before restarting the bus, or did it learn from the trip?
- Which sector did it shed, and did it explain why?
- Did it confirm the result with diagnostics before declaring success?
The model decides its own path, so every run can take a slightly different sequence of diagnostics and actions to reach stability.
At the end of the run, generate_report() asks the same session for an incident report. It sets response_format to a JSON Schema generated from the IncidentReport Pydantic model, so interaction.output_text is valid JSON that IncidentReport.model_validate_json() turns into a typed object. Downstream systems, such as a ticketing tool, can consume it directly.
Valid JSON is not the same as true JSON. main.py prints the agent's report and then the simulator's verdict, which the agent never sees. Compare final_state in the report with the checks and true_oxygen_percent in the verdict.
Run the Ghost Sensor scenario
In this scenario the oxygen sensor is faulty and always reads 97%. The life_support diagnostic shows the self-test failing and CO2 rising. Did the agent notice the contradiction? Does the oxygen figure in its report match true_oxygen_percent?
Try random faults
Treat an agent's report as a claim. Check the facts that matter against your own systems before you act on it.
Why does main.py compare the Pydantic IncidentReport against station.verdict()?
At the end of each run, main.py prints a table of what your code sent on each turn and what the model read. Look closely at the 📊 PAYLOAD VS CONTEXT output in your terminal—you should see two distinct trends:
- Sent stays small: Each turn sends only the latest tool results, a few hundred bytes, however long the session gets.
- Read keeps growing: The model's input tokens grow every turn, because it still reads the whole history. A large share of it should show as cached, which lowers the cost.
Compare the cached token count on Turn 1 with the final turn of your run. Notice how the server-side history grows with each step while implicit caching reuses the prefix from earlier turns.
This is the honest version of the stateful benefit. The Interactions API removes history management from your code and keeps requests small, which matters when many services or slow networks are involved. It does not make the model's context free. For very long sessions, you still need a strategy, such as summarising and starting a new session from the summary.
Your loop keeps no history. Everything the agent knows lives on the server, linked to an interaction ID, so any process that has the ID can continue: another terminal, another container or another service. Prove it with a script that has no simulator, no tools and no local state.
Add the code
Open resume.py, paste the following code and save the file:
How resume.py works
- Zero local dependencies: Notice that
resume.pydoes not importsimulator,tools, oragent. It creates a freshgenai.Client()in an isolated Python process. - Looking up the interaction ID: It reads either an interaction ID passed on the command line (
sys.argv[1]) or the latest ID stored insession.json(report_interaction_idorfinal_interaction_id). - Continuing the server-side chain: Passing
previous_interaction_id=interaction_idtoclient.interactions.create()loads the entire multi-turn incident history on the server—every diagnostic reading, valve failure, thought summary, and power adjustment—so the model can answer follow-up questions about its decisions and returns a new interaction ID (follow_up.id).
Run it in a separate process
Open a new Cloud Shell tab with the + icon. It is a new shell session, so export your key again, then run the script. By default it continues from the last interaction saved in session.json; you can also pass any interaction ID as an argument.
Try questions such as "Which reading did you distrust, and why?" or "What would you do differently with one more valve failure?". The agent answers from the stored session.
In production, this is how a web request, a worker or a Cloud Run instance continues the same agent session: store the interaction ID next to your own record, such as an incident ticket, and pass it back with the next request.
Because every interaction is stored, you can continue from any earlier one, not just the latest. branch.py goes back to the turn where a coolant vent succeeded and changes history: this time that valve jams too. With both valves gone, the core cannot be cooled, and the right answer is to escalate.
There is a catch, and it is the most important architecture lesson in this codelab. The server restores the conversation, but not the station. Your tools changed the simulator, so branch.py rebuilds the station as it was at that turn by replaying every earlier tool call from session.json. In production, the same applies to anything your agent touched: the API stores what was said, and you are responsible for the state of your systems.
Add the code
Open branch.py, paste the following code and save the file:
How branch.py works
- Locate the branch point (
target_index): Scans the savedturnsinsession.jsonto find the first turn wherevent_coolantsucceeded (not c["is_error"]). - Replay earlier tool calls to restore environment state: Instantiates a fresh
Stationwith the original scenario and seed, then replays every tool call fromoriginal["turns"][:target_index]. This bringsstationto the exact tick, oxygen level, and configuration it had right beforetarget_turnran. - Inject the new failure (
station.jammed_valves.add): Before executing the tool calls intarget_turn, it jams the valve that previously worked. Whenexecute_tool()runsvent_coolant, it now returns an actuator fault (is_error = True) with the samecall_idthe model generated ontarget_turn. - Fork the conversation (
previous_interaction_id=target_turn["interaction_id"]): Callsclient.interactions.create()pointing back totarget_turn["interaction_id"]with the new error result, and hands the forked interaction torun_agent(). The original session insession.jsonremains untouched, while the alternate timeline is saved tobranch.json.
Run the branch
Watch whether the agent tries to recover, recognises that it cannot, and escalates. The verdict shows escalated set to true. You can branch the same turn as many times as you like: each branch is a new interaction, and the original session is unchanged.
When branching from an earlier interaction ID, why does branch.py replay prior tool calls against a fresh Station instance first?
This workshop runs everything in Cloud Shell so you can focus on the API. When you move a stateful agent into a real Google Cloud architecture, apply these practices:
- Store interaction IDs, not history: Save the latest interaction ID with your own record, for example in Firestore against an incident ID. Any instance of your service can then continue the session, so the service itself stays stateless and scales out on Cloud Run.
- Record what your tools did: Keep your own log of tool calls and results, as
session.jsondoes here. You need it to rebuild, audit or branch, because the server does not know the state of your systems. - Send configuration on every call: Keep the model, tools, system instruction and generation settings in one place, as
REQUEST_CONFIGdoes, and send them with every request. - Design tools for reasoning and safety: Typed arguments, enums, clear errors and an escalation path. Put destructive actions behind a human approval step.
- Bound the loop: Keep a turn limit, and treat any status other than
completedorrequires_actionas a stop condition. - Verify before you act: Validate structured output, and check the agent's claims against your own systems.
- Plan for retention and privacy: Know your retention window, use
store=Falsewhere data must not be stored, and delete interactions you no longer need. - Protect the key: Keep the API key in Secret Manager and inject it at runtime.
- Observe with steps: Export
interaction.steps, including thought summaries, to your logging system as an audit trail.
Clean Up
- Clear the environment variable:
-
Revoke the API key: If you created a key only for this workshop, open the API keys page in Google AI Studio and delete it.
-
Stored interactions: Interactions from this workshop expire on their own (after 1 day on the free tier). To remove them sooner, call
client.interactions.delete(id=...)for the IDs insession.jsonandbranch.json. -
Close Cloud Shell: Type
exitor close the Cloud Shell pane. Yourorbital7-agentfolder stays in your Cloud Shell home directory if you want to come back to it.
Summary
You built an agent that diagnoses and recovers from a cascading failure with incomplete information, and you used the Interactions API to run, inspect, resume and branch its sessions.
What you covered:
- A tool loop built on typed
function_callsteps andfunction_resultinputs matched by call ID. - Tools that make a model reason, and errors it can recover from.
- Interaction-scoped configuration, and what
previous_interaction_iddoes and does not carry over. - Thought summaries, and the difference between what you send and what the model reads.
- Structured incident reports, checked against ground truth.
- Resuming a session from a new process, and branching it from an earlier turn while rebuilding your own environment state.
Next steps:
- Add a human approval step before destructive tools, and return a rejected action as a
function_result. - Stream the agent's thoughts and actions live with
stream=True. - Run long incidents with
background=Trueand poll them withclient.interactions.get(). - Hand the finished session to the Deep Research agent for a postmortem, chained with
previous_interaction_id. - Replace the simulator with real Cloud Monitoring and Compute Engine calls.
Orbital 7 Complete Reference Code
Want a clean copy of all six Python files from this workshop for future reference? Download the complete ZIP archive below.
Learn more: