Category: AI/ML

  • Which programming languages offer built-in tools for modernizing your code?

    If you’ve been a software developer for more than five minutes, then you probably have some old code running somewhere. It probably works fine, so who cares if it’s a little dusty and based on an n-5 language version? Remember that upgrades are an important means for grabbing security patches and performance improvements. But those syntax and architectural changes are important too.

    I wondered which popular languages provided deterministic tools for upgrading code to the latest version. Let’s see what Go, Java, C#, JavaScript, Python, and Rust have to offer.

    Go offers the built-in go fix tool

    Bias alert: I lead the product and engineering of Go at Google. But I’ve also used Go for years before that org shift happened a few months back.

    Go includes syntax modernization directly inside the standard toolchain. It uses modular static analyzers to inspect packages and automatically rewrite legacy code patterns. Go projects are simple—no random XML or JSON settings, no “project” files—so this can be a focused, efficient tool.

    How do you use it? Update your Go toolchain version in the go.mod file. Run go fix. That’s it. This tool was recently rewritten atop the go/analysis engine to be even more powerful.

    Let’s see an example. Maybe I’ve got a CLI tool written in idiomatic Go 1.18 code. This CLI tool renames image files. The code does safe concurrency and uses a helper for slices.

    package main
    
    import (
    	"flag"
    	"fmt"
    	"io/fs"
    	"log"
    	"os"
    	"path/filepath"
    	"strings"
    	"sync"
    )
    
    // Supported image extensions (Go 1.18 slice)
    var imageExts = []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
    
    // RenameRegistry ensures destination filenames are unique, resolving collisions thread-safely.
    type RenameRegistry struct {
    	mu    sync.Mutex
    	names map[string]int
    }
    
    func NewRenameRegistry() *RenameRegistry {
    	return &RenameRegistry{
    		names: make(map[string]int),
    	}
    }
    
    // GetUniqueName returns a collision-free filename in the destination directory.
    func (r *RenameRegistry) GetUniqueName(base, ext, destDir string) string {
    	r.mu.Lock()
    	defer r.mu.Unlock()
    
    	count := r.names[base]
    	r.names[base] = count + 1
    
    	var targetName string
    	if count == 0 {
    		targetName = base + ext
    	} else {
    		targetName = fmt.Sprintf("%s_%d%s", base, count, ext)
    	}
    
    	// Double check disk existence to avoid overwriting existing files
    	for {
    		targetPath := filepath.Join(destDir, targetName)
    		if _, err := os.Stat(targetPath); os.IsNotExist(err) {
    			break
    		}
    		// If it exists, increment the counter and try again
    		count++
    		r.names[base] = count + 1
    		targetName = fmt.Sprintf("%s_%d%s", base, count, ext)
    	}
    
    	return targetName
    }
    
    // isImageFile checks if a file has a supported image extension (pre-slices inline check)
    func isImageFile(path string) bool {
    	ext := strings.ToLower(filepath.Ext(path))
    	for _, item := range imageExts {
    		if item == ext {
    			return true
    		}
    	}
    	return false
    }
    
    func main() {
    	// Parse CLI flags
    	srcDir := flag.String("src", ".", "Source directory containing photos")
    	destDir := flag.String("dest", "", "Destination directory for renamed photos (defaults to source)")
    	dryRun := flag.Bool("dry", false, "Dry run mode (lists proposed changes without executing)")
    	verbose := flag.Bool("verbose", false, "Enable verbose logging output")
    	flag.Parse()
    
    	// Default destination to source directory if not specified
    	if *destDir == "" {
    		*destDir = *srcDir
    	}
    
    	// Clean paths
    	*srcDir = filepath.Clean(*srcDir)
    	*destDir = filepath.Clean(*destDir)
    
    	log.Printf("Starting photorename CLI...")
    	log.Printf("Source directory: %s", *srcDir)
    	log.Printf("Destination directory: %s", *destDir)
    	if *dryRun {
    		log.Printf("DRY RUN MODE ENABLED - No files will be moved or renamed.")
    	}
    
    	// Scan source directory recursively
    	var photos []string
    	err := filepath.WalkDir(*srcDir, func(path string, d fs.DirEntry, err error) error {
    		if err != nil {
    			return err
    		}
    		if !d.IsDir() && isImageFile(path) {
    			photos = append(photos, path)
    		}
    		return nil
    	})
    
    	if err != nil {
    		log.Fatalf("Error scanning source directory: %v", err)
    	}
    
    	totalPhotos := len(photos)
    	log.Printf("Found %d photo(s) to process.", totalPhotos)
    	if totalPhotos == 0 {
    		return
    	}
    
    	// Ensure destination directory exists (unless dry run)
    	if !*dryRun {
    		if err := os.MkdirAll(*destDir, 0755); err != nil {
    			log.Fatalf("Failed to create destination directory: %v", err)
    		}
    	}
    
    	registry := NewRenameRegistry()
    	var wg sync.WaitGroup
    	// Allocate worker pool limit using custom new helper (pre-Go 1.26 newexpr target)
    	limit := newInt(4)
    	// Limit concurrency using a semaphore (buffered channel)
    	sem := make(chan struct{}, *limit)
    
    	var successCount int
    	var successMu sync.Mutex
    
    	for _, photoPath := range photos {
    		photoPath := photoPath
    		wg.Add(1)
    		sem <- struct{}{}
    
    		go func() {
    			defer wg.Done()
    			defer func() { <-sem }()
    
    			logDebug(*verbose, "[Processing] %s", photoPath)
    
    			// Get file info for modification time
    			info, err := os.Stat(photoPath)
    			if err != nil {
    				log.Printf("Error stating file %s: %v", photoPath, err)
    				return
    			}
    
    			// Format modification time as YYYYMMDD_HHMMSS
    			modTime := info.ModTime()
    			baseName := fmt.Sprintf("IMG_%s", modTime.Format("20060102_150405"))
    			ext := strings.ToLower(filepath.Ext(photoPath))
    
    			// Get unique target name to avoid collisions
    			targetName := registry.GetUniqueName(baseName, ext, *destDir)
    			targetPath := filepath.Join(*destDir, targetName)
    
    			if *dryRun {
    				log.Printf("[Dry-Run] Would rename: %s -> %s", photoPath, targetPath)
    				successMu.Lock()
    				successCount++
    				successMu.Unlock()
    				return
    			}
    
    			// Perform the rename/move operation
    			err = os.Rename(photoPath, targetPath)
    			if err != nil {
    				log.Printf("Rename failed for %s -> %s, attempting copy: %v", photoPath, targetPath, err)
    			} else {
    				logDebug(*verbose, "[Success] Renamed: %s -> %s", photoPath, targetPath)
    				successMu.Lock()
    				successCount++
    				successMu.Unlock()
    			}
    		}()
    	}
    
    	wg.Wait()
    
    	if *dryRun {
    		log.Printf("Dry run complete. Checked %d/%d photos.", successCount, totalPhotos)
    	} else {
    		log.Printf("Renaming complete. Successfully processed %d/%d photos.", successCount, totalPhotos)
    	}
    }
    
    // logDebug helper that prints debug logs using interface{} (pre-Go 1.18 any)
    func logDebug(verbose bool, format string, args ...interface{}) {
    	if verbose {
    		log.Printf(format, args...)
    	}
    }
    
    // newInt is a custom helper to return a pointer to an int value (pre-Go 1.26 newexpr helper target)
    func newInt(x int) *int {
    	return &x
    }
    
    

    On disk, my app contained a folder of randomly named pictures in various folders, as well as a misplaced text file that should be ignored:

    After building and running the tool, it takes milliseconds to complete. And I end up with renamed files in a fresh folder.

    Let’s modernize! I updated my go.mod to now reference Go 1.26.

    go mod edit -go=1.26
    

    Then ran the following command to do a dry run and see what it would change.

    go fix -diff ./...
    

    I get back a +/- view in the console that shows me which modernizers kicked in.

    It won’t find EVERY opportunity to upgrade the code, as it looks for specific patterns. But in this case, it refactored legacy handling of slices, deleted some now-unnecessary code for loop variable shadowing, upgraded an interface parameter to any, and improved a pointer. All in under a second.

    Go has a backwards compatibility promise, so it doesn’t need an aggressive modernizer. Instead, a first-party tool like go fix —which is an Abstract Syntax Tree (AST) that focuses on grammar and structural shape of source code—is meant for continuous codebase modernizations.

    And I’ll note that Dart, the language that powers the popular Flutter framework, also has a pretty great dart fix command that does similar things. Google is pretty good at these things.

    Java has built-in analysis tools, but relies on third party modernization tools

    Modernizing Java code is a lot more than updating your build file! Bumping the version in your maven/gradle file simply tells the compiler what byte code to emit, but your source code doesn’t change.

    The JDK does ship with diagnostic tools to help you identify upgrade issues, but these are read-only. No proactive rewrites. Useful JDK tools include:

    • jdeps: This scans your class files and JARs to report any static dependencies. It helps you get ready for migrations by finding deprecated features, broken third-party tools, and any references to restricted internal JDK APIs.
    • jdeprscan: Another static analysis tool that scans class files, directories, and JAR files for any use of deprecated Java APIs.

    Many Java rewrites start in the IDE, like IntelliJ. There are some built-in analyzers and code fixing tools. You’re constrained to the GUI here, so people doing at-scale rewrites often use OpenRewrite. This build plug-in can upgrade syntax and do framework refactoring.

    Given its established enterprise presence, Java has many documented modernization practices and a strong ecosystem of vendor-created modernization tools. But don’t look deeply into the built-in toolchain for a lot of support.

    C# (.NET) provides a built in upgrade assistant

    For some reason, .NET has had an (unnecessarily?) exciting journey. That means you need some industrial-strength modernization tooling to get you from one major iteration to the next.

    Microsoft doesn’t bake any modernization tools directly into the language toolchain. They introduced the .NET Upgrade Assistant years ago, powered by their Roslyn compiler platform. You could run this sophisticated tool as a Visual Studio plug-in or via a standalone CLI. It scanned your code and then helped you upgrade to the latest version. It covered a lot of ground, because .NET projects are relatively heavy and the framework has seen some major architectural changes over the years.

    However, this tool is now deprecated in favor of the GitHub Copilot modernization chat agent. That’s a very different solution. Now you have commercial implications that impact who can access it. It’s online-only, and internet-dependent. You’re not running this easily in a headless fashion. And now it’s non-deterministic, and not following preset rules. That’s also a “plus” as you’ll probably see deeper modernizations, self-correcting loops, and more flexibility. But some big token costs!

    Like Java, C#/.NET has a massive community and plenty of well-documented practices. The ever-changing tools landscape means those practices aren’t timeless, but it’s not hard to find experts to help you modernize .NET apps. Just don’t look at the framework for a major assist.

    JavaScript doesn’t offer built-in tools for modernization

    You won’t find a built-in modernizer for JavaScript/TypeScript. It’s a fluid, fast-moving, and fragmented ecosystem with different pace layers. You’ve got the language-level updates (ECMAScript) and then the framework-level (React, Next.js, Angular, Vue). Instead of using heavyweight Node.js-native AST-style parsers, developers are embracing faster structural engines and framework CLI orchestrators.

    These developers relied heavily on codemods, like jscodeshift. But that can be heavy and slow on giant repos. There’s a new class of Rust-based codemod engines like ast-grep, jssg, and GritQL.

    You’ve also got framework-first codemod orchestrators like the upgrade CLI baked into Next.js . Angular provides an ng update experience. Tailwind also offers a version upgrade tool.

    JavaScript is probably too decentralized to offer a single, language-included code modernization tool. But the ecosystem has stepped up with useful options.

    Python relies on third party modernizers

    Plenty of devs are still scarred by the Python 2 to Python 3 migration. Python 3 came out in 2008, but Python 2 wasn’t retired until 2020. This wasn’t a simple version bump, but what felt like a whole different programming language. Python 3 did ship with an official AST-based modernization tool called 2to3. But it had issues, and didn’t make migrations easy.

    Today’s Python doesn’t include any built-in source code rewriting tools. For any minor release upgrade, modernizing this code is entirely an ecosystem (third party) story. The built-in tooling basically covers diagnostics and signaling of deprecation. You get runtime warnings for deprecated API use, and compiler warnings for risky or outdated control flows. That’s about it.

    Here’s where the ecosystem stepped up. You have syntax fixers like ruff that work at the base layer of the language. It includes many built-in rules and is used by many OSS projects. Another option is LibCST from Meta that preserves all the whitespace, inline comments, and code formatting which makes it good at complex refactorings. Then, like with JavaScript, there are framework-specific upgraders like django-upgrade for Django.

    Rust offers a built-in cargo fix tool

    Rust, like Go years earlier, added a native code modernizer in their toolchain. However, it’s quite different in how it works, despite doing conceptually-similar things.

    cargo fix uses rustc compiler diagnostics to detect deprecated features or edition-incompatible idioms to drive changes to source files. A foundational part of Rust modernization relies on Editions. Rust uses an “Edition” approach (2015, 2018, 2021, 2024) to introduce potential opt-in breaking syntax changes without breaking the ecosystem. cargo fix was introduced to automate code modifications needed to transition crates (packages) from one Edition to the next.

    I can’t run cargo fix to jump from a 2018 Edition to 2024. I’d execute a step-by-step path to first go to 2021, then to 2024. The tool’s goal is to modify code so that it’s valid in both the current Edition and target one simultaneously.

    Of note, there are more modernization options than just cargo fix. Clippy is the official code linting tool for Rust. It analyzes source code to catch any design mistakes and improve your code. cargo upgrade also upgrades crate dependencies, while cargo outdated shows you a detailed tree of outdated dependencies. Our friend ast-grep also applies to Rust and can help you with codebase-wide refactoring.

    Wrap Up

    Does any of this matter now that we have LLMs? Can I just point Claude Code, Cursor, or Google Antigravity at a legacy code base and ask it to shine it up? Sort of.

    Yes, you can use LLMs for some of this code modernization effort. But solely relying on an LLM? You’ll see a significant token spend, it’ll take magnitudes longer to execute a modernization, and you risk context truncation or degradation across massive files. It’s an “and” where you ideally couple LLMs with deterministic tools like some of the ones we listed here.

    What did I get wrong? Do you have other favorite tools that help you modernize code written in your favorite language?

  • How to share agentic coding artifacts with your teammates

    Maybe it’s just me, but it feels like most of the agentic coding tools out there are single-player. An individual developer engages in a coding session with one or more agents, and the non-code “exhaust” (scratch files, harness-generated session docs) stays on the local machine. Oh sure, the code gets checked into a team-visible repo, and git commit body should list all the key changes. But are teammates losing out on the “thinking” that went on during that individual coding session? Maybe those core details are in a requirements/design doc somewhere, assuming people still write those! But how are teams of developers collaborating nowadays with all this agentic coding going on? Are we sharing those session-level artifacts and creating a team “brain”?

    I wondered if there was an easy way to take my Google Antigravity session artifacts and make them part of my commits. By default, any session/conversation docs—implementation plans, walkthroughs, chat transcripts—live in a machine folder. But I want those dragged into my local project folder so that they’re automatically pulled into commits, and thus visible to teammates. Then, teammates can use their own harness to understand my thinking or how I arrived at a certain decision for my code contribution.

    So I wrote an agent skill. It includes a Python script that explicitly moves the files when the coding session is done. The script does a git add of those files, including the full (and summary) session transcript. Specific to Antigravity, you might also build a sidecar, or something that runs in the background and continuously works. But this was a simpler choice.

    Let’s walk through the key bits. And then I’ll show it in action.

    In a hidden .agents folder, I’ve got an AGENTS.md file, a skills folder that contains a skill named team-sync, and then a SKILL.md along with a Python script used by the skill.

    We’ll start with the SKILL.md. It fires up as the agent finishes artifacts, and when explicitly triggered by the user.

    ---
    name: team-sync
    description: Synchronizes local Antigravity conversation history, transcripts, and artifacts (including a light summary) to the project repository for team sharing.
    ---
    
    # Team-Sync Custom Skill
    
    Use this skill to archive and upload your conversation's transcripts, artifacts, and a "light" human-readable summary into the Git repository, allowing other team members to quickly catch up on your agent steps, decisions, and outcomes.
    
    ## When to Use
    * Call this skill when a coding task, implementation plan, or refactoring conversation has finished successfully.
    * Run it before opening a Pull Request so that reviewers can inspect the execution logs and the conversation summary.
    
    ## Instructions for the Agent
    1. **Generate the Light Transcript (`summary.md`):**
       Create a file named `summary.md` in your local brain directory. The summary must follow this structure:
    
       ```markdown
       # Conversation Summary: [Objective/Topic]
       * **Date:** [Current Date]
       * **Conversation ID:** [ANTIGRAVITY_CONVERSATION_ID]
    
       ## TL;DR
       [A 1-2 sentence high-level summary of what was accomplished]
    
       ## Key Decisions & Rationale
       * **Decision:** [e.g., Using Python's shutil instead of bash commands]
         * **Why:** [e.g., Cross-platform safety and better permission handling]
    
       ## Most Interesting Event / "Aha" Moment
       [Capture any major back-and-forth conversation, user course corrections, pivot points, or model/user "aha" moments that defined the flow of this conversation.]
    
       ## Scope of Changes
       * **Files Modified/Created:** [List of files]
       * **Verification:** [How the changes were validated, command outputs, etc.]
    
       ## Learnings & Gotchas for the Team
       * [Any lessons learned about the API, codebase, or environment that others should know]
       ```
    
    2. **Verify Environment:**
       Ensure `ANTIGRAVITY_CONVERSATION_ID` is set in the environment.
    
    3. **Execute Sync Script:**
       Run the sync helper script:
       ```bash
       python3 .agents/skills/team-sync/scripts/sync.py
       ```
       *Note: Because `sync.py` automatically copies all `.md` files, your newly created `summary.md` will be synced to the workspace repository automatically.*
    
    4. **Report Status:**
       Confirm to the user that the summary and files have been successfully synced.
    
    

    The Python script does the work of actually copying files from Antigravity’s “brain” folder into my local project folder.

    #!/usr/bin/env python3
    import os
    import sys
    import shutil
    import subprocess
    from pathlib import Path
    
    def get_project_root() -> Path:
        # Find project root by looking for .agents or .git starting from CWD
        current = Path.cwd().resolve()
        for parent in [current] + list(current.parents):
            if (parent / ".agents").is_dir() or (parent / ".git").is_dir():
                return parent
        return current
    
    def find_most_recent_conversation(brain_root: Path) -> str:
        """Finds the most recently modified conversation subdirectory in the brain cache."""
        if not brain_root.is_dir():
            return None
        
        subdirs = []
        for p in brain_root.iterdir():
            # Conversation directories are 36-char UUIDs (plus optional custom name directories)
            if p.is_dir() and p.name != "scratch":
                try:
                    mtime = p.stat().st_mtime
                    subdirs.append((mtime, p.name))
                except OSError:
                    continue
                    
        if not subdirs:
            return None
        
        # Sort by modification time, newest first
        subdirs.sort(key=lambda x: x[0], reverse=True)
        return subdirs[0][1]
    
    def check_git_installed() -> bool:
        """Checks if git command-line tool is installed and available in PATH."""
        return shutil.which("git") is not None
    
    def main():
        print("=== Antigravity Team Sync ===")
        
        home_dir = Path.home()
        brain_root = home_dir / ".gemini" / "antigravity" / "brain"
        
        # 1. Retrieve conversation ID from environment or fallback
        conv_id = os.environ.get("ANTIGRAVITY_CONVERSATION_ID")
        if not conv_id:
            print("Notice: ANTIGRAVITY_CONVERSATION_ID env variable is not set.", file=sys.stderr)
            print("Attempting to auto-detect the most recent local conversation...", file=sys.stderr)
            conv_id = find_most_recent_conversation(brain_root)
            
            if not conv_id:
                print("ERROR: Could not locate any local conversation histories.", file=sys.stderr)
                sys.exit(1)
            print(f"Auto-detected conversation: {conv_id}")
        else:
            print(f"Active Conversation ID: {conv_id}")
        
        # 2. Define source paths
        source_brain_dir = brain_root / conv_id
        if not source_brain_dir.is_dir():
            print(f"ERROR: Local conversation directory not found at: {source_brain_dir}", file=sys.stderr)
            sys.exit(1)
            
        # 3. Define target paths
        proj_root = get_project_root()
        target_history_dir = proj_root / ".antigravity" / "history" / conv_id
        
        print(f"Source Directory: {source_brain_dir}")
        print(f"Target Directory: {target_history_dir}")
        
        # Create target directory
        try:
            target_history_dir.mkdir(parents=True, exist_ok=True)
        except PermissionError:
            print(f"ERROR: Permission denied. Cannot write to target directory: {target_history_dir}", file=sys.stderr)
            sys.exit(1)
        except Exception as e:
            print(f"ERROR: Failed to create target directory: {e}", file=sys.stderr)
            sys.exit(1)
        
        # 4. Copy Artifacts (markdown files and media in the main directory)
        copied_count = 0
        for file_path in source_brain_dir.iterdir():
            if file_path.is_file() and file_path.suffix in [".md", ".png", ".jpg", ".jpeg", ".gif", ".mp4", ".mov"]:
                try:
                    shutil.copy2(file_path, target_history_dir / file_path.name)
                    print(f"-> Copied file: {file_path.name}")
                    copied_count += 1
                except PermissionError:
                    print(f"Warning: Permission denied copying file {file_path.name}")
                except Exception as e:
                    print(f"Warning: Failed to copy {file_path.name}: {e}")
                
        # 5. Copy Transcripts
        logs_dir = source_brain_dir / ".system_generated" / "logs"
        transcript_copied = False
        if logs_dir.is_dir():
            for log_file in ["transcript.jsonl", "transcript_full.jsonl"]:
                source_log = logs_dir / log_file
                if source_log.is_file():
                    try:
                        shutil.copy2(source_log, target_history_dir / log_file)
                        print(f"-> Copied log: {log_file}")
                        transcript_copied = True
                    except PermissionError:
                        print(f"Warning: Permission denied copying log {log_file}")
                    except Exception as e:
                        print(f"Warning: Failed to copy log {log_file}: {e}")
                    
        if not transcript_copied:
            print("Warning: No transcript files found in the conversation logs.")
            
        # 6. Git Synchronization
        git_dir = proj_root / ".git"
        if git_dir.is_dir():
            if not check_git_installed():
                print("\nWarning: Git repository detected, but git executable is not available in your PATH. Skipping Git commit.")
                print("Sync completed successfully!")
                return
                
            try:
                print("\nStaging files in Git...")
                subprocess.run(["git", "add", str(target_history_dir)], check=True, cwd=proj_root)
                print("Conversation history and transcripts staged in Git successfully!")
                print("You can now commit these staged history files together with your code updates.")
            except subprocess.CalledProcessError as e:
                print(f"\nWarning: Git command failed with error code {e.returncode}.", file=sys.stderr)
                print("Your files have been successfully copied locally, but could not be staged automatically in Git.", file=sys.stderr)
        else:
            print("\nNote: Not a Git repository (or .git not found). Skipping Git commit.")
            
        print("\nSync completed successfully!")
    
    if __name__ == "__main__":
        main()
    
    

    And finally, my AGENTS.md is quite simple and tells my harness when to mirror the core coding session artifacts.

    # Team Customization Rules
    
    These rules govern how Antigravity agents operate within this project repository to facilitate seamless team collaboration.
    
    ## 1. Artifact Mirroring
    To ensure that all design decisions, test verifications, and walkthroughs are visible to the team during code reviews:
    * **Mirroring Rule:** Whenever you create or modify an artifact (such as `implementation_plan.md`, `task.md`, or `walkthrough.md`) in the local user cache directory (`~/.gemini/antigravity/brain/<conversation-id>`), you MUST copy or write a duplicate version of that file to the workspace under `.antigravity/history/<conversation-id>/`.
    * **Git Commits:** These mirrored documents should be staged and committed to Git alongside the source code changes.
    
    ## 2. Conversation Telemetry & Syncing
    * If the user or agent needs to share the raw execution logs, terminal outputs, and thinking transcripts of a conversation:
      * Run the `team-sync` skill at the end of the session.
      * This will execute the `sync.py` script to collect the finalized `transcript.jsonl` and copy it to the same `.antigravity/history/<conversation-id>/` directory.
    
    

    This .agents folder could be part of some shared Git project or project-bootstrapping script. Here’s an example of how to use it.

    I created a local directory for my new coding project. Maybe I’m the first one on my team working on it. After copying the .agents folder into that directory, and running a git init, I opened Google Antigravity and started a new session/conversation. Notice that my Antigravity settings for this project shows the skill and agent rules loaded up automatically.

    At this point, I just did my work like always. I used Antigravity to build a Dart and Flutter-based web app for my fictitious hotel chain. I built this (and packaged it) over three distinct coding sessions.

    As each session went along, I noticed the session artifacts (like implementation plans and walkthroughs) showing up in the .antigravity folder within my project directory. And I ended each session with a request to run the team-sync skill. That ensured that my final chat transcript(s) showed up too.

    Let’s imagine that I’ve pushed all my changes into a team-shared repo. The next developer(s) can pull down the app code, along with the session history. Maybe they’re curious about how we arrived at our deployment choice. For example:

    Review the summary transcripts in this project and help me understand how the team decided to deploy to Cloud Run instead of Kubernetes.
    
    

    The result? The transcript summary is consulted and the developer sees the results of the conversation and trade-offs factored in.

    These key architectural decisions should be in other stateful artifacts like design docs. But given how fast teams are running now, the “requirements” are sprinkled throughout the code, test plans, session artifacts, and upfront docs.

    Maybe I’m solving a temporary problem and within weeks, all these coding tools will make it super easy to create a shared “brain” for software teams. But for now, I like that agent skills make it easy to extend Antigravity this way. How are you thinking about sharing “thinking” among your developers?

  • Crafting an agent team that still includes me

    That was fast. We’ve moved from prompting an LLM, to providing instructions to an agent, to having agents prompting other agents in a loop. “Loop engineering” is all the rage among the AI elite who are excited to spin up an agent and let it chomp tokens until it achieves a stated goal. You might rightfully wonder where you fit into all of this.

    LLMs and agents basically know everything but your context. There’s a role for you in setting up the full context—instructions, tools, examples, policies, skills, and such—your agent needs. It’s also up to the human to set a goal for the agent to loop on. And unless you completely trust the quality of the output, we have a role in reviewing (and owning) the result.

    Earlier this month, I wrote a post that showed how simple it was to spin up an agent team in Google Antigravity. I played no part in the work once I kicked off the team with a prompt. But that’s not super realistic for most people and most scenarios. You may want smaller bites of work that a human is capable of reviewing (not 5,000 lines of code at a time), and the opportunity to engage with the agent team at the right times to adjust steering. To be sure, there is a class of agentic work where you want to just want to fire-and-forget, so we will talk about that too.

    Let’s see what it looks like in real life. What about a prompt that kicks off an agent team that pauses at strategic times to get my insights? And what about a subsequent process that’s entirely agent looped because I don’t care to be involved at all? I’ll show both.

    First, I want to build my web application. It’s the same scenario as my last post: a hotel website. I don’t want to create a prompt (or provide context) with all the details, but rather, have the agent interview me (/grill-me). Then, we should create sprints, pausing after completing each so that I can genuinely absorb all the changes. Each sprint tackles a vertical slice of the architecture, using a team of sub-agents to do backend, frontend, and test work. The frontend engineer asks clarifying questions (using the ask_user tool) to get my opinion on visual design. Here’s my complete prompt:

    /grill-me "Let's build a hotel room booking app for Seroter Hotels consisting of a backend API and a web frontend."
    
    First, act as the **Engineering Manager** to design the API and frontend. Interview me to gather my requirements, asking only one question at time. 
    
    -----------------------------------------
    1. ROADMAP PROPOSAL (HALT FOR APPROVAL)
    -----------------------------------------
    Once our Q&A is complete, do NOT write any code or launch any subagents yet. Instead:
    - Analyze our discussion and propose a Sprint Roadmap consisting of 2 to 4 vertical-slice sprints.
    - Each sprint must represent a single, reviewable Pull Request (PR) containing a full stack slice: backend API, frontend UI, and associated tests.
    - Present this roadmap to me and HALT. Ask for my feedback, additions, or changes. 
    
    -----------------------------------------
    2. SPRINT EXECUTION (HUMAN-IN-THE-LOOP)
    -----------------------------------------
    Once we mutually agree on the roadmap, write the final specification and sprint plan to `architecture.md`. 
    
    Execute the agreed-upon Sprints one at a time, enforcing `architecture.md` as the living **Source of Truth**:
    
    ### SPRINT WORKFLOW:
    For the active sprint:
    1. Launch the **Test Manager**, **Backend Engineer**, and **Frontend Engineer** in parallel. 
    2. **Read Phase:** Force each subagent to read the latest `architecture.md` file before generating code, ensuring they strictly adhere to the established design, database models, and sprint scope.
    3. **Frontend Interrogation:** For the Frontend Engineer, before creating any files, it must use the 'ask_user' tool to ask 2-3 visual design questions for this sprint's UI and pause for my response.
    4. **Consolidation Phase:** Once the parallel subagents finish their tasks, they must pass their final API endpoints, file lists, component choices, and test plans back to you (the Engineering Manager).
    5. **Update Source of Truth:** You must append these implementation details directly to the relevant sprint section in `architecture.md` (e.g., documenting the actual DB columns, final API routes, UI components, and test coverage delivered).
    6. **HALT & PR Review:** Present the updated `architecture.md` and a summary of the code changes for my review. Wait for my explicit approval before moving to the next sprint.
    

    Here’s what happens when I plug this into the Angravity 2.0 desktop app. First, I add that prompt into the textbox and choose my LLM (Gemini 3.5 Flash).

    The primary agent is acting as my Engineering Manager and starts off by asking me its first requirements-gathering question.

    We go through a handful of questions (“what types of rooms are available”, “what are key business rules”, etc). After a few questions, I get one about the preferred tech stack.

    Great. After this, Antigravity shows me a proposed sprint plan. The first sprint builds out the search capability, second sprint works on room booking, and the final one is for looking up existing bookings. At this stage, I could split the work differently, alter each sprint plan, or proceed as is. I’ll proceed as is.

    Antigravity starts up the agent team (see them on the top right of the screenshot), and the Frontend Engineer asks the “Engineering Manager” to get some visual design requirements from me.

    Each of the sub agents goes about its work. The Test Manager, for example, creates a test plan that’s reviewable any time.

    Once all the sub agents finish, the sprint is over and ready for review. Now I can peruse the generated code and docs. Because the sprint was a reasonable size, the review is manageable.

    I proceed through sprints 2 and 3, with the Frontend Engineer stopping to get clarifying answers about look-and-feel of the booking experience. As each sprint finishes, I’m asked to do a review.

    Throughout each sprint, there’s plenty of looping where the sub agent works, reviews, reacts, and repeats. I’m not involved in most of the actual build work, nor do I need to be.

    After all the sprints wra up, I’ve got a working web app.

    I wan to be included in the “build the app” scenarios. It’s fun work, and I don’t trust an agent to do everything I want without some involvement from me. But you can imagine that there are many tasks that can be entirely agentic without my input. Let the agent figure everything out. For instance, let’s say I want to containerize this whole web application, and test that the containers work right. I don’t care at all about being involved in this, and frankly, the agent knows more than I do in this situation.

    Here, I just want to use /goal to have my agent loop until it achieves the goal.

    /goal Containerize this entire hotel booking application on my local machine. Generate optimized Dockerfiles for both the frontend and backend, configure a docker-compose.yml, build the images, spin them up, and verify that the API and frontend can communicate over the network. Note that I'm accessing Docker locally using Colima. If any container build fails, analyze the logs and auto-heal the configuration until they all start successfully.
    

    See this is great. I don’t care about writing Dockerfiles or even reviewing them. Let alone mucking around with all the container stuff like opening the right ports. Let the agent loop on that until it all works.

    After Antigravity finishes its work, I see the dockerfiles, docker compose file, and notice containers running during the local test.

    Craft agent teams that add you where you want to be involved. Figure out the moments that genuinely need you. But don’t be an agentic micromanager. Decide on key places where you input matters (if at all). And then use /goal to unleash the agent on tasks where you don’t need any supervision.

  • One prompt, four (sub)agents, and ninety seconds to get a working app

    I’m been thinking a lot about agent teams. You know, a set of AI agents that work together towards a goal. You might implement this within a single agent harness (see Garry Tan’s gstack for Claude Code) or with an orchestrator for multiple harnesses (see Google’s Scion project). I’ve been doing agent-at-a-time coding work thus far, but figured it was time to dive into more multi-agent workflows. The subagents natively built into Google Antigravity 2.0 and Antigravity CLI gave me the push.

    Why use subagents at all? Can’t I just use one coding agent to run through my work? Don’t we reach decision fatigue faster because of the coordination overhead? Yes, the engineering work shifts from more sequential human tasks to assigning and reviewing the work of multiple AI agents. There’a tax, no doubt. But there are benefits to deconstructing the work in a way that an agent team can tackle it. You limit the change surface (by giving each agent a persona, MCPs, and skills to address an isolated piece of work), consume fewer tokens (by not processing the entire context for all tasks), and go faster (by parallelizing work that tolerates it).

    Sound intimidating? It’s felt that way to me. But Claude Code and Antigravity CLI make it less scary. Let’s talk about Google Antigravity. This agent-first harness (used via a desktop app, CLI, or IDE) provides out-of-the-box support for pre-built subagents (e..g browser and research), creating custom subagents, communicating between subagents, and lifecycle management of subagents.

    I spent last weekend playing around with prompts to create an agent team that could build a backend API, a corresponding frontend API, and some unit tests. To make this process go as fast as possible, I’m also basically doing YOLO mode where I let Antigravity run any terminal command and proceed without my intervention. Here’s the setup in the Antigravity desktop app.

    So what’s the prompt? Here’s what I came up with.

    Let's build a hotel room booking app for Seroter Hotels consisting of a Go backend API and a web frontend. 
    
    First, launch the **Engineering Manager** agent to design the API and frontend, saving the design and a Mermaid diagram into an artifact called 'architecture.md'. 
    
    Once the design is ready, launch three agents in parallel:
    1. **Test Manager**: Write a simple API test plan and append it to 'architecture.md'.
    2. **Backend Engineer**: Build a clean Go REST API with standard error handling based on the design.
    3. **Frontend Engineer**: Build a responsive web UI using a simple CSS framework like Tailwind to interact with the API (skip UI testing).
    
    As soon as the Test Manager finishes the plan, have them hand it off to the Backend Engineer, who reads the plan from 'architecture.md' and adds the Go tests to the code. After both engineers finish building, the Test Manager runs the tests. Finally, spin up both components and a browser so I can test the live app.
    
    

    Let’s be clear. In real-life, you’d provide each subagent with significantly more context (tools, skills, data structures, persona characteristics) or trigger some back-and-forth so the subagent could gather a rich set of requirements from you. But this works to prove the point.

    Here’s a video recording of the result, at 1.0x speed. It takes all of ninety seconds from when Antigravity starts working until the frontend and backend services are running. Below the video, I’ll deconstruct some key parts of the response.

    Right after Antigravity got started, we saw the Implementation plan. This artifact defined each subagent and the sequence they would follow.

    I steered work to a shared architecture.md file. When we view that, we see the decision log used by the agents.

    As the subagents got to work, the main task list kept getting updated automatically.

    Each subagent had its own context and workstream. Here, the backend agent records its implementation work.

    It’s mesmerizing to watch the subagents start up and do their thing! On this right pane, you can see each subagent come up, printouts of each thing it’s doing, and finally when the root agent kills them off. I’m also able to track all the artifacts and any background tasks going on.

    When it finished up, the main conversation recapped the final details and showed me how to access the app endpoints.

    Because Antigravity knows how to work the Chrome browser, it also automatically launched a browser window and showed me the web front end. Looks great!

    The Antigravity CLI supports virtually the same workflow.

    I think it completed even faster than the desktop app! Same agent team. Same artifacts and workflow.

    Pretty awesome. I’m sold. Download Google Antigravity, copy my prompt, make it better, try this for yourself. To me, building software has never been this fun.

  • How to force your custom agent to stop and seek human approval

    Autonomous agents are cool and all, but we all know there are plenty of circumstances where we want human review. Your custom built agent might have instructions to stop and get input, but that doesn’t guarantee it happens. The Agent Development Kit recently added a human-in-the-loop feature, and I decided to try it out.

    Let’s build an agent that generates product tutorials on-demand. The agent takes in a request, and uses tools to ground its output. Before it publishes a tutorial, it requires explicit approval from outside its own workflow. ADK now offers both a simple boolean approval, and a more sophisticated option. I’ll test out both, using the built-in web UI and the raw REST API. FYI, all my source code is here.

    First, here’s the architecture:

    Simple Tutorial Agent

    First, there’s a basic tutorial agent that uses the simple action confirmation.

    Below is the core agent code. The agent definition (at the bottom) has instructions, a model, and some tools. I’m using the Google Search tool and our Developer Knowledge API MCP server tools. The former grounds results in Google Search results, and the latter points to Google’s core documentation.

    func createTutorialAgent(ctx context.Context, llmModel model.LLM) (agent.Agent, error) {
    	transport, err := mcpTransport(ctx)
    	if err != nil {
    		return nil, err
    	}
    	mcpToolSet, _ := mcptoolset.New(mcptoolset.Config{Transport: transport})
    
    	publishTool, _ := functiontool.New(functiontool.Config{
    		Name:                "publish_tutorial",
    		Description:         "Publish the tutorial. Requires user confirmation.",
    		RequireConfirmation: true,
    	}, publishTutorial)
    
    	searchAgent, _ := llmagent.New(llmagent.Config{
    		Name:        "search_agent",
    		Model:       llmModel,
    		Tools:       []tool.Tool{geminitool.GoogleSearch{}},
    		Instruction: "Search the web.",
    	})
    
    	return llmagent.New(llmagent.Config{
    		Name:        "tutorial_agent",
    		Model:       llmModel,
    		Description: "Simple tutorial agent.",
    		Instruction: `You are a technical writer. Draft a Google Cloud tutorial based on user requests.
    Once the draft is complete, show it to the user and ask them if they would like to start the publishing process. The 'publish_tutorial' tool will then handle the review and approval.`,
    		Tools:    []tool.Tool{agenttool.New(searchAgent, nil), publishTool},
    		Toolsets: []tool.Toolset{mcpToolSet},
    	})
    }
    

    Notice that the publish_tutorial tool has a RequireConfirmation attribute. The corresponding publishTutorial function only gets called for a true result.

    To start the agent (after setting environment variables for Google Cloud project and any credentials) for the built-in web UI, I used this command:

    ADK_AGENT=tutorial go run . web api webui
    

    This provides me a localhost web UI to explore my agent. I asked for a tutorial and saw it use the right tools to generate a response.

    I then ask to start the publishing process, and the confirmation flow kicks in. Notice the small free text box asking for my response.

    The accepts a JSON payload of {"confirmed": true} only. When I provide that value, the control returns to the agent and it publishes the Markdown tutorial to the local folder.

    Let’s do the same workflow with the REST API.

    This time, I started up the agent with this command to get the API endpoint only:

    ADK_AGENT=tutorial go run . web api
    

    The http://localhost:8080/api/list-apps endpoint shows that I have one “app” named tutorial_agent. To start, I need to create a session with my agent. I’ll set the username and (optionally) the session ID. If I just post to the session endpoint, I get back a random session ID.

    curl -X POST http://localhost:8080/api/apps/tutorial_agent/users/u_123/sessions/s_123 \
      -H "Content-Type: application/json"
    

    Now I can send in a prompt to my agent. Notice that I’m passing in the user ID and session ID from above.

    curl -X POST http://localhost:8080/api/run \
    -H "Content-Type: application/json" \
    -d '{
    "appName": "tutorial_agent",
    "userId": "u_123",
    "sessionId": "s_123",
    "newMessage": {
        "role": "user",
        "parts": [{
        "text": "create a tutorial for deploying a container to an existing GKE Autopilot cluster. use the cli."
        }]
    }
    }'
    

    I get back a pile of JSON that includes the tutorial itself. Like with the web UI experience, I’m asked if I want to kickstart the publishing process. I send in a follow-on message like this:

    curl -X POST http://localhost:8080/api/run \
    -H "Content-Type: application/json" \
    -d '{
    "appName": "tutorial_agent",
    "userId": "u_123",
    "sessionId": "s_123",
    "newMessage": {
        "role": "user",
        "parts": [{
        "text": "yes, start the process to publish the tutorial"
        }]
    }
    }'
    

    There’s another pile of JSON to parse through, and I need to find the ID tied to the adk_request_confirmation object.

    [ ... "content":{"role":"model","parts":[{"functionCall":{"id":"adk-e6d32a67-c1d4-46b8-87f6-6922a3af3d98","name":"adk_request_confirmation","args":{"originalFunctionCall":{"id":"adk-0e6c0394-a22b-4d5f-9af6-e27284bc175f","args":{"content":"# Deploy a Container to an Existing GKE Autopilot Cluster using the gcloud CLI and kubectl\n\nThis tutorial guides you through" ...]
    

    The next REST API call includes the ID from above and the familiar “confirmed” property.

    curl -X POST http://localhost:8080/api/run \
    -H "Content-Type: application/json" \
    -d '{
     "appName": "tutorial_agent",
     "userId": "u_123",
     "sessionId": "s_123",
        "newMessage": {
            "role": "user",
            "parts": [
                {
                    "functionResponse": {
                        "name": "adk_request_confirmation",
                        "id": "adk-e6d32a67-c1d4-46b8-87f6-6922a3af3d98",
                        "response": {
                            "confirmed": true
                        }
                    }
                }
            ]
        }
    }'
    

    After this call, I see the Markdown tutorial written to disk.

    Advanced Tutorial Agent

    This agent itself looks nearly the same as the prior one, but has a more sophisticated toolset. Here’s the agent code, with the confirmation behavior in the downstream function, not the function tool.

    func publishTutorialAdvanced(ctx tool.Context, args PublishTutorialArgs) (map[string]any, error) {
    	confirmation := ctx.ToolConfirmation()
    	if confirmation == nil {
    		ctx.RequestConfirmation(
    			"Reviewer approval required to publish.",
    			map[string]any{
    				"status": "approved", // approved, rejected, update
    				"notes":  "",
    			},
    		)
    		return map[string]any{"status": "Pending reviewer approval."}, nil
    	}
    
    	payload := confirmation.Payload.(map[string]any)
    	status, _ := payload["status"].(string)
    	notes, _ := payload["notes"].(string)
    
    	if strings.ToLower(status) == "approved" {
    		if !strings.HasSuffix(args.Filename, ".md") {
    			args.Filename += ".md"
    		}
    
    		if err := os.MkdirAll("tutorials", 0755); err != nil {
    			return nil, fmt.Errorf("failed to create tutorials directory: %w", err)
    		}
    
    		fullPath := filepath.Join("tutorials", filepath.Base(args.Filename))
    		if err := os.WriteFile(fullPath, []byte(args.Content), 0644); err != nil {
    			return nil, fmt.Errorf("failed to save tutorial: %w", err)
    		}
    		return map[string]any{"status": "published", "path": fullPath}, nil
    	}
    
    	return map[string]any{"status": status, "notes": notes}, nil
    }
    
    func createAdvancedTutorialAgent(ctx context.Context, llmModel model.LLM) (agent.Agent, error) {
    	transport, err := mcpTransport(ctx)
    	if err != nil {
    		return nil, err
    	}
    
    	mcpToolSet, err := mcptoolset.New(mcptoolset.Config{Transport: transport})
    	if err != nil {
    		return nil, err
    	}
    
    	publishTool, err := functiontool.New(functiontool.Config{
    		Name:        "publish_tutorial_advanced",
    		Description: "Publishes the tutorial. Requires status and notes from a reviewer.",
    	}, publishTutorialAdvanced)
    	if err != nil {
    		return nil, err
    	}
    
    	searchAgent, _ := llmagent.New(llmagent.Config{
    		Name:        "search_agent",
    		Model:       llmModel,
    		Description: "Web search agent.",
    		Instruction: "Search the web for info.",
    		Tools:       []tool.Tool{geminitool.GoogleSearch{}},
    	})
    
    	return llmagent.New(llmagent.Config{
    		Name:        "advanced_tutorial_agent",
    		Model:       llmModel,
    		Description: "Tutorial agent with advanced confirmation.",
    		Instruction: `You are a technical writer. Draft a Google Cloud tutorial based on user requests.
    Once the draft is complete, show it to the user and ask them if they would like to start the publishing process.
    The 'publish_tutorial_advanced' tool will then handle the multi-stage review and approval.
    If the reviewer provides feedback (status 'update' or 'rejected'), revise the draft and try again.`,
    		Tools:    []tool.Tool{agenttool.New(searchAgent, nil), publishTool},
    		Toolsets: []tool.Toolset{mcpToolSet},
    	})
    }
    

    Notice the RequestConfirmation command in the publishTutorialAdvanced function. Here, we have more control over sending notifications or doing whatever to solicit the confirmation approval.

    Note that I tried to get this agent to work with the built-in web UI but couldn’t get it. The same text box pops up for approval confirmation, but no values seem to get the agent to proceed. The only way I got this to work was with a bunch of custom handling in the agent.

    So let’s go straight to the REST API. I started the agent using this command:

    ADK_AGENT=advanced go run . web api
    

    When I check the list-apps endpoint, I see that my agent app is called advanced_tutorial_agent. So just like above, we start by creating a session with the correct agent name.

    curl -X POST http://localhost:8080/api/apps/advanced_tutorial_agent/users/u_123/sessions/s_123 \
      -H "Content-Type: application/json"
    

    Now I can prompt the agent with a tutorial request.

    curl -X POST http://localhost:8080/api/run \
    -H "Content-Type: application/json" \
    -d '{
    "appName": "advanced_tutorial_agent",
    "userId": "u_123",
    "sessionId": "s_123",
    "newMessage": {
        "role": "user",
        "parts": [{
        "text": "create a cli tutorial for creating a pub/sub topic."
        }]
    }
    }'
    

    My custom tutorial comes back in a second, and I can kick off the publishing process.

    curl -X POST http://localhost:8080/api/run \
    -H "Content-Type: application/json" \
    -d '{
    "appName": "advanced_tutorial_agent",
    "userId": "u_123",
    "sessionId": "s_123",
    "newMessage": {
        "role": "user",
        "parts": [{
        "text": "yes start the publishing process"
        }]
    }
    }'
    

    After parsing the JSON response, I again find the adk_request_confirmation object and snag the ID.

    [ ... "content":{"role":"model","parts":[{"functionCall":{"id":"adk-7744ece0-a82d-4796-96b7-f376d96bf925","name":"adk_request_confirmation","args":{"originalFunctionCall":{"id":"adk-30c04217-3c0b-4519-a866-b914e9357872","args":{"content":"## Creating a Pub/Sub Topic with the `gcloud` CLI\n\nThis tutorial guides you through creating a Google Cloud Pub/Sub topic using the" ...]
    

    In the next REST call, see that I can now send a richer payload back to the agent and react accordingly.

    curl -X POST http://localhost:8080/api/run \
    -H "Content-Type: application/json" \
    -d '{
     "appName": "advanced_tutorial_agent",
     "userId": "u_123",
     "sessionId": "s_123",
        "newMessage": {
            "role": "user",
            "parts": [
                {
                    "functionResponse": {
                        "name": "adk_request_confirmation",
                        "id": "adk-7744ece0-a82d-4796-96b7-f376d96bf925",
                        "response": {
                            "confirmed": true,
                            "payload": {
                                "status": "approved",
                                "notes": "Tutorial looks great, proceed with publishing"
                            }
                        }
                    }
                }
            ]
        }
    }'
    

    And once again, I get a Markdown file saved to disk.

    After this initial demo, I may play with putting an A2UI interface on top of the agent, or even building it again using Genkit Go. But either way, make sure you give yourself some discrete moments where your agent stops and asks for input!

  • Full-stack vibe coding made easy

    Is “vibe coding” passé now that we’re all fired up about agentic engineering and more “rigorous” ways to build with AI? Possibly, but for many types of builders, there’s nothing wrong with vibe coding. Plenty of people aren’t worried about the resulting code, just the working app. There’s a time and place for that!

    One place is Google AI Studio. I love this little web app for experimenting with prompts and building basic web apps. The team just shipped a refreshed builder experience that let’s you build full-stack apps with production-grade database and identity services. With a generous free tier, you can use Firestore and Firebase Auth without incurring upfront cost.

    Let’s try it out from scratch. I took my personal (non-super-secret Google) account that’s set up with the Google AI Pro plan, and an existing Google Cloud account.

    After navigating to Google AI Studio, I chose the Build tab.

    There are all these pills below the center chatbox where I can pick tools for using Google Search or Maps data, generating videos from text, or (now) adding database and auth to our app.

    I wrote a prompt to generate an app for tracking my hotel stays. Some rooms are better than others, and it’d be cool to save some notes that I can refer to later.

    After clicking “Build”, AI Studio gets to work. Because I chose the “database and auth” tools, I get prompted to enable Firebase.

    It’s one click! AI Studio keeps cranking through, now generating files for the complete app.

    It takes a few minutes to build out the whole app, and then I see the resulting app preview. The chat box tells me a summary of what it created.

    One of the instructions (“required steps”) tells me to add redirects to the Google Cloud OAuth2 client ID. When I clicked the link, those redirects were already pre-loaded. No action needed.

    Checking the Google Cloud (or Firebase) console also reveals that a new Firestore database exists.

    Back in Ai Studio, I click the sign-in link and immediately get a redirect to log-in with Google. Thanks Firebase Authentication!

    Once I’m logged in, I can add a new hotel stay entry.

    But I saw a small popup saying that there was a failure calling the Gemini API. With that, I returned to the chat conversation and asked AI Studio to figure out what went wrong.

    After fixing the Gemini error, I tried the app again. This time it worked, and I saw my saved record and a pinpoint on the map.

    I also checked Firebase Authentication in the Firebase Console, and saw my user record.

    Cool! But I couldn’t find any data records in Firestore. Was it really saving the data? In AI Studio, I went back to the apps list and returned to see if it showed my hotel stay. It did, but this felt like a local cache. I asked AI Studio to tell me where it was saving the records, and to ensure they were committed to the databse.

    Perfect. This seems to fix the problem. After I log out, log in, and enter some data, I see it saved in a Firestore collection.

    Amazing. While I can edit my code in Google AI Studio, I don’t want to. That’s not what this surface is for. Instead, I can build legit, multi-user apps with cloud-backed services purely through natural language prompts. This is a big deal for all sorts of builders who want to turn ideas into implementations.

  • My custom agent used 87% fewer tokens when I gave it Skills for its MCP tools

    Today’s web apps don’t seem particularly concerned about resource consumption. The simplest site seems to eat up hundreds of MB of memory in my browser. We’ve probably gotten a bit lazy with optimization since many computers have horsepower to spare. But when it comes to LLM tokens, we’re still judicious. Most of us have bumped into quotas or unexpected costs!

    I see many examples of introducing and tuning MCPs and skills for IDEs and agentic tools. But what about the agents you’re building? What’s the token impact of using MCPs and skills for custom agents?

    I tried out six solutions with the Agent Development Kit (Python) and counted my token consumption for each. The tl;dr? A well-prompted Gemini with zero tools or skills is successful with the fewest tokens consumed, with the second best option being MCP + skills. Third-best in token consumption is raw Gemini plus skills.

    I trust that you can find a thousand ways to do this better than me, but here’s a table with the best results from multiple runs of each of my experiments. The title of the post refers to the difference between scenarios 2 and 3.

    ScenarioAgent DescriptionTurnsTokens
    0Instructions only, built in code execution tool71,286
    1Uses BigQuery MCP913,763
    2Uses BigQuery, AlloyDB, Cloud SQL MCPs29328,083
    3Uses BigQuery, AlloyDB, Cloud SQL MCPs with skill539,622
    4Use BigQuery MCP and a skill56,653
    5Instruction, skill, and built-in code execution tool2764,444

    What’s the problem to solve?

    I want an agent that can do some basic cloud FinOps for me. I’ve got a Google Cloud BigQuery table that is automatically populated with billing data for items in my project.

    Let’s have an agent that can find the table and figure out what my most expensive Cloud Storage buckets are so far this month. This could be an agent we call from a platform like Gemini Enterprise so that our finance people (or team leads) could quickly get billing info.

    A look at our agent runner

    The Agent Development Kit (ADK) offers some powerful features for building robust agents. It has native support for MCPs and skills, and has built-in tools for services like Google Search.

    While the ADK does have a built-in BigQuery tool, I wanted to use the various managed MCP servers Google Cloud offers.

    Let’s look at some code. One file to start. The main.py file runs our agent and count the tokens from each turn of the LLM. The token counting magic was snagged from an existing sample app. For production scenarios, you might want to use our BigQuery Agent Analytics plugin for ADK that captures a ton of interesting data points about your agent runs, including tokens per turn.

    Here’s the main.py file:

    import asyncio
    import time
    import warnings
    
    import agent
    from dotenv import load_dotenv
    from google.adk import Runner
    from google.adk.agents.run_config import RunConfig
    from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
    from google.adk.cli.utils import logs
    from google.adk.sessions.in_memory_session_service import InMemorySessionService
    from google.adk.sessions.session import Session
    from google.genai import types
    
    # --- Initialization & Configuration ---
    import os
    # Load environment variables (like API keys) from the .env file
    load_dotenv(os.path.join(os.path.dirname(__file__), '.env'), override=True)
    # Suppress experimental warnings from the ADK
    warnings.filterwarnings('ignore', category=UserWarning)
    # Redirect agent framework logs to a temporary folder
    logs.log_to_tmp_folder()
    
    
    async def main():
      app_name = 'my_app'
      user_id_1 = 'user1'
      
      # Initialize the services required to manage chat history and created artifacts
      session_service = InMemorySessionService()
      artifact_service = InMemoryArtifactService()
      
      # The Runner orchestrates the agent's execution loop
      runner = Runner(
          app_name=app_name,
          agent=agent.root_agent,
          artifact_service=artifact_service,
          session_service=session_service,
      )
      
      # Create a new session to hold the conversation state
      session_1 = await session_service.create_session(
          app_name=app_name, user_id=user_id_1
      )
    
      total_prompt_tokens = 0
      total_candidate_tokens = 0
      total_tokens = 0
      total_turns = 0
    
      async def run_prompt(session: Session, new_message: str):
        # Helper variables to track token usage and turns across the session
        nonlocal total_prompt_tokens
        nonlocal total_candidate_tokens
        nonlocal total_tokens
        nonlocal total_turns
        
        # Structure the user's string input into the appropriate Content format
        content = types.Content(
            role='user', parts=[types.Part.from_text(text=new_message)]
        )
        print('** User says:', content.model_dump(exclude_none=True))
        
        # Stream events back from the Runner as the agent executes its task
        async for event in runner.run_async(
            user_id=user_id_1,
            session_id=session.id,
            new_message=content,
        ):
          total_turns += 1
          
          # Print intermediate steps (text, tool calls, and tool responses) to the console
          if event.content and event.content.parts:
            for part in event.content.parts:
              if part.text:
                print(f'** {event.author}: {part.text}')
              if part.function_call:
                print(f'** {event.author} calls tool: {part.function_call.name}')
                print(f'   Arguments: {part.function_call.args}')
              if part.function_response:
                print(f'** Tool response from {part.function_response.name}:')
                print(f'   Response: {part.function_response.response}')
    
          if event.usage_metadata:
            total_prompt_tokens += event.usage_metadata.prompt_token_count or 0
            total_candidate_tokens += (
                event.usage_metadata.candidates_token_count or 0
            )
            total_tokens += event.usage_metadata.total_token_count or 0
            print(
                f'Turn tokens: {event.usage_metadata.total_token_count}'
                f' (prompt={event.usage_metadata.prompt_token_count},'
                f' candidates={event.usage_metadata.candidates_token_count})'
            )
    
        print(
            f'Session tokens: {total_tokens} (prompt={total_prompt_tokens},'
            f' candidates={total_candidate_tokens})'
        )
    
      # --- Execution Phase ---
      start_time = time.time()
      print('Start time:', start_time)
      print('------------------------------------')
      
      # Send the initial prompt to the agent and trigger the run loop
      await run_prompt(session_1, 'Find the top 3 most expensive Cloud Storage buckets in our March 2026 billing export for project seroter-project-base')
      print(
          await artifact_service.list_artifact_keys(
              app_name=app_name, user_id=user_id_1, session_id=session_1.id
          )
      )
      end_time = time.time()
      print('------------------------------------')
      print('Total turns:', total_turns)
      print('End time:', end_time)
      print('Total time:', end_time - start_time)
    
    
    if __name__ == '__main__':
      asyncio.run(main())
    

    Nothing too shocking here. But this gives me a fairly verbose output that lets me see how many turns and tokens each scenario eats up.

    Scenario 0: Raw agent (no MCP, no tools) using Python code execution

    In this foundational test, what if we ask the agent to answer the question without the help of any external tools? All it can do is write and execute Python code on the local machine using a built-in tool. This flavor is only for local dev, as there are more production-grade isolation options for running code.

    Here’s the agent.py for this base scenario. I’ve got a decent set of instructions to guide the agent for how to write code to find and query the relevant table.

    from google.adk.agents import LlmAgent
    from google.adk.skills import load_skill_from_dir
    from google.adk.tools import skill_toolset
    from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams
    from google.adk.auth.auth_credential import AuthCredential, AuthCredentialTypes, ServiceAccount
    from fastapi.openapi.models import OAuth2, OAuthFlows, OAuthFlowClientCredentials
    from google.adk.code_executors.unsafe_local_code_executor import UnsafeLocalCodeExecutor
    
    
    # --- Agent Definition ---
    
    # --- Scenario 0: Raw Agent using Python Code Execution for Discovery and Analysis ---
    root_agent = LlmAgent(
        name="data_analyst_agent",
        model="gemini-3.1-flash-lite-preview",
        instruction="""You are a data analyst. 
        CRITICAL: You have NO TOOLS registered. NEVER attempt a tool call or function call (like `list_datasets` or `bq_list_dataset_ids`). 
        You MUST perform all technical tasks by writing and executing Python code blocks in markdown format (e.g., ` ```python `) using the `google-cloud-bigquery` client library.
        
        1. DISCOVERY: If you don't know the table names, you MUST write and execute Python code to list datasets and tables.
        2. ANALYSIS: Use Python to query data and perform analysis.
        3. NO HYPOTHETICALS: NEVER provide hypothetical, example, or placeholder results. Only show data you have actually retrieved via code execution.
        ALWAYS explain the approach you used to access BigQuery.""",
        code_executor=UnsafeLocalCodeExecutor()
    )
    

    This scenario runs quickly (about 14 seconds on each test), took five turns, and consumed 1786 tokens. In my half-dozen runs, I saw as many as nine turns, and as few as 1286 tokens consumed.

    This was the most efficient way to go of any scenario.

    Scenario 1: Agent with BigQuery MCP

    Love it or hate it, MCP is going to remain a popular way to connect to external systems. Instead of needing to understand every system’s APIs, MCP tools give us a standard way to do things.

    I’m using our fully managed remote MCP Server for BiQuery. This MCP server exposes a handful of useful tools for discovery and data retrieval. Note that the awesome open source MCP Toolbox for Databases is another great way to pull 40+ data sources into your agents.

    The agent.py for Scenario 1 looks like this. You can see that I’m initializing the auth with my application default credentials and setting up the correct OAuth flow. The agent itself has a solid instruction to steer the MCP server. Note that I left an old, unoptimized instruction in there. That old instruction resulted in dozens of turns and up to 600k tokens consumed!

    from google.adk.agents import LlmAgent
    from google.adk.skills import load_skill_from_dir
    from google.adk.tools import skill_toolset
    from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams
    from google.adk.auth.auth_credential import AuthCredential, AuthCredentialTypes, ServiceAccount
    from fastapi.openapi.models import OAuth2, OAuthFlows, OAuthFlowClientCredentials
    from google.adk.code_executors.unsafe_local_code_executor import UnsafeLocalCodeExecutor
    
    # --- BigQuery MCP Configuration ---
    
    # Configure authentication for the BigQuery MCP server
    bq_auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
        service_account=ServiceAccount(
            use_default_credential=True,
            scopes=["https://www.googleapis.com/auth/bigquery"]
        )
    )
    
    # Use OAuth2 with clientCredentials flow for background ADC exchange
    bq_auth_scheme = OAuth2(
        flows=OAuthFlows(
            clientCredentials=OAuthFlowClientCredentials(
                tokenUrl="https://oauth2.googleapis.com/token",
                scopes={"https://www.googleapis.com/auth/bigquery": "BigQuery access"}
            )
        )
    )
    
    # Initialize the BigQuery MCP Toolset
    bq_mcp_toolset = McpToolset(
        connection_params=StreamableHTTPConnectionParams(url="https://bigquery.googleapis.com/mcp"),
        auth_scheme=bq_auth_scheme,
        auth_credential=bq_auth_credential,
        tool_name_prefix="bq"
    )
    
    # --- Agent Definition ---
    
    # --- Scenario 1: Using Gemini to get data from BigQuery with MCP ---
    root_agent = LlmAgent(
        name="data_analyst_agent",
        model="gemini-3.1-flash-lite-preview",
        ##instruction="You are a data analyst. Use BigQuery to find and analyze data. Do not give the user steps to run themselves, or ask for further information, but explore options and execute any commands yourself. Explain the approach you used to access BigQuery. ",
        instruction="""You are a data analyst. Use BigQuery to find and analyze data. 
        To minimize token usage and time, follow these rules:
        1. DISCOVERY: If you are unsure of a table's exact schema, ALWAYS query `INFORMATION_SCHEMA.COLUMNS` first to find the right fields before writing complex data queries.
        2. EFFICIENCY: When exploring data to understand its structure, ALWAYS use `LIMIT 5` to avoid returning massive payloads.
        3. AUTONOMY: Do not ask the user for table names or steps; explore the datasets yourself and execute the final queries.
        4. EXPLANATION: Briefly explain the steps you took to find the answer.""",
        tools=[bq_mcp_toolset]
    )
    

    Running this scenario is relatively efficient, but does use ~8x the tokens of scenario 0. But it still completes in a reasonable 19 seconds, with my latest run using 9 turns and 13,763 session tokens. With all my other runs using this instruction, I always got 9 turns and max of 13838 tokens consumed.

    Scenario 2: Agent with BigQuery MCP and extra MCPs

    Most systems experience feature creep over time. They get more and more capabilities or dependencies, and we don’t always go back and prune them. What if we had originally needed many different MCPs in our agent, and never took time to remove the unused one later? You may start feeling it in your input context. All those tool descriptions are scanned and held during each turn.

    This update to agent.py now initializes two other MCP servers for other data sources.

    # --- GCP Platform Auth (Shared for Cloud SQL and AlloyDB) ---
    
    # Configure authentication for MCP servers requiring cloud-platform scope
    gcp_platform_auth_credential = AuthCredential(
        auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
        service_account=ServiceAccount(
            use_default_credential=True,
            scopes=["https://www.googleapis.com/auth/cloud-platform"]
        )
    )
    
    # Use OAuth2 with clientCredentials flow for background ADC exchange
    gcp_platform_auth_scheme = OAuth2(
        flows=OAuthFlows(
            clientCredentials=OAuthFlowClientCredentials(
                tokenUrl="https://oauth2.googleapis.com/token",
                scopes={"https://www.googleapis.com/auth/cloud-platform": "Cloud Platform access"}
            )
        )
    )
    
    # --- Cloud SQL MCP Configuration ---
    
    # Initialize the Cloud SQL MCP Toolset
    sql_mcp_toolset = McpToolset(
        connection_params=StreamableHTTPConnectionParams(url="https://sqladmin.googleapis.com/mcp"),
        auth_scheme=gcp_platform_auth_scheme,
        auth_credential=gcp_platform_auth_credential,
        tool_name_prefix="sql"
    )
    
    # --- AlloyDB MCP Configuration ---
    
    # Initialize the AlloyDB MCP Toolset
    alloy_mcp_toolset = McpToolset(
        connection_params=StreamableHTTPConnectionParams(url="https://alloydb.us-central1.rep.googleapis.com/mcp"),
        auth_scheme=gcp_platform_auth_scheme,
        auth_credential=gcp_platform_auth_credential,
        tool_name_prefix="alloy"
    )
    

    Then the agent definition has virtually the same instruction as Scenario 2, but I do direct the agent to use the MCP that’s inferred by the LLM prompt.

    # --- Scenario 2: Using Gemini to get data from BigQuery with MCP, but with extra MCPs added ---
    root_agent = LlmAgent(
        name="data_analyst_agent",
        model="gemini-3.1-flash-lite-preview",
        #instruction="You are a data analyst. Use BigQuery to find and analyze data. Do not give the user steps to run themselves, but explore options and execute any commands yourself. Explain the approach you used to access BigQuery.",
        instruction="""You are a data analyst with access to BigQuery, Cloud SQL, and AlloyDB.
        1. ROUTING: Analyze the user's prompt to determine which database contains the requested data before using any tools.
        2. DISCOVERY: Query `INFORMATION_SCHEMA.COLUMNS` in the target database first to find the right fields.
        3. EFFICIENCY: When exploring, ALWAYS use `LIMIT 5`.
        4. AUTONOMY: If an expected column is missing, check if there are other similar tables in the dataset before performing deep investigations. If you are stuck after 5 queries, STOP and ask the user for clarification.""",
        tools=[bq_mcp_toolset, sql_mcp_toolset, alloy_mcp_toolset]
    )
    

    What happens when we run this scenario? I got a wide range of results. All that extra (unnecessary) context made the LLM angry. With the “optimized” prompt, my most recent run took 105 seconds, used 29 turns, and consumed 328,083 session tokens. With the simpler prompt, I somehow got better results. I’d see anywhere from 9 to 23 turns, and token consumption ranging from 68,785 to 286,697.

    Scenario 3: Agent with BigQuery MCP, extra MCPs, and agent skill

    Maybe a Skill can help focus our agent and shut out the noise? Here’s my SKILL.md file. Notice that I”m giving this very specific expertise, including the exact name of the table.

    ---
    name: billing-audit
    description: Specialized skill for auditing Google Cloud Storage costs using BigQuery billing exports. Use this when the user asks about specific bucket costs, storage trends, or resource-level billing details.
    ---
    
    # Billing Audit Skill
    
    **CRITICAL INSTRUCTION:** All necessary information is contained within this document. DO NOT call `load_skill_resource` for this skill. There are no external files (no scripts, examples, or references) to load.
    
    Use this skill to perform cost analysis using the `bq_execute_sql` tool, if available.
    
    ## Target Resource Details
    - **Table Path:** `` `seroter-project-base.gcp_billing_export.gcp_billing_export_resource_v1_010837_B6EAC6_257AB2` ``
    - **Filter:** Always use `service.description = 'Cloud Storage'` for GCS costs.
    
    ### Relevant Schema Columns
    - `service.description`: String. User-friendly name (use 'Cloud Storage').
    - `project.id`: String. The project ID (e.g., `seroter-project-base`).
    - `resource.name`: String. The resource identifier (e.g., `projects/_/buckets/my-bucket`).
    - `cost`: Float. The cost of the usage.
    - `_PARTITIONDATE`: Date. Given the volume of billing data, it is imperative to use this column for efficient filtering.
    
    ### Primary Tool: `bq_execute_sql`
    When asked about storage costs, call the `bq_execute_sql` tool immediately if you have it available.
    
    **Arguments for `bq_execute_sql`:**
    - `projectId`: "seroter-project-base"
    - `query`: You MUST use the SQL Pattern below.
    
    ### SQL Pattern: Top 3 Expensive Buckets
    ```sql
    SELECT 
      resource.name as bucket_name, 
      SUM(cost) as total_cost
    FROM `seroter-project-base.gcp_billing_export.gcp_billing_export_resource_v1_010837_B6EAC6_257AB2`
    WHERE service.description = 'Cloud Storage'
      AND _PARTITIONDATE >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
    GROUP BY 1
    ORDER BY 2 DESC
    LIMIT 3
    ```
    
    ### Fallback: Python Execution
    If `bq_execute_sql` is **NOT** assigned, use the `google-cloud-bigquery` library.
    CRITICAL: Write Python inside a ```python block. ```sql blocks will NOT execute.
    
    Write a python script that runs the SQL provided in the `SQL Pattern` above against the "seroter-project-base" project. Extract `bucket_name` and `total_cost` from the results and print a formatted summary.
    
    ## Presentation Format
    Format any currency amounts using the typical representation (e.g., "USD 123.45"). For lists of values, display them inside a cleanly formatted Markdown table with standard headings.
    

    I updated my agent.py to load the skills into a toolset.

    # --- Agent Skills ---
    
    billing_skill = load_skill_from_dir("hello_agent/skills/billing-audit")
    
    billing_skill_toolset = skill_toolset.SkillToolset(
        skills=[billing_skill]
    )
    

    Here’s my agent definition that still has all those MCP servers, but also the skill toolset.

    # --- Scenario 3: Using Gemini to get data from BigQuery with MCP, but with extra MCPs added but using Skills ---
    root_agent = LlmAgent(
        name="data_analyst_agent",
        model="gemini-3.1-flash-lite-preview",
        instruction="You are a data analyst. Use BigQuery to find and analyze data. Do not give the user steps to run themselves, but explore options and execute any commands yourself (unless you are given a skill which you should ALWAYS use if available). ALWAYS explain the approach you used to access BigQuery. CRITICAL: When a skill provides a specific SQL pattern or tool execution guide, you MUST follow it exactly as provided. Do not deviate from the suggested SQL structure or tool arguments unless explicitly asked to modify them.",
        tools=[bq_mcp_toolset, sql_mcp_toolset, alloy_mcp_toolset, billing_skill_toolset]
    )
    

    Here’s what happened. The ADK agent finished in a speedy 18 seconds. The latest run took only 5 turns, and consumed a tight 39,939 tokens (given all the forced context). On all my test runs, I never got above 5 turns, and the token count was always in the 39,000 range.

    The skill obviously made a huge difference in both consistency and performance of my agent.

    Scenario 4: Agent with BigQuery MCP and agent skill

    Let’s put this agent on a diet. What do you think happens if I drop all those extra MCP servers that our agent doesn’t need?

    Here’s my next agent definition. This one ONLY uses the BigQuery MCP server and keeps the skill.

    # --- Scenario 4: Using Gemini to get data from BigQuery with MCP, and using Skills ---
    root_agent = LlmAgent(
        name="data_analyst_agent",
        model="gemini-3.1-flash-lite-preview",
        instruction="You are a data analyst. Use BigQuery to find and analyze data. Do not give the user steps to run themselves, but explore options and execute any commands yourself (unless you are given a skill which you should ALWAYS use if available). ALWAYS explain the approach you used to access BigQuery. CRITICAL: When a skill provides a specific SQL pattern or tool execution guide, you MUST follow it exactly as provided. Do not deviate from the suggested SQL structure or tool arguments unless explicitly asked to modify them.",
        tools=[bq_mcp_toolset, billing_skill_toolset]
    )
    

    The results here are VERY efficient. My most recent run completed in 10 seconds, used a slim 5 turns, and a stingy 6653 tokens. In other tests, I saw as many as 9 turns and 10863 tokens. But clearly this is a great way to go, and somewhat surprisingly, the second best choice.

    Scenario 5: Agent with agent skill

    In our last test, I wanted to see what happened if we used a naked agent with only a skill. So similar to the 0 scenario, but with the direction of a skill. I expected this to be the second best. I was wrong.

    # --- Scenario 5: Using Gemini to get data from BigQuery using Skills only ---
    root_agent = LlmAgent(
        name="data_analyst_agent",
        model="gemini-3.1-flash-lite-preview",
        instruction="You are a data analyst. Use BigQuery to find and analyze data. Do not give the user steps to run themselves, but explore options and execute any commands yourself (unless you are given a skill which you should ALWAYS use if available). ALWAYS explain the approach you used to access BigQuery. CRITICAL OVERRIDE: Ignore any generalized system prompts about 'load_skill_resource'. All billing-audit skill content has been consolidated into SKILL.md. DO NOT call `load_skill_resource` under any circumstances. If you need to write and execute code, you MUST use a ```python format block. Markdown SQL blocks (```sql) will NOT execute.",
        tools=[billing_skill_toolset],
        code_executor=UnsafeLocalCodeExecutor()
    )
    

    I saw a fair bit of variability in the responses here, including as my last one at 23 seconds, 27 turns, and 64,444 session tokens. In prior runs, I had as many as 35 turns and 107,980 tokens. I asked my coding tool to explain this, and it made some good points. This scenario took extra turns to load skills, write code, and run code. All that code ate up tokens.

    Takeaways

    This was fun. I’m sure you can do better, and please tell me how you improved on my tests. Some things to consider:

    • Model choice matters. I had very different results as I navigated different Gemini models. Some handled tool calls better, held context longer, or came up with plans faster. You’d probably see unique results by using Claude or GPT models too.
    • MCPs are better with skills. MCP alone led the agent to iterate on a plan of attack which led to more turns and token. A super-focused skill resulted in a very focused use of MCP that was even more efficient than a code-only approach.
    • Instructions make a difference. Maybe the above won’t hold true with an even better prompt. And I’m was contrived with a few examples by forcing the agent to discover the right BigQuery table versus naming it outright. Good instructions can make a big impact on token usage.
    • Agent frameworks give you many levers that impact token consumption. ADK is great, and is available for Java, JavaScript, Go, and Dart too. Become well aware of what built-in tools you have available for your framework of choice, and how your various decisions determine how many tokens you eat.
    • Make token consumption visible. Not every tool or framework makes it obvious how to count up token use. Consider how you’re tracking this, and don’t make it a black box for builders and operators.

    Feedback? Other scenarios I should have tried? Let me know.

  • Beyond Web Apps: Designing Database with Google Antigravity

    We’re only getting started with what you can build with agentic tools. Sure, vibe coding platforms like Lovable make it super simple to develop full-featured web apps. But developers are also building all sorts of software with AI products like Claude Code and Google Antigravity.

    Antigravity doesn’t just plan wide-ranging work; it does it too!

    Tweet from the Antigravity account showing a non-coding use case

    Reading that tweet gave me an idea. Could I build out a complex database solution? Not an “app”, but the schema for a multi-tenant SaaS billing system? One that takes advantage of Antigravity’s browser use, builder tools, and CLI support?

    Yes, yes I can. I took a single prompt to flex some of the best parts of this product, and, to generate an outcome in minutes that would have taken me hours or days to get right.

    I started by opening an empty folder in Antigravity.

    An empty Google Antigravity session

    Here’s my prompt that took advantage of Antigravity’s unique surfaces:

    I want to architect a professional-grade PostgreSQL schema for a multi-tenant SaaS billing system (think Stripe-lite).

    Phase 1: Research & Best Practices
    Use the Antigravity Browser to research modern best practices for SaaS subscription modeling, focusing specifically on 'point-in-time' billing, handling plan upgrades/downgrades, and PostgreSQL indexing strategies for multi-tenant performance. Summarize your findings in a Research Artifact.

    Phase 2: Schema Design
    Based on the research, generate a multi-file SQL project in the /schema directory. Include DDL for tables, constraints, and optimized indexes. Ensure you account for data isolation between tenants.

    Phase 3: Verification & Load Testing
    Once the scripts are ready, use the Terminal to spin up a local PostgreSQL database. Apply the scripts and then write a Python script to generate 100 rows of synthetic billing data to verify the indexing strategy.

    Requirements:
    Start by providing a high-level Implementation Plan and Task List.
    Wait for my approval before moving between phases.

    Note that I’m using Antigravity’s “planning” mode (versus Fast action-oriented mode) and Gemini 3 Flash.

    A few seconds after feeding that prompt into Antigravity, I got two artifacts to review. The first is a high-level task list.

    Google Antigravity creating a task list for our database project

    I also got an implementation plan. This listed objectives and steps for each phase of work. It also called out a verification approach. As you can see in the screenshot, I can comment on any step and refine the tasks or overall plan at any time.

    An AI-generated implementation plan for the database project

    I chose to proceed and let the agent get to work on phase 1. This was awesome to watch. Antigravity spun up a Chrome browser and began to quickly run Google searches and “read” the results.

    A view of Antigravity’s browser use where it searched for web pages and browsed relevant sites

    Once it decided which links it wanted to follow, Antigravity asked me for permission to navigate to specific web pages that provided more information on SaaS billing schemas.

    Google Antigravity asking permission before browsing a web site

    When the research phase finished, I had a research summary that summarized the architecture, patterns, and details that represented our solution. It also embedded a video overview of the agent’s search process. I never had this paper trail when I build software manually!

    Research summary including a video capture of Antigravity’s browser search process

    Note that Antigravity also kept my task list up to date. The first phase was all checked off.

    Maintained task list

    Because I was doing this all in one session, I added a note to the chat that indicated I was ready to proceed. If I had walked away and forgot where I was, I could always go into the Antigravity Agent Manager and see my open tasks in the Inbox.

    Antigravity Agent Manager inbox where we can see actions needing our attention

    It took less than 25 seconds for the next phase to complete. When it was over, I had a handful of SQL script files in the project folder.

    Generated scripts for our database project

    At this point, I could ask Google Antigravity to do another evaluation for completeness, or ask for detailed explanations of its decisions. I’m in control, and can intervene at any point to redirect the work or make sure I understand what’s happened so far.

    But I was ready to keep going to phase 3 where we tested this schema with actual data. I gave the “ok” to proceed.

    This was fun too! I relocated the agent terminal to my local terminal window so that I could see all the action happening. Notice here that Antigravity created seed data, a data generation script, and then started up my local PostgreSQL instance. It loaded the data in, and ran a handful of tests. All I did was watch!

    Google Antigravity using terminal commands to test our database solution

    That was it. When the process wrapped up, Antigravity generated a final Walkthrough artifact that explained what it did, and even offered a couple of possible next steps for my data architecture.

    Complete walkthrough of how Google Antigravity built this solution

    Is your mind swirling on use cases right now? Mine still is. Maybe infrastructure-as-code artifact generation based on analyzing your deployed architecture? Maybe create data pipelines or Kubernetes YAML? Use Google Antigravity to build apps, but don’t discount how powerful it is for any software solution.

  • Will Google Antigravity let me implement a terrible app idea?

    Yes, there are such things as stupid questions. No, you can’t do anything you set your mind to. Yes, some ideas are terrible and don’t warrant further attention. That concludes our reality check and pep talk for today.

    But hey, sometimes a bad idea can evolve to a less-bad idea. Do modern agentic coding tools keep us from doing terrible things, or do they simply help us do bad things faster? The answer to both is “sort of.”

    They’re tools. They follow our instructions, and provide moments to pause and reflect. Whether we choose to take those, or ask the right questions, is up to us.

    Let’s see an example. In almost thirty years of coding, I’ve never had as much fun as I’m having now, thanks to Google Antigravity. I can go from idea to implementation quickly, and iterate non-stop on almost any problem. But what if I have a dumb idea? Like an app where I’ll click a button every time I take a breath. Here’s my prompt to Antigravity:

    Let's build a web app where I can track each breath I take. Make a button that I click when I take a breath in, and increment a counter. Call Gemini 3 Flash Preview with a variable holding my API key XXX-XXXXX-XXXXXX-XXXXX-XXXX and return an inspirational quote when I load the app for the first time. Store the hourly results of my breathing stats in an XML file in the app folder.

    There’s probably like eight things wrong with this “app.” The idea is unsustainable, I shouldn’t store API keys as variables, and stashing results in the local file system is silly.

    Does Antigravity stop me? Tell me I’ve been sniffing glue? It does not. But, our first moment of reflection is the resulting Implementation Plan and Task List. Antigravity dutifully sketches out a solution per my instructions, but I have a chance to evaluate what’s about to happen.

    But I’ll stubbornly stay on point. Antigravity shrugs in quiet resignation and builds out my dumb app idea. Within a minute or two, I have the Antigravity-controlled Chrome instance that loads my app. The agent tests it, and proves that I have a Gemini-generated quote, and a way to track every breath I take. Yay?

    My app implements the bad practices I asked for, and uses local XML for persistent storage.

    I could stop here and “ship it.” I could also eat paint chips for lunch. Life is about choices.

    But good product thinking isn’t about solving problems. It’s about finding the right problems. Asking “why”, repeatedly. Thinking through assumptions and considerations and arriving at a well-analyzed place.

    What I should have done after getting my implementation plan from Antigravity is asked Gemini (or literally anyone) whether this was a good idea. Heck, I should I have asked for validation before it even started planning anything. But, we can still do it now. I asked Antigravity “Let’s pause. Is this app even a good idea?”

    Here’s the full answer, which is pretty darn good. It doesn’t call me an idiot outright, but steers me towards areas I should be thinking of instead.


    That is a great question to ask at this stage! Now that the “how” is solved, it’s worth reflecting on the “why.”

    Whether this app is a “good idea” depends entirely on your goal. Here’s a breakdown of how it might be perceived:

    Theoretically: It’s a Mindfulness Powerhouse

    In many meditation and breathwork practices, conscious awareness is the goal. By requiring a manual click for every breath, you are forcing your brain into a state of absolute presence.

    • The Benefit: It prevents “mindless” breathing. You can’t click the button if you’re stuck in a stressful thought loop.
    • The Insight: The hourly XML data could reveal exactly when you’re most “unconscious” or stressed during the day.

    Practically: It’s High-Friction

    From a UX perspective, there are some significant hurdles:

    • The “Click Fatigue”: An average adult takes 12–20 breaths per minute. That’s nearly 1,000 clicks an hour. Even a dedicated monk might find that distracting!
    • Counter-Productivity: The act of tracking might actually disrupt the relaxation you’re trying to achieve. You might find yourself focusing on the button rather than the breath.

    The “Sweet Spot” Strategy

    If you’re feeling skeptical about tracking every breath, you could pivot the app’s purpose slightly without losing the work we’ve done:

    • Focused Sessions: Instead of an “all day” tracker, use it for a “Mindful 5 Minutes” exercise.
    • Stress Intervention: Keep it as a tool to use specifically when you feel anxious—a way to “anchor” yourself back to reality through physical interaction.
    • Automated Evolution: If you love the data but hate the clicking, the next step would be integrating with a wearable (like an Apple Watch) to fuel that XML file automatically.

    Verdict: As a primary way to live your life, it’s probably too much work. But as a training tool or a “digital fidget spinner” for mindfulness, it’s a fascinating experiment in human-computer interaction.

    What do you think? Does the “High-Friction” part make it less appealing, or does that friction actually feel like the “point” of the exercise to you?


    As a next step, I asked Antigravity about continuing down this “mindful breathing” path and what sort of metrics might indicate success. Thinking through metrics is a great way to slow down and consider the measurable value of whatever you’re building.

    This is great. I get different types of metrics to think about, and the answer concludes with ideas of how we might evolve the app to be more useful and practical.

    I prompt Antigravity to take a few of these ideas and update the implementation plan that accounts for these new ideas and project goals.

    I like it. In addition, I want to revisit some of the lazy tech assumptions I made early. My next prompt into Antigravity was this:

    Now let's consider the app we've built so far. Does it follow good practices? Am I doing things securely, and with long-term maintenance in mind? What should we do differently for this to be more production ready?

    I’m finally doing better work, more slowly. Challenging assumptions, and improving the quality attributes of the app. Now my plan factors in putting the Gemini API key in an environment variable, cleaning up project structure, and reconsidering the persistence layer.

    I tell Google Antigravity to go ahead and implement the updated plan. It goes off to improve the quality of the code itself, but also the relevance of the idea. In a minute, I have an updated app that helps me do measured breathing for two minutes at a time.

    It even adds pre-and-post mood checkers that can help determine if this app is making a positive difference.

    Did Google Antigravity prevent me from doing dumb things? No. But I’m not sure that it should. Tools like this (or Conductor in the Gemini CLI) inject an explicit “planning” phase that give me an option to go slow and think through a problem. This should be the time when I validate my thinking, versus outsourcing my thinking to the AI.

    I did like Antigravity’s useful response when we explored our “why” and pressed into the idea of building something genuinely useful. We should always start here. Planning is cheap, implementation is (relatively) expensive.

    These are tools. We should still own the responsibility of using them well!

  • Stop following tutorials and learn by building (with Antigravity) instead

    Don’t get me wrong, I like a good tutorial. Might be in a blog, book, video, or training platform. I’ve probably created a hundred (including in dozens of Pluralsight courses) and consumed a thousand. But lately? I don’t like be constrained by the author’s use case, and I wonder if all I’ve learned how to do is follow someone else’s specific instructions.

    This popped for me twice in the past few days as I finally took some “should try” technologies off my backlog. Instead of hunting for a hello-world tutorial to show me a few attributes of Angular Signals, I simply built a demo app using Google Antigravity. No local infrastructure setup, wrangling with libraries, or figuring out what machinery I needed to actually see the technology in action.

    I did it again a couple of days later! The Go version of the Agent Development Kit came out a bit ago. I’ve been meaning to try it. The walkthrough tutorials are fine, but I wanted something more. So, I just built a working solution right away.

    I still enjoy reading content about how something works. That doesn’t go away. And truly deep learning still requires more than vibe coding an app. But I’m not defaulting to tutorials any more. Instead, I can just feed them into the LLM and build something personalized for me. Here’s an example.

    Take the cases above. I jumped into Google AI Studio to get inspiration on interesting async agent use cases. I liked this one. Create a feed where an agent picks up a news headline and then does some research into related stories before offering some analysis. It’ll read from a queue, and then drop analysis to a Cloud Storage bucket.

    Prompting for agent ideas in Google AI Studio

    With my use case in hand, I jumped into Antigravity to sketch out a design. Notice that I just fed the tutorial link into Antigravity to ensure it’d get seeded with up-to-date info for this new library.

    Sparking an application build in Google Antigravity

    Antigravity started whirring away on creating implementation plans and a task list. Because I can comment on its plans and iterate on the ideas before building begins, I’m not stressed about making the first prompt perfect. Notice here that it flags a big assumption, so I provided a comment confirming that I want a JSON payload for this background worker.

    Google Antigravity provides a way to offer feedback on implementation plans

    After Antigravity started building, I noticed the generated code used a package the IDE flagged as deprecated. I popped into the chat (or I could have commented in the task list) and directed the AI tool to use the latest version and ensure the code still built successfully.

    Steering Google Antigravity to pick a newer version of the library it selected

    Constantly, I’m focused on the outcomes I’m after, not the syntax of agent building. It’s refreshing. When reviewing the code, I started to realize I wanted more data in the incoming payload. A directive later, and my code reflected it.

    Iterating on software with Antigravity

    This started with me wanting to learn ADK for Go. It was easy to review the generated agent code, ask Antigravity questions about it, and see “how” to do it all without typing it all out myself. Will it stick in my brain as much as if I wrote it myself? No. But that wasn’t my goal. I wanted to fit ADK for Go into a real use case.

    Code that sets up an agent in ADK for Go

    This solution should feel “real”, and not just be a vibe-and-go. How about using CI/CD? I never remember the syntax for Google Cloud Build, and getting my pipeline right can swallow up half my dev time. No problem.

    I express my intent for a Cloud Build pipeline, and moments later I have a fully valid YAML definition, along with a generated Dockerfile.

    Antigravity created a Google Cloud Build pipeline for me

    Next I asked Antigravity to add a deployment step so that the container image is pushed to a Cloud Run worker pool after a successful build. I needed to point Antigravity to a tutorial for worker pools for it to know about this new feature.

    I’m using an API key in this solution, and didn’t want that stored as a regular environment variable or visible during deployment. Vibe coding doesn’t have to be insecure. I asked Antigravity to come up with a better way. It chose Google Cloud Secret Manager, gave me the commands to issue, and showed me what the Cloud Run deployment command would now look like.

    Getting a proper, security-conscious deployment command for Cloud Run worker pools

    I then told Antigravity to introduce this updated Cloud Run command to complete the build + deploy pipeline.

    A complete, AI-generated CI/CD pipeline for my agentic app

    Amazing! I wanted to test this out before putting an Angular frontend into the solution. Antigravity reminded my of the right way to format a Cloud Build command given the substitution variables and I was off.

    Building and deploying this agent with Cloud Build

    Within a few minutes, I had a container image in Artifact Registry, and a Cloud Run worker pool listening for work.

    My running workload in Cloud Run worker pools

    To test it out, I needed to publish a message to Google Cloud Pub/Sub. Antigravity gave me a sample JSON message structure that agent expected to receive. I went to Techmeme.com to grab a recent news headline as my source. Pub/Sub has a UI for manually sending a message into a Topic, so I used that.

    Publishing a message to Pub/Sub to trigger my agent

    After a moment, I saw a new JSON doc in my Cloud Storage bucket. Opening it up revealed a set of related news, and some interesting insights.

    News analysis performed by my ADK agent and dropped into a Cloud Storage bucket

    I also wanted to see more of Angular Signals in action, so I started a new project and prompted Antigravity to build out a site where I could submit news stories to my Pub/Sub topic. Once again, I passed in a reference guide into my prompt as context.

    Prompting Antigravity to create a frontend app using Angular Signals

    I asked Antigravity to show me how Angular Signals were used, and even asked it to sketch a diagram of the interaction. This is a much better way to learn a feature than hoping a static tutorial covers everything!

    The first build turned out ok, but I wanted better handling of the calls to Google Cloud Pub/Sub. Specifically, I wanted this executed server side and after adding a comment to the the implementation plan, Antigravity came up with a backend-for-frontend pattern.

    Directing Antigravity to support a backend for calls to Google Cloud

    After a couple of iterations on look-and-feel, and one debugging session which revealed I was using the wrong Pub/Sub topic name, I had a fully working app.

    After starting the server side component and the frontend component, I viewed my app interface.

    The interface Antigravity built

    Grabbing another headline from Techmeme gave me a chance to try this out. Angular Signals seems super smooth.

    Adding a headline and seeing a dynamic frontend in action

    Once again, my Cloud Storage bucket had some related links and analysis generated by ADK agent sitting in Cloud Run worker pools.

    News analysis initiated from my Angular app

    It took my longer to write this post than it did to build a fully working solution. How great is that?

    For me, tutorials are now LLM input only. They’re useful context for LLMs teaching me things or building apps with my direction. How about you?