Build a Daily Tech Digest Agent with Managed Agents on the Gemini API

1. Overview

The AI and tech landscape moves faster than anyone can track. New models, papers, and products drop daily. A digest agent that fetches today's headlines, writes sharp summaries, and generates a PDF every morning would solve that, but building one used to mean picking a framework, defining tools in Python, writing an orchestration loop, packaging a container, and deploying to Cloud Run. All of that before the agent had made a single web request.

Managed agents on the Gemini API changes the equation. You write two markdown config files and a pre-built renderer script, make one API call, and a real Ubuntu sandbox boots, browses the web, writes your summaries, and generates a PDF. No containers. No deployment. No orchestration code.

In this codelab you will build exactly that agent: from an empty function to a working daily digest, one concept at a time.

What you will build

  • Create and run your first managed agent in a real Linux sandbox
  • Customize the agent with editorial voice, web sources, and a PDF skill
  • Add a safety hook to block destructive commands before they run
  • Download the PDF the agent generated
  • Refine the digest in a multi-turn conversation without re-fetching the web
  • Save the agent config and invoke it by ID in future runs
  • Send the digest to your inbox via the Gmail API
  • Schedule the agent to run and send automatically every day

What you'll need

2. What is Managed agents on the Gemini API?

Three levels of AI systems

Before diving into code, here is where Managed Agents fits relative to the two alternatives:

Level

What It Is

Who Manages Infrastructure?

Standard LLM

You prompt, it replies with text. No hands, no memory, no internet.

N/A: it cannot do anything on its own

Self-Hosted Agent

You wire up ADK/LangChain/AutoGen + Docker + tools + memory.

You: all of it (or a managed platform like Agent Engine)

Managed Agent

You give it a goal. Google provisions a secure sandbox. The agent writes code, runs it, reads errors, searches the web, and fixes bugs autonomously.

Google: all of it

This codelab is about the third row. You supply a task and configuration files. Google handles everything else.

What you would build with ADK + Cloud Run

To build a news digest agent that browses the web, runs Python, and generates a PDF, you would need all of this with ADK + Cloud Run:

# agent.py: define tools and wire up the agent
from google.adk.agents import LlmAgent
from google.adk.tools import google_search, built_in_code_execution

agent = LlmAgent(
    name="digest-agent",
    model=MODEL,
    instruction=AGENTS_MD,          # your editorial voice and rules
    tools=[google_search, built_in_code_execution],
)
# app.py: serve the agent over HTTP
from google.adk.runners import FastApiRunner
runner = FastApiRunner(agent=agent)
app = runner.app
# pdf_tool.py: custom tool, install reportlab, render PDF
# scraper.py: custom tool, fetch each news source
# streaming.py: wire agent events to your SSE endpoint
# Dockerfile: package everything
FROM python:3.12
COPY . /app
RUN pip install google-adk reportlab requests
CMD ["uvicorn", "app:app", "--host", "0.0.0.0"]
# Deploy to Cloud Run
gcloud run deploy digest-agent \
  --image gcr.io/your-project/digest-agent \
  --set-secrets GEMINI_API_KEY=gemini-key:latest \
  --memory 2Gi

That is before the agent has run once. You still own sandbox isolation (so the agent cannot damage your server), package installation, state management between tool calls, and the streaming infrastructure to get events to a client.

What Managed Agents replaces it with

from google import genai
client = genai.Client()

stream = client.interactions.create(
    agent="antigravity-preview-05-2026",
    input="Generate the digest.",
    stream=True,
    environment={
        "type": "remote",
        "sources": [          # your config files, mounted at startup
            {
                "type": "inline",
                "target": ".agents/AGENTS.md",
                "content": AGENTS_MD,
            },
            {
                "type": "inline",
                "target": ".agents/skills/digest-pdf/SKILL.md",
                "content": SKILL_MD,
            },
            {
                "type": "inline",
                "target": ".agents/skills/digest-pdf/scripts/generate_pdf.py",
                "content": GENERATE_PDF_PY,
            },
        ],
    },
)

What ADK + Cloud Run requires

What Managed Agents handles for you

Container image + Dockerfile + CI/CD

Fully managed Ubuntu sandbox (Python 3.12, Node 22, 4 CPU / 16 GB RAM)

Cloud Run deployment + scaling

Provisioned per interaction, auto-expires after 7 days of inactivity

Sandbox isolation

Isolated per interaction

Custom PDF tool + pip install

Agent installs packages inside the sandbox

SSE streaming infrastructure

stream=True returns an event iterable

Tool definitions in Python

Tools built in: web browse, code execution, file system

State management between tool calls

Built into the agent reasoning loop

You write configuration files (AGENTS.md, SKILL.md, a pre-built script) and make one API call. Google handles everything else.

How the sandbox works

interactions.create() call
        │
        ▼
Google provisions Ubuntu sandbox (Python 3.12, Node 22, 4 CPU / 16 GB RAM)
        │
        ▼
Agent reasoning loop:
  plan → fetch URLs → run Python → write files → reason → repeat
        │
        ▼
Events stream back in real time: tool calls, text chunks, completion
        │
        ▼
interaction.completed → environment_id + interaction_id

The sandbox persists for 7 days of inactivity. You can resume it with environment_id to refine the output, run follow-up tasks, or fork it into a saved named agent.

3. Set Up

Click the button below to open this codelab in Google Cloud Shell. All dependencies are pre-installed.

Open in Cloud Shell

Option B: Local setup

git clone https://github.com/Saoussen-CH/tech-digest-managed-agent.git
cd tech-digest-managed-agent

Install uv if needed:

curl -LsSf https://astral.sh/uv/install.sh | sh

Configure your API key

cp .env.example .env
cloudshell edit .env

Set your key:

GEMINI_API_KEY=your-key-here

Install dependencies

uv sync

4. Make Your First Agent Call

Open the starter file

cloudshell edit run_digest.py

run_digest() has one TODO to fill in now and three more for the next step. Two helpers are already pre-filled above it:

  • load_source(path): reads a file from .agents/ relative to the script. You will use it in the next exercise to mount the editorial voice, PDF playbook, and renderer into the sandbox.
  • run_stream(stream): processes the event stream and returns (environment_id, interaction_id). You do not need to write the event loop yourself.

What to add

TODO 1: replace pass with (ignore TODOs 3, 4 for now; those are for the next step):

    from google import genai
    client = genai.Client()

    stream = client.interactions.create(
        agent=BASE_AGENT,
        agent_config={"type": "antigravity", "model": "gemini-3.7-flash"},
        input="Fetch the Hacker News front page and list the top 5 stories.",
        stream=True,
        environment="remote",
    )

    environment_id, interaction_id = run_stream(stream)
    print(f"\nDone. environment_id={environment_id}")

What each part does

genai.Client() reads GEMINI_API_KEY from the environment. Everything else goes through this client.

interactions.create() is the core call. Four parameters make it work:

  • agent=BASE_AGENT: selects the Antigravity agent (antigravity-preview-05-2026), a general-purpose managed agent powered by Gemini 3.7 Flash by default. You can configure the underlying model using agent_config (options: gemini-3.7-flash, gemini-3.6-flash, gemini-3.5-flash, gemini-3.5-flash-lite). It comes with three built-in tools enabled by default: code_execution (run Bash, Python, Node.js), google_search, and url_context (fetch and read web pages). Filesystem tools (read_file, write_file, list_files) are enabled automatically when you pass the environment parameter. One call provisions a fully managed Ubuntu environment with Python 3.12, Node.js 22, git, pip, and curl pre-installed. No container to build, no deployment to run.
  • input: the task for this run. The agent browses Hacker News and reasons about the results.
  • environment="remote": provisions a fresh cloud sandbox for this interaction.
  • stream=True: returns an iterable of events instead of blocking. Without it, the call waits 30-90 seconds and returns all output at once as interaction.output_text. With streaming you see the agent reason and act as it happens. Streaming is not an advanced feature here: it is the right default, because a 90-second black box gives you no signal about whether the agent is working or stuck.

environment_id is a handle to the sandbox that just ran. After interaction.completed, the sandbox does not shut down: it stays alive for up to 7 days. The environment_id is how you get back to it. Pass it to a second interactions.create() call and the agent resumes on the same filesystem, with the same files and installed packages, as if it never left. The next step uses it to download the PDF without re-running the agent, and the step after that uses it to continue the conversation.

interaction_id is a handle to the conversation turn that just completed. Pass it as previous_interaction_id in the next call and the agent has full memory of what it said and did in this turn.

Verify

uv run python run_digest.py

You should see live output as the agent works:

[agent started]
  [tool] run_code
Here are the top 5 stories currently on the Hacker News front page, retrieved via the official Hacker News API:

1. **Qwen 3.6 27B is the sweet spot for local development** (471 points)
2. **.self: A new top-level domain designed to support self-hosting** (116 points)
...
Done. environment_id=e3de58774073f75a6ef42924c6ce2e88

The API returns a real environment_id even with environment="remote". The sandbox ran. What is missing is config: no voice, no skill, no PDF generator. The agent just printed stories as text and stopped. The next step adds those.

Each line of output maps to an event from run_stream():

step.type

What it is

What run_stream() prints

"url_context_call"

agent fetching a URL

[tool] url_context (https://...)

"code_execution_call"

agent running code in the sandbox

[tool] run_code

"google_search_call"

agent searching the web

[tool] google_search

"function_call"

file tools and others

[tool] read_file (/workspace/...)

step.delta where delta.type == "text"

agent writing text

streamed directly to stdout

5. Customize the Agent

The agent had no instructions: no voice, no skill, no PDF generator. In this step you load the configuration files from .agents/ and mount them into the sandbox.

What to change

Make four changes to run_digest.py:

TODO 2: Below load_source(), add the three module-level constants (these sit outside run_digest(), at the top of the file):

AGENTS_MD       = load_source(".agents/AGENTS.md")
SKILL_MD        = load_source(".agents/skills/digest-pdf/SKILL.md")
GENERATE_PDF_PY = load_source(".agents/skills/digest-pdf/scripts/generate_pdf.py")

Open each file to see what you are loading: AGENTS.md sets the editorial voice and workflow rules; SKILL.md is the step-by-step PDF playbook; generate_pdf.py is the pre-built renderer the agent will run.

Now make two more changes inside run_digest():

TODO 3: change environment from "remote" to the sources dict, and set input to "Generate the digest.":

        environment={
            "type": "remote",
            "sources": [
                {
                    "type": "inline",
                    "target": ".agents/AGENTS.md",
                    "content": AGENTS_MD,
                },
                {
                    "type": "inline",
                    "target": ".agents/skills/digest-pdf/SKILL.md",
                    "content": SKILL_MD,
                },
                {
                    "type": "inline",
                    "target": ".agents/skills/digest-pdf/scripts/generate_pdf.py",
                    "content": GENERATE_PDF_PY,
                },
            ],
        },

TODO 4: add this line right after print(f"\nDone. environment_id={environment_id}"):

    save_env(ENVIRONMENT_ID=environment_id, INTERACTION_ID=interaction_id)

save_env is already defined in run_digest.py. It writes both IDs to .env so the next step can download the PDF without re-running the agent.

What each source does

Each source is a file mounted into the sandbox filesystem at startup before the agent runs. The target paths match where the Antigravity harness expects to find them:

.agents/
├── AGENTS.md                              ← auto-loaded as global instructions
└── skills/
    └── digest-pdf/
        ├── SKILL.md                       ← auto-discovered and registered as a skill
        └── scripts/
            └── generate_pdf.py            ← pre-built renderer the agent can run

target path

Variable

What the harness does with it

.agents/AGENTS.md

AGENTS_MD

Auto-loaded as persistent instructions: editorial voice, workflow, execution rules

.agents/skills/digest-pdf/SKILL.md

SKILL_MD

Auto-discovered and registered as a named skill; the agent invokes it by name

.agents/skills/digest-pdf/scripts/generate_pdf.py

GENERATE_PDF_PY

Pre-built PDF renderer; the agent writes summaries.json then runs this script

Verify

uv run python run_digest.py

The run now takes 1-3 minutes. You should see the agent reading config files, writing summaries, and saving the PDF:

[agent started]
  [tool] read_file (/.agents/skills/digest-pdf/SKILL.md)
  [tool] list_files (/.agents/skills/digest-pdf/scripts)
  [tool] read_file (/.agents/skills/digest-pdf/scripts/generate_pdf.py)
  [tool] run_code
  [tool] write_file (/workspace/summaries.json)
  [tool] run_code
  [tool] delete_file (/tmp/test_scrape.py)
I have successfully generated today's tech news digest and saved the formatted document to /workspace/digest.pdf.
Done. environment_id=4129ffd75574e308748e9425d7ec828f

environment_id is now a real value: the sandbox ran with your config files and the agent created digest.pdf. The next step adds a safety hook before downloading.

6. Add a Safety Hook

Hooks let you run a script inside the sandbox before or after each tool call. The digest agent uses code_execution to run Python scripts, so a pre_tool_execution hook can intercept those calls and block destructive shell commands before they execute.

The runtime reads .agents/hooks.json from the sandbox. Before each matching tool call it pipes the call details to your gate script on stdin. The script prints {"decision": "allow"} or {"decision": "deny", "reason": "..."} to stdout. A deny cancels the tool call and the agent sees your reason and self-corrects.

What to add

TODO 5: in run_digest.py, add these two constants near the top, after the existing load_source calls:

import json

HOOKS_JSON = json.dumps({
    "safety-gate": {
        "pre_tool_execution": [
            {
                "matcher": "code_execution",
                "hooks": [
                    {
                        "type": "command",
                        "command": "python3 /.agents/hooks-scripts/gate.py",
                        "timeout": 10,
                    }
                ],
            }
        ]
    }
}, indent=2)

GATE_PY = """\
#!/usr/bin/env python3
import sys, json
data = json.load(sys.stdin)
cmd = str(data.get("tool_call", {}).get("args", {}))
if "rm -rf" in cmd:
    print(json.dumps({"decision": "deny", "reason": "Destructive command blocked by safety gate."}))
else:
    print(json.dumps({"decision": "allow"}))
"""

TODO 6: add two more entries to the sources list inside interactions.create():

{"type": "inline", "target": ".agents/hooks.json",            "content": HOOKS_JSON},
{"type": "inline", "target": ".agents/hooks-scripts/gate.py", "content": GATE_PY},

How hooks fire in the digest run

Every time the agent calls code_execution to run a Python script or shell command, the runtime pipes the call details to gate.py first. If the command contains rm -rf, the hook returns deny and the agent receives the rejection reason and retries with a safe alternative. All other code execution calls pass through unchanged.

Verify

uv run python run_digest.py

The output is identical to before: the safety gate allows all normal PDF-generation commands. To confirm the hook fires, temporarily change the agent input to ask it to run rm -rf /tmp/test — you will see the agent report that the command was blocked and choose an alternative.

7. Download the PDF

The agent wrote digest.pdf to /workspace/digest.pdf inside the sandbox. The environment snapshot is available as a tar archive via the Gemini Files API.

Install requests if needed:

uv pip install requests

What to fill in

Open download_pdf.py. It has two TODOs.

TODO 1: fill in the requests.get() call:

    r = requests.get(
        f"https://generativelanguage.googleapis.com/v1beta/files/environment-{environment_id}:download",
        params={"alt": "media"},
        headers={"x-goog-api-key": api_key},
        allow_redirects=True,
    )
    r.raise_for_status()

The URL addresses the sandbox snapshot. params={"alt": "media"} returns raw bytes instead of metadata. Your existing GEMINI_API_KEY authenticates the Files API too.

TODO 2: find and extract the PDF from the tar archive:

            member = next(m for m in tar.getmembers() if m.name.endswith("workspace/digest.pdf"))
            tar.extract(member, path=tmp, filter="data")

The tar path prefix varies across runs, so search by suffix instead of hardcoding the exact path. filter="data" suppresses the Python 3.13 deprecation warning about unsafe tar extraction.

Verify

uv run python download_pdf.py
Saved digest.pdf (48,231 bytes)

Open digest.pdf in the same directory. It contains the formatted digest the agent generated from live web pages.

8. Continue the Conversation

You already have digest.pdf. If you just wanted the file, you are done. This step is about something different: asking the agent to change the digest without re-fetching the web.

The sandbox is still alive. The agent still has /workspace/digest.pdf and remembers every story it summarized. A second interactions.create() call sends a follow-up message into that same sandbox. Here you ask it to add a "Why it matters" note under each story, and it updates the PDF in place, with no re-fetching and no re-summarizing.

What to fill in

Open refine_digest.py. It has three TODOs.

TODOs 1 and 2: fill in the two multi-turn parameters inside interactions.create():

    environment=environment_id,
    previous_interaction_id=interaction_id,

environment=environment_id resumes the same sandbox with its files and packages. previous_interaction_id=interaction_id gives the agent its conversation history. Nothing else changes from the first call.

TODO 3: persist the new interaction_id back to .env after the event loop:

save_env(INTERACTION_ID=interaction_id)

Every interactions.create() call produces a new interaction_id. Writing it back means the next run passes this refinement as previous_interaction_id, chaining turns correctly. The sandbox ID never changes so ENVIRONMENT_ID does not need to be updated.

The two parameters that make multi-turn work

ID

What it preserves

Analogy

environment=environment_id

Files, installed packages, system state: everything on the Linux filesystem

Keeping the same office desk between meetings

previous_interaction_id=interaction_id

Conversation history: what the agent said and did in prior turns

Remembering what was discussed in the last meeting

You can pass either ID independently:

  • environment_id only: reuse files and packages, but start a fresh conversation. Useful for a new task in the same workspace.
  • previous_interaction_id only: continue the conversation context, but in a fresh sandbox (files are gone).
  • Both: full continuity, which is what this step uses.

Without environment_id: blank sandbox, no PDF. Without previous_interaction_id: no context, agent cannot refine a specific section.

Verify

uv run python refine_digest.py

The stream should be quick; the agent is not re-fetching anything. After it finishes:

Refinement done.
Saved digest_v2.pdf (52,418 bytes)

Open digest_v2.pdf and compare it to digest.pdf. Each story should now have a "Why it matters" line added.

9. Persist a Managed Agent Config

Every call so far has passed AGENTS.md, SKILL.md, and generate_pdf.py inline. That works, but your calling code carries the full file contents on every run. agents.create() bakes the configuration into a saved named agent on Google's side. The next invocation just passes the agent ID:

Inline calls:   send sources on every call
Named agent:    bake once → invoke by ID, no sources

What to fill in

Open save_agent.py. It has one TODO (TODO 1).

Notice that the constants are imported directly from run_digest.py (no duplication):

from run_digest import BASE_AGENT, AGENTS_MD, SKILL_MD, GENERATE_PDF_PY

TODO 1: fill in the agents.create() call:

agent = client.agents.create(
    id="my-digest",
    base_agent=BASE_AGENT,
    agent_config={
        "type": "antigravity",
        "model": "gemini-3.7-flash",
    },
    description="Daily tech digest with editorial voice and PDF generation.",
    base_environment={
        "type": "remote",
        "sources": [
            {
                "type": "inline",
                "target": ".agents/AGENTS.md",
                "content": AGENTS_MD,
            },
            {
                "type": "inline",
                "target": ".agents/skills/digest-pdf/SKILL.md",
                "content": SKILL_MD,
            },
            {
                "type": "inline",
                "target": ".agents/skills/digest-pdf/scripts/generate_pdf.py",
                "content": GENERATE_PDF_PY,
            },
        ],
    },
)

agent_config sets the underlying model. gemini-3.7-flash is the default and the best choice for this workflow; gemini-3.6-flash, gemini-3.5-flash, and gemini-3.5-flash-lite are available if you want a lighter or lower-cost run.

base_environment (not environment) is the key difference from the inline call in the previous step: the sources are stored on Google's side and mounted automatically on every future invocation. Run it once, not on every digest run.

Verify: save the agent

uv run python save_agent.py
Saved: my-digest
my-digest: Daily tech digest with editorial voice and PDF generation.

Invoke the saved agent

Open invoke_agent.py. It calls the saved agent by ID with no sources:

stream = client.interactions.create(
    agent="my-digest",
    input="Generate the digest.",
    stream=True,
    environment="remote",
)

Compare this to the inline call: agent=BASE_AGENT is replaced by "my-digest", and the full environment block with three inline sources is replaced by environment="remote". The config is already baked in on Google's side.

Verify: invoke the saved agent

uv run python invoke_agent.py

You should see the same live stream as the inline run, but the call carries no source files. After the run, ENVIRONMENT_ID and INTERACTION_ID in .env are updated so you can continue with refine_digest.py as before.

[agent started]
  [tool] read_file
  [tool] write_file
  [tool] run_code
I have successfully created today's tech news digest.
Done. environment_id=9a1c3e02-...

10. Send via Gmail

The agent has generated the digest and saved it to /workspace/digest.pdf. So far you have downloaded it locally. This step delivers it directly to your inbox by having the agent call the Gmail REST API from inside the sandbox.

The approach: you obtain an OAuth 2.0 access token locally and pass it to the agent in the input prompt. The agent uses code_execution to build a MIME email with the PDF attached and POST it to the Gmail API. No custom tools, no MCP server registration.

Prerequisites

Enable the Gmail API in your GCP project and create an OAuth 2.0 client ID:

  1. Go to console.cloud.google.com/apis/library/gmail.googleapis.com and enable the Gmail API.
  2. Go to APIs & Services > Credentials > Create Credentials > OAuth 2.0 Client ID.
  3. Application type: Desktop app. Download the JSON and save it as credentials.json in the project root.

Add your recipient email to .env:

RECIPIENT_EMAIL=you@gmail.com

Install the auth libraries if needed:

uv sync

What to fill in

Open send_digest.py. It has two TODOs.

TODO 1: load or refresh an OAuth 2.0 access token:

creds = None
if TOKEN_FILE.exists():
    creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)
if not creds or not creds.valid:
    if creds and creds.expired and creds.refresh_token:
        creds.refresh(Request())
        TOKEN_FILE.write_text(creds.to_json())
    else:
        flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
        creds = flow.run_local_server(port=8080, open_browser=False)
        TOKEN_FILE.write_text(creds.to_json())

Remove the raise NotImplementedError line after adding it. On first run this opens a browser for the OAuth consent screen. The token is cached in .gmail_token.json for future runs.

TODO 2: replace input="" with the email instructions. The token is already in scope as creds.token:

    input=(
        "Use the Gmail REST API to send an email:\n"
        f"  To: {recipient}\n"
        "  Subject: Tech Digest - <today's date in YYYY-MM-DD format>\n"
        "  Attachment: /workspace/digest.pdf attached as digest.pdf\n\n"
        "For the body, read /workspace/summaries.json and format it as a "
        "human-readable newsletter, NOT raw JSON. Use this structure:\n"
        "  Tech Digest - <date>\n\n"
        "  === <source name> ===\n"
        "  1. <title>\n"
        "     <summary>\n\n"
        "Steps:\n"
        "1. Parse /workspace/summaries.json and build the formatted body text above.\n"
        "2. Read /workspace/digest.pdf as bytes.\n"
        "3. Build a MIME multipart message using Python's email library.\n"
        "4. Base64url-encode the raw message.\n"
        "5. POST to https://gmail.googleapis.com/gmail/v1/users/me/messages/send "
        "with Authorization header using this token: "
        f"{creds.token}"
    ),

What each part does

The interaction resumes the same sandbox where the agent already generated digest.pdf and summaries.json. previous_interaction_id gives the agent its conversation history.

The access token is passed in the input string. The agent reads it from the prompt and uses it in the Authorization: Bearer header when calling the Gmail API. It never touches your local machine or filesystem.

The agent uses code_execution to write and run a Python script inside the sandbox: it reads summaries.json, formats it as a newsletter, reads digest.pdf, builds a MIME multipart message, base64url-encodes it, and POSTs to https://gmail.googleapis.com/gmail/v1/users/me/messages/send.

Verify

uv run python send_digest.py
Sending digest...
[agent started]
  [tool] read_file (/workspace/summaries.json)
  [tool] run_code
  [tool] run_code
Email sent successfully.
Email sent. Check your inbox.

Check your inbox. The email arrives with the newsletter-formatted body and digest.pdf attached.

11. Schedule Daily Runs

Every step so far has been triggered manually. Triggers let you schedule the named agent to run automatically on a cron expression. The agent fires at the scheduled time, runs the full digest workflow, and the environment persists between executions so packages installed on the first run are available on every subsequent run.

Manual:     python run_digest.py     → runs once, now
Trigger:    client.triggers.create() → runs every morning, automatically

What to fill in

Open create_trigger.py. It has one TODO.

TODO 1: fill in the triggers.create() call. The trigger runs the full workflow each day: generate the digest AND send it to your inbox. Because access tokens expire in one hour, it injects the refresh token from .gmail_token.json as an inline source so the agent can exchange it for a fresh token on each run.

trigger = client.triggers.create(
    schedule="0 9 * * *",
    time_zone="UTC",
    display_name="daily-tech-digest",
    max_consecutive_failures=3,
    execution_timeout_seconds=600,
    interaction={
        "agent": "my-digest",
        "input": (
            f"Generate the daily tech digest following AGENTS.md instructions. "
            f"Then send an email to {recipient}:\n"
            "- Subject: Tech Digest - <today's date in YYYY-MM-DD format>\n"
            "- Body: the content of /workspace/summaries.json formatted as a readable "
            "newsletter (NOT raw JSON).\n"
            "- Attachment: /workspace/digest.pdf\n\n"
            "For Gmail auth: read /workspace/.gmail_creds.json, POST to "
            "https://oauth2.googleapis.com/token with grant_type=refresh_token "
            "and the client_id, client_secret, refresh_token from the file to get an "
            "access_token. Then POST to "
            "https://gmail.googleapis.com/gmail/v1/users/me/messages/send "
            "with Authorization: Bearer <access_token>."
        ),
        "environment": {
            "type": "remote",
            "sources": [
                {
                    "type": "inline",
                    "target": "/workspace/.gmail_creds.json",
                    "content": gmail_creds,
                }
            ],
        },
    },
)

execution_timeout_seconds=600 is the default timeout. max_consecutive_failures=3 pauses the trigger automatically after 3 failed runs in a row (the API default is 5; 3 is more conservative for a workshop).

The sources list injects .gmail_creds.json into the sandbox at /workspace/.gmail_creds.json. The agent reads it, exchanges the refresh token for a fresh access token, and calls the Gmail API. Refresh tokens do not expire, so this works on every scheduled run without any manual token refresh.

Remove the raise NotImplementedError line after adding the call.

Verify

uv run python create_trigger.py
Trigger created: trig_abc123
Next run:        2026-07-23T09:00:00Z

create_trigger.py saves the trigger ID to .env automatically.

To check execution history after a run:

uv run python check_trigger.py

To fire the trigger immediately without waiting for the next scheduled time:

uv run python fire_trigger.py

To pause or delete the trigger:

uv run python pause_trigger.py

12. Clean Up

The sandbox auto-expires after 7 days of inactivity. No servers to stop. No containers to delete.

If you saved an agent config, delete it:

uv run python delete_agent.py

13. Summary

You built a managed agent from scratch, one concept at a time. Here is what each exercise taught:

Exercise

Concept

Key API

Make your first call

Provision a real Linux sandbox and stream its events live

interactions.create(agent, input, environment, stream=True), event.event_type

Customize the agent

Mount config files; persist IDs to .env in the same run

environment.sources, save_env

Add a safety hook

Intercept tool calls before they execute; deny destructive commands

hooks.json, pre_tool_execution, gate.py

Download the PDF

Download the PDF without re-running the agent

Gemini Files API :download in download_pdf.py

Continue the conversation

Continue the conversation without re-fetching the web

environment=environment_id, previous_interaction_id=interaction_id

Persist agent config

Persist agent config; invoke by ID, no sources needed

agents.create(), agents.list()

Send via Gmail

Obtain an OAuth token locally; pass it to the agent, which calls the Gmail REST API via code_execution

OAuth 2.0, client.interactions.create(input=...)

Schedule daily runs

Run the agent automatically on a cron schedule

client.triggers.create(schedule, time_zone, interaction)

Key patterns

  1. One call, one sandbox: interactions.create() handles all infrastructure (no containers to deploy, no packages to install locally)
  2. Progressive streaming: stream=True turns a 90-second black box into a live feed of tool calls and text chunks
  3. Inline sources: mount AGENTS.md, SKILL.md, and pre-built scripts into the sandbox without any upload or deployment step
  4. Harness auto-discovery: files placed in .agents/ are picked up automatically (no SDK config required)
  5. Two-dimensional state: environment_id tracks files and packages; previous_interaction_id tracks conversation context; either can be passed independently
  6. Snapshot download: the environment is a full filesystem tar, accessible via the Gemini Files API
  7. Named agents: agents.create() bakes config permanently; future calls pass only the agent ID and environment="remote", with no sources
  8. Hooks: hooks.json + a gate script intercept tool calls before they execute; a deny response cancels the call and the agent self-corrects
  9. External API calls: pass a credential in the input prompt; the agent writes and runs the integration code inside the sandbox via code_execution
  10. Triggers: schedule an agent on a cron expression with client.triggers.create(); the environment persists across executions

ADK + Cloud Run vs. Managed Agents: the difference at a glance

Capability

ADK + Cloud Run

Managed agents on the Gemini API

Provision a sandbox

docker build + gcloud run deploy

interactions.create()

Define tools

Python functions registered with the agent

Built in: web browse, code execution, file system

Install packages

pip install in Dockerfile

Agent runs pip install inside sandbox

Stream events

Custom SSE infrastructure

stream=True

Continue a session

Session database + context injection

environment_id + previous_interaction_id

Config files

Hard-coded in agent or injected at startup

Mounted via environment.sources

Infrastructure to manage

Container, Cloud Run, IAM, secrets

None

Next steps