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.comto 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:
- GitHub Repositories: Automatically cloned and mounted into the remote project environment via OAuth authorization.
- Google Drive: Directly read Google Docs, Sheets, Slides, PDFs, and folders within the conversation.
- 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:
agyCLI v1.4.12 or higher (my localagyis currently1.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> 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:
- Strict Session Mode Isolation: The official documentation explicitly states: a CLI started with
--remote-agentonly opens remote conversations; a CLI without--remote-agentonly 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. - 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-ingcloud/kubectl/ SSH credentials. - Cannot Transfer via
/fork: In local conversations, we can use/fork <project_id>to branch a conversation to another project. However,/forkis 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 pullthat branch locally, then open a new localagyconversation (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-controlin the chat, or useagy remote-control startas 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 emptykkdai/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, andDockerfile -
16:45: Added deployment and LINE Webhook switching scriptscripts/deploy_and_switch_webhook.sh -
16:56: Wrote theHANDOFF.mdspecifically for the localagyCLI 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() ) 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
CreateorAppendEvent, conversation events and State are synced to theadk_sessionscollection. At the end of each turn,FirestoreMemoryServicesyncs summaries toadk_memories. - Cold Start Hydration: When Cloud Run scales to 0 and cold starts with empty memory, any
Getmiss in memory automatically triggershydrateFromFirestoreIfNeededto pull history and State from Firestore, enabling continuous follow-up questions across sessions and cold starts. - Test-Friendly Design: When
DocumentStoreisnil(e.g., runninggo testlocally 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) } 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, containingFrontImageBytes). - 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_namecardsget_namecard_by_iddisplay_namecardupdate_namecard_memoupdate_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 ofLoad()explicitly sets_ = os.Setenv("GOOGLE_CLOUD_LOCATION", "global")to prevent regional variables copied from old services from overriding theglobalendpoint required bygemini-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:
-
scripts/deploy_and_switch_webhook.sh: Automates extracting env vars from the old service, deploying to Cloud Run, checkingGET /Health Check, and calling the LINE Messaging API to update and test the Webhook (with a--rollbackparameter). -
HANDOFF.md: Organized progress, GCP permissions to verify, and a startup Prompt that can be directly copied and pasted into the localagyCLI.
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:
- macOS BSD
mktempBehavior Difference: Line 46 of the script usedmktemp /tmp/cloudrun_env.XXXXXX.yaml. While this works with Linux GNUmktemp, in macOS BSDmktemp, ifXXXXXXis followed by a.yamlextension, it won't be replaced by random characters. It would consistently produce/tmp/cloudrun_env.XXXXXX.yaml. Although the script hastrap 'rm -f "${ENV_FILE}"' EXITto clean up, it's better to use suffix-lessmktemp /tmp/cloudrun_env.XXXXXXor Python'stempfile.NamedTemporaryFilefor cross-platform scripts. - Check the actual LINE Webhook Endpoint before switching: A
GET https://api.line.me/v2/bot/channel/webhook/endpointrevealed that the original Webhook didn't point directly to the old Python Cloud Run.run.appURL. Instead, it pointed tohttps://<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--rollbackwould 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:
- Env Var Export: Automatically exported all settings from the old Python Cloud Run service to a temporary YAML.
- Cloud Build Compilation & Deployment:
FROM golang:1.27-bookworm AS builderin theDockerfilesuccessfully pulled and statically compiled (CGO_ENABLED=0 GOOS=linux) intogcr.io/distroless/static-debian12. The new Go Cloud Run service was successfully created (with100%traffic shifted to the new revision). - 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 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-agentif 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). Useagy --remote-controlif you want to remotely triggergclouddeployments 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" inHANDOFF.md. - [ ] 4. Verify IAM, Firestore, and regional variables after taking over: Don't blindly assume the old service uses
GOOGLE_APPLICATION_CREDENTIALS_JSON. Usegcloud run services describeandgcloud projects get-iam-policyto confirm if the default service account hasroles/datastore.userandroles/aiplatform.user. If using preview models likegemini-3-flash-preview, ensure the code handlesGOOGLE_CLOUD_LOCATION=global. - [ ] 5. Watch for cross-platform script issues and original Webhook paths: Note that macOS BSD
mktempdoesn't support randomizing template suffixes. Before switching the LINE Webhook, callGET /v2/bot/channel/webhook/endpointto 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!
![[AI Hands-on] Refactoring LINE Business Card Assistant: Python to Go with Antigravity Remote Agent, Google ADK, and Firestore -](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fope9m0q6bm1e1fdd8hlw.jpg)

