DEV Community

Evan Lin for Google Developer Experts

Posted on Originally published at evanlin.com on

[AI Hands-on] Refactoring LINE Business Card Assistant: Python to Go with Antigravity Remote Agent, Google ADK, and Firestore -

LINE Bot Namecard: Python (FastAPI) → Go 1.27 + Google ADK + Firestore Architecture

Background

Recently, I've been rewriting several of my daily core services from Python to Go. This time, it was the turn of my LINE bot for organizing business cards: kkdai/linebot-namecard-python.

This bot has evolved through several iterations: from the initial Gemini Vision recognition + vCard QR Code import, moving to Vertex AI to bypass quota limits, introducing Python ADK dynamic closure Tools, all the way to adding Structured Output with disambiguation lists and dual-side business card merging and recognition.

However, for this refactor to the Go version (kkdai/linebot-namecard-go), I wanted to challenge a different workflow: First, hand over the architecture rewrite and unit testing to Google Antigravity's Remote Agent to run automatically in the cloud. Once I returned to my computer, the local agy CLI would take over to deploy to Cloud Run and switch the LINE Webhook.

After a full run-through, the results were impressive—the Remote Agent completed the entire Go 1.27 + google.golang.org/adk + Firestore architecture and tests in the cloud in just 17 minutes and pushed it to GitHub. At the same time, I verified a critical boundary limitation: You can use agy to call a Remote Agent, but you "cannot" switch that same Remote Agent conversation to a local Agent to continue.

The first half of this article explains the usage of Antigravity Remote Agents, the reasons for cloud-local isolation, and two practical solutions. The second half provides a complete breakdown of the system architecture upgrade from linebot-namecard-python to linebot-namecard-go and the actual handoff deployment troubleshooting record.


1. Understanding Antigravity Remote Agents: Handing Heavy Lifting to Cloud Isolated VMs

According to the Google Antigravity official documentation (Remote Agents), the core concept of Remote Agents is to move autonomous development tasks from your local laptop to a Google-managed Cloud Sandbox VM.

This is extremely useful for long-running refactors, large-scale testing, or cross-language rewrites:

  • Asynchronous Offline Execution: Tasks run in a cloud VM. Even if you close your laptop, the computer sleeps, or you lose internet, the Agent continues writing code, running tests, and modifying files.
  • Cross-Device Monitoring and Unlocking: You can assign a task from your desktop or CLI and then head out. Mid-way, you can use a mobile browser to open antigravity.google.com to check progress, approve permissions, or add instructions, eventually receiving a complete GitHub Commit / PR.
  • Remote Resources: Since the Agent runs in a cloud sandbox, file access is handled via three mounting methods:
    1. GitHub Repositories: Automatically cloned and mounted into the remote project environment via OAuth authorization.
    2. Google Drive: Directly read Google Docs, Sheets, Slides, PDFs, and folders within the conversation.
    3. Local File Upload: Upload files directly to the remote project (8 MiB limit per file, max 50 files / 100 MiB per project, 2 GiB total account limit). Files are persistently mounted at /home/antigravity/upload.

Can I call a Remote Agent using the agy CLI?

Yes. According to the Remote Agents official documentation, two conditions must be met:

  • CLI Version: agy CLI v1.4.12 or higher (my local agy is currently 1.5.3).
  • Account Eligibility: Must be logged into a personal Google account with a Google AI Pro or Google AI Ultra subscription (Free version, Google AI Plus, and Google Workspace / Cloud enterprise accounts are not supported).

Common commands are as follows:

# Start a remote conversation
agy --remote-agent

# Specify an existing remote project or create a brand new one
agy --remote-agent --project="Project Name or ID"
agy --remote-agent --new-project

# Continue a previous remote conversation
agy --remote-agent -c
agy --remote-agent --conversation=<conversation_id>
Enter fullscreen mode Exit fullscreen mode

Once in a remote conversation, you can also use /projects to switch remote projects, or attach GitHub repositories, Google Drive files, and upload local files.


2. Key Limitation: Why "Cannot" Switch a Remote Agent Conversation to a Local Agent?

Many developers, when first using --remote-agent, have an intuitive thought: "I'll let the Remote Agent finish writing the code in the cloud, then when I'm back at my computer, I'll switch the same conversation to a local Agent so it can use my local gcloud permissions to deploy it!"

After testing and consulting the official documentation, the conclusion is: The execution environments and conversation records of the two are completely isolated. You cannot switch from cloud to local within the same conversation. There are three hard reasons for this:

  1. Strict Session Mode Isolation: The official documentation explicitly states: a CLI started with --remote-agent only opens remote conversations; a CLI without --remote-agent only opens local conversations. Conversation records are stored in different locations (remote conversations are in the cloud remote project, not on the local disk). Currently, there is no command to migrate or continue an ongoing remote session to a local Agent.
  2. Environment and Filesystem Isolation: Remote Agents run in a Google-managed cloud isolated environment and can only operate on resources mounted to the remote project (GitHub repo, Google Drive, files uploaded to /home/antigravity/upload). They cannot touch your local folders, uncommitted local changes, nor can they use locally installed MCP servers, plugins, skills, or your already logged-in gcloud / kubectl / SSH credentials.
  3. Cannot Transfer via /fork: In local conversations, we can use /fork <project_id> to branch a conversation to another project. However, /fork is currently on the list of unsupported features for Remote Agents (which also includes /diff, /rewind, /btw, --print, and ! for executing local commands). Therefore, you cannot fork an ongoing remote conversation back to a local project.

Two Practical Alternatives

Since you cannot directly switch the same conversation, you can choose one of the following two paths based on your workflow:

Comparison Item Option A: Remote Agent + GitHub Handoff (Used in this post) Option B: Remote Control
Startup Method agy --remote-agent (or antigravity.google.com) agy --remote-control / /remote-control in chat / agy remote-control start
Agent Execution Location Google Cloud Isolated VM Your own local computer
Can the computer be off/sleeping? Yes (Continues in the cloud) No (Host must remain on and connected)
Access local files / gcloud / MCP? No (Remote mounted resources only) Yes (Native use of local toolchain and credentials)
Can continue same chat at computer? No (Must open new local agy chat and git pull) Yes (Remote control via phone/browser is the local chat)
Best Scenario Out and about / Before bed, offload heavy refactoring to cloud; push to GitHub when done Away from desk but want to remotely trigger local tests/deployments, or need local secret env vars/permissions
  • Want to "Go offline/shutdown, let cloud run first, then resume locally" → Handoff code via GitHub: Bind a GitHub repository to the remote project. Have the Remote Agent commit/push changes and a handoff guide (e.g., a HANDOFF.md) to a remote branch or open a PR. Once back at your computer, git clone / git pull that branch locally, then open a new local agy conversation (without --remote-agent) to take over deployment.
  • Want to "Control from outside, return to computer and seamlessly continue same environment/chat" → Use Remote Control: Run the local Agent from the start and use agy --remote-control, enter /remote-control in the chat, or use agy remote-control start as a background service. This way, the Agent always runs on your own computer, and you can monitor/command it via phone or browser (antigravity.google.com), continuing seamlessly in the same local conversation when you return.

For this linebot-namecard-go refactor, I chose Option A: Let the cloud Remote Agent focus on writing the entire Go project and handoff documentation to GitHub, then use the local agy CLI to complete the Cloud Run deployment and LINE Webhook switch.


3. Architecture Comparison: linebot-namecard-python vs. linebot-namecard-go

Before diving into implementation details, let's look at the architectural differences between the Python + FastAPI version and the Go 1.27 + Google ADK + Cloud Firestore version:

Comparison Item Original Python Version (linebot-namecard-python) New Go Version (linebot-namecard-go)
Language & Web Framework Python + FastAPI + AsyncLineBotApi (aiohttp) Go 1.27.3 (cmd/server) + Standard net/http + line-bot-sdk-go/v8
Container Base Image Python Image (includes Pillow, etc.) Multi-stage golang:1.27-bookworm → gcr.io/distroless/static-debian12
ADK Agent & Runner Lifecycle Every message received dynamically recreates closures, Agent, and Runner in handle_smart_query Singleton resident at startup shared llmagent.New + runner.New, with 5 strongly-typed functiontools
ADK Session & Memory Every request news a temporary InMemorySessionService() (Forgotten after request, no cross-chat memory) Dual-layer common memory + Cloud Firestore persistence: FirestoreSessionService (adk_sessions, supports cold start Hydrate) + FirestoreMemoryService (adk_memories)
Multi-step Interaction State (UserState) Global Python dict (user_states = {}), lost immediately on container restart or multi-instance UserStateStore (sync.RWMutex memory cache + Firestore user_states collection + 300s TTL auto-cleanup SweepExpiredStates)
Business Card DB Strategy Supports Firebase Realtime Database only (namecard/{user_id}) Dual Database Compatibility (CardRepository): Supports read/write for both existing Firebase RTDB and Cloud Firestore (namecards/{user_id}/cards/{card_id}), zero-migration compatibility
Gemini Vision OCR google-genai (Python) + NAMECARD_SCHEMA + Pillow google.golang.org/genai (BackendVertexAI, gemini-3-flash-preview) + NamecardSchema() + Go standard image/jpeg
Deployment & Rollback Manual gcloud run deploy, manual Webhook URL update One-click script scripts/deploy_and_switch_webhook.sh auto-copies env vars, switches and tests LINE Webhook, supports --rollback

4. First Leg (Cloud Remote Agent): Rewrite and Handoff Design Completed in 17 Minutes

Looking at the GitHub Commit history for this cloud Remote Agent session, the pace was nothing short of "aggressive":

  • 16:21: Created empty kkdai/linebot-namecard-go (Initial commit)
  • 16:38: In just 17 minutes, submitted 22 files, 4,432 lines of Go code, complete unit tests, ROADMAP.md, and Dockerfile
  • 16:45: Added deployment and LINE Webhook switching script scripts/deploy_and_switch_webhook.sh
  • 16:56: Wrote the HANDOFF.md specifically for the local agy CLI to take over.

Beyond a 100% feature port, the Remote Agent helped fix three architectural debts left over from the Python version:

1. Ending Python's "Amnesia per Message": FirestoreSessionService + FirestoreMemoryService

Looking back at the old Python app/line_handlers.py, the ADK integration was written like this to get it working quickly:

# ❌ Old Python way: Recreates a brand new InMemorySessionService every time a message is received
runner = Runner(
    app_name="namecard_bot_app",
    agent=agent,
    session_service=InMemorySessionService()
)
Enter fullscreen mode Exit fullscreen mode

This led to a glaring issue: When a user asked "Help me find John Doe" and followed up with "What is his phone number?", the Agent had no memory of who was being discussed in the previous turn (because the InMemorySessionService was garbage collected at the end of the previous request).

In the Go version internal/memory/memory.go, we implemented the official google.golang.org/adk/session.Service and memory.Service interfaces, creating a dual-layer common memory + Cloud Firestore persistence architecture:

  • Hot Path Memory R/W: Normal operations hit session.InMemoryService() directly, maintaining millisecond latency.
  • Sync Changes to Firestore: After every Create or AppendEvent, conversation events and State are synced to the adk_sessions collection. At the end of each turn, FirestoreMemoryService syncs summaries to adk_memories.
  • Cold Start Hydration: When Cloud Run scales to 0 and cold starts with empty memory, any Get miss in memory automatically triggers hydrateFromFirestoreIfNeeded to pull history and State from Firestore, enabling continuous follow-up questions across sessions and cold starts.
  • Test-Friendly Design: When DocumentStore is nil (e.g., running go test locally without cloud connection), it automatically runs in pure memory mode, allowing full process testing without Mock Servers.
// internal/memory/memory.go: Automatically restore ADK Session from Firestore on cold start
func (s *FirestoreSessionService) Get(ctx context.Context, req *session.GetRequest) (*session.GetResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    resp, err := s.inMemory.Get(ctx, req)
    if err == nil {
        return resp, nil
    }
    // Memory miss (e.g., Cloud Run cold start), try to restore from Firestore adk_sessions
    s.hydrateFromFirestoreIfNeeded(ctx, req.AppName, req.UserID, req.SessionID)
    return s.inMemory.Get(ctx, req)
}
Enter fullscreen mode Exit fullscreen mode

2. Interaction State Machine UserStateStore: Memory Cache + Firestore + 300s TTL Cleanup

The business card bot has many "cross-turn states," such as:

  • Waiting for confirmation after sending a front-side photo: "Does this card have a back side?" (pending_backside_confirm) and waiting for the back-side upload (awaiting_backside_image, containing FrontImageBytes).
  • Preparing to enter a memo (adding_memo) or edit a specific field (editing_field) after a button click.
  • Safety confirmation before an AI tool triggers an update (pending_update).

In the Python version, these were all stuffed into a global user_states = {} dictionary. The Go version UserStateStore uses a sync.RWMutex protected memory cache paired with a Firestore user_states collection. It triggers SweepExpiredStates on every Webhook entry to automatically clear front-side image buffers that haven't been completed within 300 seconds.

3. Upgrading from "Closure per Request" to "Resident Singleton ADK Runner + 5 Strongly-Typed functiontools"

To isolate business card data for different LINE users, the Python version had to call make_adk_tools(user_id, found_card_ids) every time a text message was received to generate 5 closure functions to wrap into a new Agent.

In the Go version internal/agent/agent.go, we create one set of resident shared llmagent.New (gemini-3-flash-preview) and runner.New at startup in main.go, registering 5 strongly-typed functiontools:

  • get_all_namecards
  • get_namecard_by_id
  • display_namecard
  • update_namecard_memo
  • update_namecard_field

When tools execute, they use tctx.UserID() from the ADK-provided adkagent.ToolContext to isolate user data. It also automatically triggers FallbackKeywordSearch for local keyword backup search if Vertex AI or the network fails.

4. Dual DB Compatibility CardRepository + Forced GOOGLE_CLOUD_LOCATION=global

To ensure existing users' business cards don't disappear during the switch, the StoreRepository in internal/storage/storage.go supports both Firebase Realtime Database (namecard/{user_id}) and Cloud Firestore (namecards/{user_id}/cards/{card_id}):

  • Queries prioritize compatibility with existing Firebase RTDB data while supporting Firestore reads. New entries and updates are synced to both.
  • In internal/config/config.go, the first line of Load() explicitly sets _ = os.Setenv("GOOGLE_CLOUD_LOCATION", "global") to prevent regional variables copied from old services from overriding the global endpoint required by gemini-3-flash-preview.

5. The Essence of Cloud-Local Handoff: Remote Agent Writing a "Copy-Paste Handoff Document"

Because the Remote Agent is well aware it is in a cloud sandbox without gcloud or LINE API permissions, it proactively produced two key handoff items in its final commit:

  1. scripts/deploy_and_switch_webhook.sh: Automates extracting env vars from the old service, deploying to Cloud Run, checking GET / Health Check, and calling the LINE Messaging API to update and test the Webhook (with a --rollback parameter).
  2. HANDOFF.md: Organized progress, GCP permissions to verify, and a startup Prompt that can be directly copied and pasted into the local agy CLI.

5. Second Leg (Local agy CLI): Handling the Handoff, Troubleshooting, and Zero-Downtime Launch

Back at my computer, I opened the local agy CLI (without --remote-agent, using local Agent mode) and pasted the handoff Prompt from HANDOFF.md. The local Agent took over to complete verification, deployment, and switching. During this process, it caught several "details only visible when looking at the real GCP environment locally":

Detail 1: The old service didn't use GOOGLE_APPLICATION_CREDENTIALS_JSON, but relied on ADC

The handoff document originally assumed we needed to extract GOOGLE_APPLICATION_CREDENTIALS_JSON from the old service. However, after the local agy used gcloud run services describe <OLD_CLOUD_RUN_SERVICE> to inspect the live settings, it found the env var list looked like this:

  • ChannelAccessToken, ChannelSecret
  • FIREBASE_URL, FIREBASE_STORAGE_BUCKET
  • PROJECT_ID: <YOUR_GCP_PROJECT_ID>, LOCATION: global
  • GOOGLE_CLOUD_PROJECT: <YOUR_GCP_PROJECT_ID>, GOOGLE_CLOUD_LOCATION: <YOUR_GCP_REGION>, GOOGLE_GENAI_USE_VERTEXAI: True

The old service didn't have a JSON Key; it used the Cloud Run service account <PROJECT_NUMBER>-compute@developer.gserviceaccount.com via Application Default Credentials (ADC)! We checked the IAM roles for that service account and confirmed it already had roles/datastore.user, roles/aiplatform.user, roles/firebase.admin, and roles/storage.admin. Furthermore, the (default) Firestore (Native mode, in the same region) was already established.

Also, note that the old service env vars included GOOGLE_CLOUD_LOCATION: <YOUR_GCP_REGION>. This could easily cause gemini-3-flash-preview to fail to find the model in other unhandled code. Fortunately, the Go version's config.Load() forces GOOGLE_CLOUD_LOCATION to global at startup, perfectly avoiding this potential trap!

Detail 2: macOS mktemp Suffix Trap & Old Webhook Hidden Behind Firebase Hosting

Before running the deployment script, two things were checked:

  1. macOS BSD mktemp Behavior Difference: Line 46 of the script used mktemp /tmp/cloudrun_env.XXXXXX.yaml. While this works with Linux GNU mktemp, in macOS BSD mktemp, if XXXXXX is followed by a .yaml extension, it won't be replaced by random characters. It would consistently produce /tmp/cloudrun_env.XXXXXX.yaml. Although the script has trap 'rm -f "${ENV_FILE}"' EXIT to clean up, it's better to use suffix-less mktemp /tmp/cloudrun_env.XXXXXX or Python's tempfile.NamedTemporaryFile for cross-platform scripts.
  2. Check the actual LINE Webhook Endpoint before switching: A GET https://api.line.me/v2/bot/channel/webhook/endpoint revealed that the original Webhook didn't point directly to the old Python Cloud Run .run.app URL. Instead, it pointed to https://<YOUR_FIREBASE_APP>.firebaseapp.com (rewritten via Firebase Hosting to the Python Cloud Run), with a status of "active": true. Knowing the original state helps if a recovery is needed (while the script's --rollback would switch back to the direct URL of the old Python Cloud Run).

Detail 3: One-click Cloud Build Deployment of golang:1.27-bookworm and Webhook Switch

After running ./scripts/deploy_and_switch_webhook.sh locally:

  1. Env Var Export: Automatically exported all settings from the old Python Cloud Run service to a temporary YAML.
  2. Cloud Build Compilation & Deployment: FROM golang:1.27-bookworm AS builder in the Dockerfile successfully pulled and statically compiled (CGO_ENABLED=0 GOOS=linux) into gcr.io/distroless/static-debian12. The new Go Cloud Run service was successfully created (with 100% traffic shifted to the new revision).
  3. Health Check & LINE API Verification:
    • GET https://<YOUR_CLOUD_RUN_SERVICE_URL>/ → {"status":"ok"}
    • PUT /v2/bot/channel/webhook/endpoint → {}
    • POST /v2/bot/channel/webhook/test → {"success":true,"statusCode":200,"reason":"OK","detail":"200"}

Finally, checking gcloud run services logs read and gcloud logging read:

2026-11-08T09:08:26.864683Z 2026/11/08 09:08:26 Cloud Firestore & Firebase Admin SDK initialized successfully.
2026-11-08T09:08:26.868408Z 2026/11/08 09:08:26 Starting LINE Bot Namecard Go + ADK server on port 8080...
2026-11-08T09:08:26.879115Z Default STARTUP TCP probe succeeded after 1 attempt for container "<NEW_CLOUD_RUN_SERVICE>-1" on port 8080.
2026-11-08T09:08:29.410974Z INFO GET 200
2026-11-08T09:08:30.147730Z INFO POST 200
Enter fullscreen mode Exit fullscreen mode

Everything went smoothly with zero errors! Now, sending messages or business card photos in the LINE chat hits the Go + ADK + Firestore bot.


6. Remote Agent Cloud-to-Local Handoff & Painless Migration Checklist

If you also want to use Antigravity Remote Agent + local agy CLI to refactor and switch live services, follow this checklist to avoid common pitfalls:

  • [ ] 1. Choose the right remote work mode: Use agy --remote-agent if you want to shut down your computer and let the AI refactor and PR on its own (requires Google AI Pro/Ultra personal account, agy >= 1.4.12). Use agy --remote-control if you want to remotely trigger gcloud deployments from your phone or continue the same conversation.
  • [ ] 2. Don't expect to switch the same conversation directly: Remote and local Agents are isolated in terms of chat history, filesystems, and toolchains. Remote Agents don't support /fork, /diff, /rewind, /btw, --print, or ! local commands. Treat "GitHub + HANDOFF.md" as your standard handoff interface from the start.
  • [ ] 3. Have the Remote Agent produce an actionable handoff Prompt and deployment script: When wrapping up in the cloud, ask it to include test commands, environment requirements, a one-click deployment script (with --rollback), and a "Prompt for the next local Agent" in HANDOFF.md.
  • [ ] 4. Verify IAM, Firestore, and regional variables after taking over: Don't blindly assume the old service uses GOOGLE_APPLICATION_CREDENTIALS_JSON. Use gcloud run services describe and gcloud projects get-iam-policy to confirm if the default service account has roles/datastore.user and roles/aiplatform.user. If using preview models like gemini-3-flash-preview, ensure the code handles GOOGLE_CLOUD_LOCATION=global.
  • [ ] 5. Watch for cross-platform script issues and original Webhook paths: Note that macOS BSD mktemp doesn't support randomizing template suffixes. Before switching the LINE Webhook, call GET /v2/bot/channel/webhook/endpoint to record the original endpoint (check if it goes through Firebase Hosting or API Gateway) to ensure you have a baseline for verification and rollback.

The complete Go code, dual-layer common memory implementation, and deployment scripts are open-sourced on GitHub: kkdai/linebot-namecard-go. The original Python version is also preserved at kkdai/linebot-namecard-python for architectural comparison. Feel free to reference and discuss!

Top comments (0)