1. Introduction
What You'll Build
In this codelab, you'll step into the shoes of a developer building Fashion App, a Flutter shopping app for a fictional retail brand. Your mission: add two AI-powered features that transform the online shopping experience.
- Virtual Fitting Room — A user uploads a photo of themselves, selects a clothing item, and sees an AI-generated image of themselves wearing that item.
- AI Stylist — Based on the user's location, occasion, and style preferences, an AI agent curates complete outfit recommendations — and the user can refine them through conversation.
The idea is simple: when people try clothes on in a fitting room, they're far more likely to buy them. But online? You're just guessing. This project bridges that gap with AI.
Architecture at a Glance
Flutter App ──── HTTP/REST ────▶ ADK Go Backend
│
┌──────────┼──────────┐
Fitting Room Stylist Catalog
Agent Agent Agent
│
Gemini API + Cloud Storage
Core Technologies
Component | Technology | Purpose |
Agent Framework | ADK (Agent Development Kit) for Go | Multi-agent orchestration, sessions, artifacts |
Agent Reasoning (Pro) | Gemini 3.1 Pro Preview | Powers the fitting room and stylist agents |
Agent Reasoning (Flash) | Gemini 3 Flash Preview | Powers the root and catalog agents (lightweight routing/lookup) |
Image Generation | Gemini 2.5 Flash Image | Generates try-on and outfit images |
Frontend | Flutter (Dart) | Cross-platform app (Web, iOS, Android) |
Storage | Google Cloud Storage | Stores product images and generated artifacts |
Hosting | Cloud Run | Serverless container deployment |
2. 📦 Prerequisites & Cloud Shell Setup
1. Open Cloud Shell Editor
👉 Open Cloud Shell Editor in your browser.
If the terminal doesn't appear at the bottom of the screen:
- Click View → Terminal
2. Set Up Flutter SDK
Cloud Shell ships with Flutter pre-installed at /google/flutter. Because that directory is owned by a different system user, you'll hit a fatal: detected dubious ownership error the first time you run flutter. Add it to git's safe-directory list once:
git config --global --add safe.directory /google/flutter
Verify Flutter is on your PATH and working:
flutter --version
The first run downloads the Dart SDK and builds the Flutter tool — give it a minute. You should see something like Flutter 3.x • channel stable.
3. Clone the Repository
cd ~
git clone https://github.com/gca-americas/fashion-app-demo
cd fashion_app_demo
4. Explore the Project Structure
fashion_app_demo/
├── adk_backend/ # Go backend with ADK agents
│ ├── main.go # Entry point — wires all agents + REST server
│ ├── catalog/ # Catalog Agent — product lookup
│ │ ├── agent.go
│ │ ├── catalog.yaml # Product database (YAML)
│ │ └── instructions.md # Agent persona prompt
│ ├── fittingroom/ # Fitting Room Agent — virtual try-on
│ │ ├── agent.go
│ │ ├── instructions.md # Agent persona prompt
│ │ └── tool_instructions.md # Image generation prompt
│ ├── stylist/ # Stylist Agent — outfit curation
│ │ ├── agent.go
│ │ └── instructions.md # Agent persona + output format
│ ├── rootagent/ # Root Agent — routes to the right agent
│ │ └── agent.go
│ └── tools/ # Shared tools
│ ├── imagetool.go # getProductImage — loads product images
│ ├── outfit_gen_tool.go # generate_outfit_image — creates outfit images
│ └── cors_helper.go # CORS middleware + request logging
│
├── flutter_frontend/ # Flutter cross-platform app
│ ├── lib/
│ │ ├── main.dart # App entry point + Provider setup
│ │ ├── app_config.dart # Backend URL configuration
│ │ ├── core_app/ # Pre-built shopping app (browse, cart, etc.)
│ │ └── workshop_tasks/ # AI feature code
│ │ ├── step_1_try_it_on/ # Virtual Try-On flow
│ │ │ ├── providers/ # TryItOnProvider (state management)
│ │ │ ├── services/ # AdkFittingRoomService (HTTP calls)
│ │ │ └── ui/ # Screens (product detail → try on → fitting room)
│ │ └── step_2_style_me/ # Style Me flow
│ │ ├── models/ # Outfit, StyleRequest data classes
│ │ ├── providers/ # StylingProvider (state management)
│ │ ├── services/ # AdkStylingService (HTTP calls)
│ │ └── ui/ # Screens (form sheet → outfit carousel)
│ └── assets/images/ # Product catalog images
3. ☁️ Google Cloud Project Setup
1. Create a New Project
gcloud projects create fashion-app-demo --name="Fashion App Demo"
gcloud config set project fashion-app-demo
2. Link a Billing Account
List your billing accounts:
gcloud billing accounts list
Look at the
OPEN
column. It must say True. If it says False (common with an expired free trial), the account is closed and won't actually pay for anything — skip ahead to the troubleshooting block below before continuing.
Copy the ACCOUNT_ID of an OPEN: True account (looks like 0X0X0X-0X0X0X-0X0X0X) and link it to your project:
gcloud billing projects link fashion-app-demo \
--billing-account=YOUR_BILLING_ACCOUNT_ID
Verify the link:
gcloud billing projects describe fashion-app-demo
You should see billingEnabled: true. If you see billingEnabled: false even after linking, the account is closed (OPEN: False) — see the troubleshooting block below.
3. Enable Required APIs
gcloud services enable \
aiplatform.googleapis.com \
storage.googleapis.com \
run.googleapis.com \
cloudbuild.googleapis.com \
artifactregistry.googleapis.com
API | Purpose |
| Vertex AI — the |
| Cloud Storage — stores product catalog images and generated try-on results |
| Cloud Run — hosts the backend as a serverless container |
| Cloud Build — builds Docker images from source |
| Artifact Registry — stores built Docker images |
4. Create a GCS Bucket
export PROJECT_ID=$(gcloud config get-value project)
gcloud storage buckets create gs://fashion-app-$PROJECT_ID \
--location=us-central1 \
--uniform-bucket-level-access
5. Upload Product Catalog Images
The backend's getProductImage tool reads from gs://$GCS_BUCKET/catalog-assets/images/. Upload the catalog images to that exact path:
cd ~/fashion_app_demo
gcloud storage cp flutter_frontend/assets/images/*.png \
gs://fashion-app-$PROJECT_ID/catalog-assets/images/
Verify the upload (you should see a list of .png files):
gcloud storage ls gs://fashion-app-$PROJECT_ID/catalog-assets/images/
6. Configure the .env File
cd ~/fashion_app_demo/adk_backend
cat > .env << EOF
GOOGLE_CLOUD_PROJECT=$PROJECT_ID
GCS_BUCKET=fashion-app-$PROJECT_ID
EOF
7. Authenticate with Application Default Credentials
You must run this before starting the backend locally. The Go backend uses ADC to authenticate every call to Vertex AI (Gemini) and Cloud Storage. Without ADC, the backend will start up but every try-on request will fail with a 401 CREDENTIALS_MISSING.
One credential covers both services. Run these two commands in order:
# 1. Log in (opens a browser; in Cloud Shell, paste the verification code back)
gcloud auth application-default login
# 2. Attach your project as the quota / billing project for ADC
gcloud auth application-default set-quota-project $(gcloud config get-value project)
Verify ADC is healthy:
gcloud auth application-default print-access-token | head -c 20 && echo "..."
You should see ~20 characters of a token followed by .... If it errors, the login didn't take — re-run step 1.
4. 🏗️ Architecture Overview
Now that the environment is ready, let's understand how the system works before looking at the code.
The Four-Agent System
The backend is built as a multi-agent system using ADK (Agent Development Kit) for Go. Four agents work together, each with a specific responsibility:
┌──────────────┐
│ Root Agent │ ← Routes requests to the right agent
│ (gemini-3- │
│ flash- │
│ preview) │
└──────┬───────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────────┐ ┌──────────┐ ┌────────────────┐
│ Fitting Room │ │ Catalog │ │ Stylist │
│ Agent │ │ Agent │ │ Agent │
│ (gemini-3.1- │ │ (gemini- │ │ (gemini-3.1- │
│ pro-preview) │ │ 3-flash-│ │ pro-preview) │
│ │ │ preview)│ │ │
│ Tools: │ │ │ │ Tools: │
│ • fitting_tool │ │ Tools: │ │ • fitting_tool │
│ • getProduct │ │ • list │ │ • getProduct │
│ Image │ │ Products │ │ Image │
│ • catalog_agent│ │ • get │ │ • catalog_agent│
│ (delegation) │ │ Product │ │ (delegation) │
│ │ │ Image │ │ • generate_ │
└────────────────┘ └──────────┘ │ outfit_image │
└────────────────┘
Agent | Model | Role |
Root Agent |
| Traffic cop. Reads the user's message and delegates to the right specialist agent. Uses a fast, lightweight model because it only needs to make routing decisions. |
Catalog Agent |
| Product expert. Loads the product catalog from a YAML file and answers product queries. Also lightweight — it's just looking up data. |
Fitting Room Agent |
| Virtual try-on specialist. Takes a user photo + product image and generates a composite image of the person wearing that item. Uses a more capable model because it needs to reason about images. |
Stylist Agent |
| Fashion advisor. Given location, occasion, and preferences, it curates 3 outfit combinations from the catalog. Can generate try-on images for each outfit. Also uses the capable model for creative reasoning. |
The Entry Point: main.go
Everything starts in main.go, which wires the agents together and starts the HTTP server:
// main.go — simplified for clarity
func main() {
godotenv.Load() // Load .env file
// 1. Create the artifact storage (GCS-backed)
artifacts, _ := gcsartifact.NewService(ctx, bucket)
// 2. Build agents bottom-up (dependencies first)
catagent, _ := catalog.NewCatalogAgent(apikey, "catalog/catalog.yaml")
fitagent, _ := fittingroom.NewFittingRoomAgent(apikey, catagent)
stylistAgent, _ := stylist.NewStylistAgent(apikey, catagent)
ragent, _ := rootagent.NewRootAgent(apikey, fitagent, catagent, stylistAgent)
// 3. Register all agents with a multi-loader
loader, _ := agent.NewMultiLoader(ragent, fitagent, catagent, stylistAgent)
// 4. Create the ADK REST server
restHandler, _ := adkrest.NewServer(adkrest.ServerConfig{
SessionService: session.InMemoryService(),
MemoryService: memory.InMemoryService(),
AgentLoader: loader,
ArtifactService: artifacts,
})
// 5. Mount behind /api/ with CORS support
r := mux.NewRouter()
r.Use(tools.LocalhostCORS)
r.PathPrefix("/api/").Handler(
http.StripPrefix("/api", tools.LogHandler(restHandler)))
http.Server{Addr: ":8080", Handler: r}.ListenAndServe()
}
A few important things to notice:
- Agents are built bottom-up: The catalog agent is created first because both the fitting room and stylist agents depend on it (they delegate product lookups to it).
agent.NewMultiLoaderregisters all four agents so the REST API can route to any of them by name.adkrest.NewServerprovides the REST API automatically — you don't write endpoint handlers yourself. ADK gives you session management, artifact storage, and agent execution out of the box.session.InMemoryService()stores sessions in memory. This means sessions are lost if the server restarts, which is fine for a demo. In production, you'd use a persistent store.gcsartifact.NewServicestores artifacts (generated images) in Google Cloud Storage, so they persist across requests and can be shared via GCS URIs.
5. 🤖 ADK (Agent Development Kit) Deep Dive
What is ADK?
The Agent Development Kit (ADK) is an open-source framework from Google for building AI agents in Go (and Python/Java). It's the layer between your application and the Gemini API.
You could call the Gemini API directly. But once your app needs to:
- Look up products from a catalog
- Generate images based on user photos
- Remember what outfits were previously suggested
- Coordinate multiple AI agents
You need structure. ADK provides that structure.
The Agent Loop
Every ADK agent follows a loop:
1. Receive a message (from user or another agent)
2. Think — the LLM reasons about what to do
3. Act — call a tool, delegate to a sub-agent, or respond
4. Return — send the result back
This loop can repeat multiple times within a single request. For example, the stylist agent might:
- Receive "Style me for a beach vacation"
- Call
catalog_agenttool to get the product list - Select 3 outfit combinations
- Call
fitting_toolfor each outfit to generate images - Return the structured JSON response
Core Concepts (With Code From This Repo)
LLM Agents
The primary building block. Created with llmagent.New():
// From catalog/agent.go
agent, err := llmagent.New(llmagent.Config{
Name: "catalog_agent", // Unique identifier
Model: m, // Which LLM to use
Description: "An agent that can search and list products from the catalog",
Instruction: instructions, // Persona prompt (embedded from .md file)
Tools: []tool.Tool{listTool, imageTool}, // What the agent can do
})
The Instruction field is the agent's persona — it tells the LLM who it is and how to behave. In this repo, instructions are written as markdown files and embedded at compile time using Go's //go:embed directive:
//go:embed instructions.md
var instructions string
This keeps prompts as separate, versionable documents rather than inline strings.
Tools
Tools are Go functions that the LLM can call. ADK handles the translation between the LLM's tool-calling format and your typed Go function:
// From catalog/agent.go
type ListProductsArgs struct{} // Input (can be empty)
type ListProductsResult struct {
Products []Product `json:"products"` // Output
}
func ListProducts(ctx tool.Context, args ListProductsArgs) (ListProductsResult, error) {
return ListProductsResult{Products: catalogProducts}, nil
}
// Register it:
listTool, _ := functiontool.New(functiontool.Config{
Name: "listProducts",
Description: "list all products in the catalog",
}, ListProducts)
ADK automatically generates a JSON schema from your Go structs and sends it to the LLM. When the LLM decides to call listProducts, ADK deserializes the arguments, calls your function, and sends the result back.
The tool.Context parameter gives tools access to ADK's runtime services — most importantly artifacts:
// Save an image as an artifact
ctx.Artifacts().Save(ctx, "my_image", imagePart)
// Load an artifact
resp, _ := ctx.Artifacts().Load(ctx, "my_image")
Sub-Agent Delegation
An agent can use another agent as a tool via agenttool.New():
// From fittingroom/agent.go
Tools: []tool.Tool{
loadartifactstool.New(), // List available artifacts
imgtool, // Get product images
agenttool.New(catalogAgent, nil), // Delegate to catalog agent
fittingTool, // Generate try-on image
},
When the fitting room agent needs product information, it can call the catalog agent as if it were a regular tool. The LLM sees it in the tool list and can decide to invoke it.
Sessions
Sessions track conversation history. ADK's REST API manages them automatically:
POST /api/apps/{appName}/users/{userId}/sessions → Creates a new session
POST /api/run (with sessionId) → Runs agent within that session
A critical design decision in this app: the fitting room creates a new session per request (each try-on is independent), while the stylist reuses the same session (so it remembers previous suggestions and can refine based on feedback).
State
State is a key-value store attached to a session. Agents read and write state to coordinate:
// Write to state
ctx.State().Set("previously_used_products", "[\"id_bomber\",\"id_hat\"]")
// Read from state
val, err := ctx.State().Get("previously_used_products")
The stylist agent uses state to remember which products it already suggested, so it picks different ones next time.
Artifacts
Artifacts are named binary objects (usually images) stored per-session. Unlike text responses, they're stored separately and fetched by name:
// Save a generated image as an artifact
artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
ctx.Artifacts().Save(ctx, artName, imagePart)
// The frontend fetches it via:
// GET /api/apps/{app}/users/{user}/sessions/{session}/artifacts/{artName}
This keeps responses lightweight — the agent returns just the artifact name, and the frontend fetches the binary image data separately.
Callbacks
Callbacks are hooks that run at specific points in the agent loop. They can inspect, modify, or short-circuit the execution:
llmagent.Config{
// Runs before the agent starts — used to save uploaded images
BeforeAgentCallbacks: []agent.BeforeAgentCallback{SaveIncomingBlobs},
// Runs before each LLM call — used to inject context
BeforeModelCallbacks: []llmagent.BeforeModelCallback{
ExtractAndInjectUserImage,
InjectPreviousProducts,
},
// Runs after each LLM response — used to extract data
AfterModelCallbacks: []llmagent.AfterModelCallback{SaveSelectedProducts},
}
If a callback returns a non-nil response, the default behavior is skipped. For example, a BeforeModelCallback that returns a cached response would skip the actual LLM call entirely.
JSON Schema Enforcement
Both the fitting room and stylist agents force the LLM to respond in structured JSON:
GenerateContentConfig: &genai.GenerateContentConfig{
ResponseMIMEType: "application/json",
ResponseJsonSchema: fittingSchemaMap(), // Defines the expected structure
}
This ensures the Flutter frontend always receives parseable data, not freeform text.
The Catalog Agent: Simplest Example
The catalog agent (catalog/agent.go) is the simplest agent in the system — a good starting point for understanding ADK patterns.
It has two tools:
listProducts— Returns the full product catalog from a YAML filegetProductImage— Loads a product image from GCS (or local fallback) and saves it as an artifact
The getProductImage tool shows an important pattern — multi-source loading with artifact caching:
// From tools/imagetool.go — simplified
func GetProductImage(ctx tool.Context, args GetProductImageArgs) (GetProductImageResult, error) {
// First: check if it's already an artifact
_, err := ctx.Artifacts().Load(ctx, args.ImageName)
if err == nil {
return GetProductImageResult{Image: args.ImageName}, nil // Cache hit!
}
// Second: try loading from GCS bucket
rc, err := client.Bucket(bucket).Object("catalog-assets/images/" + args.ImageName).NewReader(ctx)
if err != nil {
// Third: fall back to local filesystem
localData, _ := os.ReadFile("../flutter_frontend/assets/images/" + args.ImageName)
ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
return GetProductImageResult{Image: args.ImageName}, nil
}
// Save to artifact cache and return
ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
return GetProductImageResult{Image: args.ImageName}, nil
}
The tool tries artifacts first, then GCS, then local files. Once loaded, the image is cached as an artifact so subsequent calls are instant.
6. 🧪 The AI Pipeline: Agents in Action
Now let's walk through the two most sophisticated agents — the ones that actually generate images and curate outfits.
6.1 The Fitting Room Agent
File:
adk_backend/fittingroom/agent.go
The fitting room agent is the engine behind "Virtual Try-On." When a user uploads their photo and picks a product, this agent generates a composite image of the person wearing that item.
The fitting_tool — Step by Step
The core logic lives in the doFitting function. Here's what happens when the agent calls it:
Step 1: Resolve the user image
func doFitting(ctx tool.Context, args FittingToolArgs) (FittingToolResult, error) {
if len(args.Accessories) > 2 {
args.Accessories = args.Accessories[:2] // Safety limit: max 2 items
}
var userPart *genai.Part
if strings.HasPrefix(args.UserImage, "gs://") {
// If we have a GCS URI from a previous fitting, use it directly
userPart = &genai.Part{FileData: &genai.FileData{
FileURI: args.UserImage,
MIMEType: gcsURIMimeType(args.UserImage),
}}
} else {
// Otherwise, load the image from artifact storage
userImgResp, err := ctx.Artifacts().Load(ctx, args.UserImage)
userPart = userImgResp.Part
}
The user image can come from two sources:
- An artifact name (like
upload_abc123_1) — this is the initial upload, saved by theSaveIncomingBlobscallback - A
gs://URI — this is a previously generated fitting result, stored in GCS for cross-session reuse
This dual-path design is intentional: when the stylist agent later generates outfit try-ons, it reuses the GCS URL from the initial fitting room result so the user's identity stays consistent across all outfits.
Step 2: Build the multimodal prompt
parts := []*genai.Part{
genai.NewPartFromText(toolInstructions), // Identity preservation prompt
genai.NewPartFromText("Reference Person Photo:"),
userPart, // The user's photo
}
for _, acc := range args.Accessories {
accResp, _ := ctx.Artifacts().Load(ctx, acc) // Load product image artifact
parts = append(parts, genai.NewPartFromText("Product Image to Apply:"))
parts = append(parts, accResp.Part) // The product photo
}
The toolInstructions (embedded from tool_instructions.md) is crucial — it tells Gemini to preserve the user's identity (face, body type, skin tone, hair) while applying only the clothing item. Without this prompt engineering, the model might change the person's appearance.
Step 3: Call Gemini for image generation
client, _ := genai.NewClient(ctx, &genai.ClientConfig{
Backend: genai.BackendVertexAI, // Vertex AI endpoint
Project: os.Getenv("GOOGLE_CLOUD_PROJECT"), // From your .env
Location: "global", // Multi-region endpoint
})
resp, _ := client.Models.GenerateContent(ctx, "gemini-2.5-flash-image",
[]*genai.Content{genai.NewContentFromParts(parts, "user")},
&genai.GenerateContentConfig{
ResponseModalities: []string{"TEXT", "IMAGE"}, // Request both text and image output
Temperature: genai.Ptr(float32(0.2)), // Low temperature for consistency
})
All four agents and the image-gen tool share a single authentication path: Backend: genai.BackendVertexAI with the project ID, authenticated via Application Default Credentials. The orchestration models (gemini-3.1-pro-preview, gemini-3-flash-preview) and the image model (gemini-2.5-flash-image) all sit behind the same Vertex AI endpoint, and the same ADC also authorizes Cloud Storage access — one credential, every call.
Step 4: Save the result
// Find the image in the response (may contain both text + image parts)
var genPart *genai.Part
for _, p := range resp.Candidates[0].Content.Parts {
if p.InlineData != nil {
genPart = p
break
}
}
// Save as an ADK artifact
artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
ctx.Artifacts().Save(ctx, artName, genPart)
// Also upload to GCS for cross-session reuse
objectName := fmt.Sprintf("generated-fittings/%s.jpg", ctx.InvocationID())
w := storageClient.Bucket(bucket).Object(objectName).NewWriter(ctx)
w.Write(genPart.InlineData.Data)
gcsURI := fmt.Sprintf("gs://%s/%s", bucket, objectName)
return FittingToolResult{ArtifactName: artName, GCSUrl: gcsURI}, nil
The dual-save (artifact + GCS) is the key to the agent handoff between fitting room and stylist. The artifact provides immediate access within the current session, while the GCS URI allows the stylist (which runs in a different session) to reference the same image later.
The SaveIncomingBlobs Callback
Before the agent even starts reasoning, this BeforeAgentCallback runs to save any images the user uploaded:
func SaveIncomingBlobs(ctx agent.CallbackContext) (*genai.Content, error) {
for pindex, p := range ctx.UserContent().Parts {
if p.InlineData != nil {
aname := fmt.Sprintf("upload_%s_%d", ctx.InvocationID(), pindex)
ctx.Artifacts().Save(ctx, aname, p)
}
}
return nil, nil // Return nil to proceed with normal agent execution
}
By returning (nil, nil), the callback signals "I'm done preprocessing — now run the agent as normal." If it returned non-nil content, it would short-circuit the agent entirely.
6.2 The Stylist Agent
File:
adk_backend/stylist/agent.go
The stylist agent is the most sophisticated in the system. It curates personalized outfit recommendations and supports iterative refinement through conversation.
Three Callbacks — The Stylist's Memory
The stylist uses three callbacks to maintain context across multi-turn conversations:
Callback 1:
InjectPreviousProducts (BeforeModel)
The problem: If the user says "show me different options," the LLM might suggest the same products again because it doesn't inherently track what it already recommended.
The solution: After each response, product IDs are saved to session state. Before the next LLM call, this callback reads them and injects a hint:
func InjectPreviousProducts(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
prev, err := ctx.State().Get(stateKeyPreviousProducts) // Read from session state
if err != nil {
return nil, nil // No previous state — first run
}
// Append hint to the user's message
for i := len(req.Contents) - 1; i >= 0; i-- {
if req.Contents[i].Role == "user" {
req.Contents[i].Parts = append(req.Contents[i].Parts,
genai.NewPartFromText(fmt.Sprintf(
"IMPORTANT: You previously suggested these products: %s. "+
"You MUST pick DIFFERENT complementary products this time.", prev)))
break
}
}
return nil, nil // Continue to LLM call
}
Callback 2:
ExtractAndInjectUserImage (BeforeModel)
The problem: When the user provides feedback ("make it more casual"), the follow-up message doesn't include the user's photo again. But the fitting tool needs it.
The solution: On the first request, this callback extracts the user image reference and saves it to state. On subsequent requests, it re-injects it:
func ExtractAndInjectUserImage(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
var foundImgStr string
// Search for user image in the latest message
for i := len(req.Contents) - 1; i >= 0; i-- {
if req.Contents[i].Role == "user" {
for _, part := range req.Contents[i].Parts {
if strings.Contains(part.Text, "User try-on base image") {
foundImgStr = part.Text // Found the GCS URI reference
}
}
break
}
}
if foundImgStr != "" {
ctx.State().Set(stateKeyUserImageStr, foundImgStr) // Save for later
} else {
// Not in current message — retrieve from state and inject
val, _ := ctx.State().Get(stateKeyUserImageStr)
if savedImgStr, ok := val.(string); ok {
// Inject into the latest user message
req.Contents[last].Parts = append(req.Contents[last].Parts,
genai.NewPartFromText("REMINDER: Use this image: " + savedImgStr))
}
}
return nil, nil
}
Callback 3:
SaveSelectedProducts (AfterModel)
After the LLM responds with outfit suggestions, this callback parses the JSON to extract product IDs and saves them for the InjectPreviousProducts callback to use next time:
func SaveSelectedProducts(ctx agent.CallbackContext, resp *model.LLMResponse, respErr error) (*model.LLMResponse, error) {
for _, part := range resp.Content.Parts {
ids := extractProductIDs(part.Text) // Parse JSON → extract product IDs
if len(ids) > 0 {
data, _ := json.Marshal(ids)
ctx.State().Set(stateKeyPreviousProducts, string(data)) // Save to state
}
}
return nil, nil // Don't modify the response
}
Together, these three callbacks create a feedback loop:
Request 1: User sends styling request + user image
→ ExtractAndInjectUserImage SAVES image to state
→ LLM generates 3 outfits
→ SaveSelectedProducts SAVES product IDs to state
Request 2: User says "make it more casual"
→ ExtractAndInjectUserImage INJECTS saved image into prompt
→ InjectPreviousProducts INJECTS "don't reuse these IDs"
→ LLM generates 3 NEW outfits
→ SaveSelectedProducts UPDATES product IDs in state
6.3 The Root Agent
File:
adk_backend/rootagent/agent.go
The simplest agent — just 31 lines:
func NewRootAgent(project string, fittingAgent, catalogAgent, stylistAgent agent.Agent) (agent.Agent, error) {
m, _ := gemini.NewModel(ctx, "gemini-3-flash-preview", &genai.ClientConfig{
Backend: genai.BackendVertexAI,
Project: project,
Location: "global",
})
return llmagent.New(llmagent.Config{
Name: "root_agent",
Model: m,
Description: "A root agent that delegates to other agents",
Instruction: "You are a helpful shopping assistant. If the user asks about fitting " +
"items or generating images, delegate to the fitting room agent. If the user " +
"asks about products, delegate to the catalog agent. If the user asks for " +
"styling advice, delegate to the stylist agent.",
SubAgents: []agent.Agent{fittingAgent, catalogAgent, stylistAgent},
})
}
It uses gemini-3-flash-preview (the fastest model) because routing decisions are simple — the LLM just needs to read the user's intent and pick the right sub-agent. No tools needed; SubAgents handles delegation automatically.
7. 📱 Flutter Frontend Architecture
The Flutter frontend is a fully functional retail shopping app. The AI features live in flutter_frontend/lib/workshop_tasks/, separate from the pre-built shopping experience in core_app/.
The MVVM Pattern
The app follows Model-View-ViewModel architecture with the Provider package:
┌──────────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ View (Widget) │◀───│ ViewModel (Provider)│◀───│ Service (HTTP) │
│ │ │ │ │ │
│ • Renders UI │ │ • Holds state │ │ • Makes API calls│
│ • User gestures │───▶│ • Business logic │───▶│ • Parses response│
│ • Listens for │ │ • notifyListeners() │ │ • Returns data │
│ state changes │ │ │ │ │
└──────────────────┘ └────────────────────┘ └──────────────────┘
Each layer has a clear role:
- Model: Data classes like
Product,Outfit,StyleRequest, and enums likeTryOnState - ViewModel (
ChangeNotifier): Holds the current state and broadcasts changes to the UI vianotifyListeners() - View (Widget): Subscribes to the ViewModel with
context.watchand rebuilds when state changes() - Service: Makes HTTP calls to the ADK backend and returns typed data
The Service Layer
Services are defined as abstract interfaces, with ADK-specific implementations:
// Abstract interface — defines WHAT the service does
abstract class TryItOnService {
Future<(Uint8List?, String?)> generateTryOnImage(
Uint8List userImageBytes,
Uint8List productImageBytes,
);
}
// Concrete implementation — defines HOW (via ADK REST API)
class AdkFittingRoomService implements TryItOnService { ... }
This separation means you could swap the ADK backend for Firebase AI, a mock service, or any other implementation without changing the rest of the app.
The 3-Step API Pattern
Both AdkFittingRoomService and AdkStylingService follow the same pattern to talk to the ADK backend:
Step 1: Create a session
Future<String> _createSession() async {
final url = Uri.parse(
'$_baseUrl/apps/${Uri.encodeComponent(_appName)}/users/$_userId/sessions');
final response = await _client.post(url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({}));
final body = jsonDecode(response.body) as Map<String, dynamic>;
return body['id'] as String; // Returns the session ID
}
Step 2: Run the agent
Future<(String?, String?)> _runAgent({required String sessionId, ...}) async {
final requestBody = jsonEncode({
'appName': _appName,
'userId': _userId,
'sessionId': sessionId,
'newMessage': {
'role': 'user',
'parts': [
{'text': 'Generate a virtual try-on...'},
{'inlineData': {'mimeType': 'image/jpeg', 'data': base64Encode(userImageBytes)}},
{'inlineData': {'mimeType': 'image/png', 'data': base64Encode(productImageBytes)}},
],
},
});
final response = await _client.post(Uri.parse('$_baseUrl/run'), body: requestBody);
// Parse response events for artifact name and GCS URL...
}
Step 3: Fetch the artifact
Future<Uint8List?> _loadArtifact({required String sessionId, required String artifactName}) async {
final url = Uri.parse(
'$_baseUrl/apps/$_appName/users/$_userId/sessions/$sessionId/artifacts/$artifactName');
final response = await _client.get(url);
final part = jsonDecode(response.body) as Map<String, dynamic>;
final data = part['inlineData']['data'] as String;
return base64Decode(data); // Returns raw image bytes
}
A critical design difference: the fitting room service creates a new session for every request (_createSession() is called each time), while the styling service reuses the same session (_sessionId ??= await _createSession()) to enable multi-turn conversation.
State Management: The TryItOnProvider
File:
workshop_tasks/step_1_try_it_on/providers/try_it_on_provider.dart
The TryItOnProvider manages the entire try-on flow. It uses a TryOnState enum as a state machine:
enum TryOnState { initial, imagePicked, generating, success, error }
class TryItOnProvider with ChangeNotifier {
TryOnState _state = TryOnState.initial;
Uint8List? _userImageBytes;
Uint8List? _generatedImage;
String? _errorMessage;
Private state transitions ensure consistency — you never update the state without also clearing stale data and notifying the UI:
void _setGenerating() {
_state = TryOnState.generating;
_errorMessage = null; // Clear any previous error
_wasLastGenerationCached = false;
notifyListeners(); // Tell the UI to rebuild
}
void _setSuccess(Uint8List image, {bool isCached = false}) {
_generatedImage = image;
_errorMessage = null;
_wasLastGenerationCached = isCached;
_state = TryOnState.success;
notifyListeners();
}
The main generation method ties it all together:
Future<String?> generateTryOnImage(String productImagePath) async {
final userImageBytes = _userImageBytes;
if (userImageBytes == null) {
_setError('No image selected.');
return _errorMessage;
}
// Check local cache first
final cachedImage = ImageUtils.getCachedImage(_sessionCache, userImageBytes, productUint8List);
if (cachedImage != null) {
_setSuccess(cachedImage, isCached: true);
return null;
}
_setGenerating(); // Triggers loading state in UI
try {
final (generatedBytes, gcsUrl) = await _aiService.generateTryOnImage(
userImageBytes, productUint8List);
if (generatedBytes != null) {
_fittingGcsUrl = gcsUrl; // Save for the stylist agent later
_setSuccess(generatedBytes);
}
} catch (e) {
_setError(e.toString());
}
return _state == TryOnState.success ? null : _errorMessage;
}
The UI: Screens as a State Router
File:
workshop_tasks/step_1_try_it_on/ui/2_try_it_on_screen.dart
The try-on screen uses Dart 3's pattern matching with AnimatedSwitcher to route between sub-screens based on the provider's state:
class TryItOnScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tryOnProvider = context.watch<TryItOnProvider>();
return ScaffoldWithBackgroundNoise(
appBar: const TryOnAppBar(),
body: AnimatedSwitcher(
duration: AppDurations.medium,
child: switch (tryOnProvider.state) {
TryOnState.initial || TryOnState.error => const ChooseImageScreen(),
TryOnState.imagePicked || TryOnState.generating => LoadingScreen(
userImage: tryOnProvider.userImageBytes),
TryOnState.success => const FittingRoomScreen(),
},
),
);
}
}
context.watch subscribes to the provider. Whenever notifyListeners() is called, this widget rebuilds, and AnimatedSwitcher smoothly transitions between screens. There's no Navigator.push — the screen content changes in-place based on the state enum.
The Agentic Handoff: Fitting Room → Stylist
The most interesting UX pattern is how the app passes context from the fitting room agent to the stylist agent.
In 5_fitting_room.dart, after the try-on image is generated, the "Style Me" button opens a form. When the user submits:
// From 1_style_me_form_sheet.dart
Navigator.pop(context, StyleRequest(
location: _locationController.text.trim(),
occasion: _occasionController.text.trim(),
notes: _notesController.text.trim(),
gcsUserImageUrl: provider.fittingGcsUrl, // GCS URI from fitting result
userImageData: provider.fittingGcsUrl == null
? provider.userImageBytes : null, // Fallback to raw bytes
selectedProductId: provider.selectedProduct?.id, // Product they already tried on
selectedProductTitle: provider.selectedProduct?.title,
));
The StyleRequest bundles everything the stylist needs:
- Location and occasion — text context for styling
- GCS user image URL — so the stylist can reuse the exact same user representation
- Selected product — so the stylist includes it in every outfit
This is the agentic handoff — seamlessly transferring multimodal context from one AI agent to another, with the user only seeing a simple form.
The Styling Flow: StylingProvider
File:
workshop_tasks/step_2_style_me/providers/styling_provider.dart
The StylingProvider is simpler than TryItOnProvider because it delegates most complexity to the backend:
class StylingProvider with ChangeNotifier {
StylingState _state = StylingState.initial;
List<Outfit> _outfits = [];
Future<bool> getStyleSuggestions(StyleRequest request) async {
if (_state == StylingState.loading) return false; // Prevent spam
_currentRequest = request;
_setLoading();
try {
final suggestions = await _stylingService.getStyleSuggestions(request);
_setSuccess(suggestions);
return true;
} catch (e) {
_setError('Something went wrong.');
}
return false;
}
// Multi-turn refinement — uses the same session
Future<bool> refineWithFeedback(String feedback) async {
if (_state == StylingState.loading) return false;
_setLoading();
try {
final suggestions = await _stylingService.refineWithFeedback(feedback);
_setSuccess(suggestions);
return true;
} catch (e) {
_setError('Something went wrong.');
}
return false;
}
}
The refineWithFeedback method sends a plain text message to the same session — the backend's InjectPreviousProducts and ExtractAndInjectUserImage callbacks handle all the context management automatically.
8. 🚀 Run the App Locally
For a smooth Cloud Shell experience, the Go backend serves the compiled Flutter web app from the same port (8080). One process, one preview URL, no cross-origin headaches, no editing of config files.
Before you start — sanity-check ADC
The backend needs Application Default Credentials to call Vertex AI. If you finished step 7 of the project setup in this Cloud Shell session and this Google account, you're good. If you're returning after a break, switched accounts, or aren't sure, take 5 seconds to verify:
gcloud auth application-default print-access-token | head -c 20 && echo "..."
If that prints ~20 characters of a token, you're set. If it errors, re-run step 7 of the project setup:
gcloud auth application-default login
gcloud auth application-default set-quota-project $(gcloud config get-value project)
You'll use two Cloud Shell terminals:
- Terminal A — runs the backend continuously (
./run.sh). Leave it open. - Terminal B — runs the Flutter web build once (
flutter build web). Exits when done.
The order doesn't matter — you can start either first. But for the cleanest first-run experience, build Flutter first so the backend has a UI to serve from the moment it starts.
1. Terminal B — Build the Flutter Web Bundle (one-shot)
Open a new Cloud Shell tab (the + at the top of the terminal panel), then:
cd ~/fashion_app_demo/flutter_frontend
flutter pub get
flutter build web
This produces flutter_frontend/build/web/ — a directory of static files (HTML, JS, assets) — and exits when finished. The backend will serve these as soon as it sees the directory exists.
2. Terminal A — Start the Backend (long-running)
In your original Cloud Shell terminal:
cd ~/fashion_app_demo/adk_backend
./run.sh
You should see something like:
Serving Flutter web build from ../flutter_frontend/build/web
Leave this terminal running — the backend stays up for as long as run.sh is alive. To stop it, hit Ctrl+C.
The server exposes everything on port 8080:
/— Flutter web app (the shopping UI)/api/— ADK REST endpoints (called by the Flutter app)- ADK Dev UI — also at
/when there's no Flutter build; useful for direct agent debugging
3. Open Web Preview
- In Cloud Shell, click the Web Preview icon (top-right) → Preview on port 8080
- The Flutter shopping app loads in a new tab
- Browse the product catalog and select an item
- Tap the person icon (👤) to start the Try-On flow
- Upload a photo and watch the AI generate a try-on image
- Tap "Style Me" to get outfit recommendations
- Type follow-up feedback like "make it more casual" — same-session refinement
9. ☁️ Deploy to Cloud Run
Bundle the Flutter Build into the Backend
The Cloud Run container ships both the API and the UI from one image. Copy the Flutter web build into adk_backend/flutter_web/ — that's the first path the Go server checks when picking which UI to serve:
cd ~/fashion_app_demo/flutter_frontend
flutter build web
rm -rf ../adk_backend/flutter_web
cp -r build/web ../adk_backend/flutter_web
(If you've been iterating locally, you may already have build/web from the Run-Locally step. Re-running flutter build web is still fine.)
Deploy the Backend (Serves API + UI)
cd ~/fashion_app_demo/adk_backend
gcloud run deploy fashion-app-backend \
--source . \
--region us-central1 \
--allow-unauthenticated \
--set-env-vars "GOOGLE_CLOUD_PROJECT=$PROJECT_ID,GCS_BUCKET=fashion-app-$PROJECT_ID" \
--memory 1Gi \
--cpu 2 \
--timeout 300s \
--min-instances 0 \
--max-instances 3
When the deploy finishes, you'll get a Service URL like https://fashion-app-backend-xyz-uc.a.run.app. Open it in a browser — the Flutter shopping app loads from /, and its API calls go to /api/ on the same host. No frontend config edits needed, no API key passed.
Verify the Deployment
Open the Cloud Run URL in your browser and run through the full flow:
- Browse → Select a product
- Try On → Upload your photo → See the AI-generated image
- Style Me → Fill out location/occasion → See curated outfits
- Feedback → Type "make it more casual" → See updated outfits
- Add to Bag → Complete the shopping flow
10. 🎉 Conclusion
What You Built
You've explored a complete AI-powered retail experience with:
- ✅ A multi-agent backend with 4 specialized agents working together
- ✅ A virtual fitting room that generates personalized try-on images
- ✅ An AI stylist that curates outfits and refines them through conversation
- ✅ A cross-platform Flutter app that connects to the agent backend
- ✅ Cloud Run deployment for scalable, serverless hosting
Key Concepts
Concept | Where You Saw It |
ADK multi-agent orchestration | Root agent routing to fitting room, catalog, and stylist agents |
Gemini multimodal image generation |
|
Session state for conversational AI | Stylist reusing sessions for iterative feedback |
Artifact storage for binary data | Separating image storage from text responses |
Callbacks for middleware logic |
|
MVVM + Provider in Flutter |
|
Agentic handoff |
|
Next Steps
- 🎨 Customize agent prompts — edit
instructions.mdto change the stylist's personality - 🛍️ Add more products — update
catalog.yamlwith new items - 📱 Build for mobile — run
flutter build iosorflutter build apk - 🔄 Add persistent sessions — replace
InMemoryServicewith a database-backed implementation - 🔒 Add authentication — secure the Cloud Run endpoint with IAM
Resources
- ADK Documentation — Official Agent Development Kit docs
- ADK Go Source Code — GitHub repository
- ADK Go Package Reference — API reference
- Gemini API Documentation — Model capabilities and guides
- Flutter Provider Package — State management docs
- Cloud Run Documentation — Deployment and scaling guides