Recap
In the first three articles of the "Agent Platform in Action" series, we step-by-step pushed the LINE AI Business Card Assistant (line-card-assistant) from a conceptual prototype to a cloud-native architecture:
- [Agent Platform in Action (1)] Building a LINE AI Business Card Assistant with Go and Google ADK 2.0 Graph Workflow: We used
google.golang.org/adk/v2/workflowto build a 12-node business card processing graph (Graph Workflow), covering Vision OCR,AddFanOut/AddFanInparallel background investigations, and the HITL (Human-in-the-Loop) pause and modification loop ofNewRequestInputEvent. - [Agent Platform in Action (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime: Refactoring LINE AI Business Card Assistant with agents-cli and ADK Go: Completed Phase 1 (PR #10), introducing the
agents-cli(adk_go) standard dual-channel architecture, allowing the project to support LINE Webhook, ADK WebUI Playground, and the A2A protocol simultaneously. - [Agent Platform in Action (3)] Solving HITL Amnesia in Stateless Containers: Implementing Cross-Instance Business Card Recognition and Pause/Resume with ADK 2.0 GCS Artifact Service and Agent Platform Sessions: Completed the first half of Phase 2 (PR #11), using
artifact/gcsartifactto move multi-MB business card photos out ofSession.State, and combiningsession/vertexaito implementresolvePendingHITL, allowing stateless containers to seamlessly continue HITL modification and saving even if scaled to 0 or instances are switched.
In today's fourth episode of the series, we will complete the final piece of Phase 2 (Issue #5) and the Phase 3 core architecture upgrade (Issue #6) (corresponding to PR #12):
- Cloud Business Card Database Persistence (
PostgresStore/ Cloud SQL): Officially upgrading the business card library, which was originally stored in local memory or container temporary files, to a relational and JSON hybrid storage supporting Cloud SQL PostgreSQL, and enablingHybridSearchand token-scoring fault-tolerantBroadSearch. - Two-Tier Decoupled Call Architecture (Cloud Run LINE Webhook Gateway $\rightarrow$ Agent Runtime): Completely decoupling the lightweight edge layer (Edge Layer) responsible for "LINE verification and image downloading" from the execution layer (Execution Layer) responsible for "ADK 2.0 Graph Workflow multimodal inference and search"!
1. Why Do We Need the "Cloud Persistence Iron Triangle" and "Two-Tier Decoupled Architecture"?
When we deploy line-card-assistant to a Google Cloud production environment, a careful examination of the responsibilities of every piece of data and component in the system reveals two key problems that must be solved:
1. The Last Remaining "Local State": CardStore Business Card Database
In the previous post (PR #11), we already placed the "original business card photos" in Google Cloud Storage (GCSArtifactService) and the "workflow execution state and HITL breakpoints" in Vertex AI / Cloud SQL (SessionService). However, when a user clicks "✅ Confirm Save" in LINE and reaches SaveCardNode, if the final confirmed business card data (models.BusinessCard) is still written to local memory MemoryStore or a local SQLite file inside the container:
- Cleared on container restart or Scale-to-Zero: Once the Agent Runtime instance scales to 0, the hard-earned network business card library will evaporate instantly.
- Multiple instances cannot share search results: When a user saves a business card on Instance A, and a search request is routed to Instance B a second later,
HybridSearchNodewon't find the card just saved in Instance B's memory!
Therefore, we must complete the final corner of the "Cloud-Native Persistence Triad" — Cloud SQL PostgreSQL (PostgresStore):
| Persistence Dimension | Content Responsible for Storing | Cloud Backend Service | ADK / Go Implementation Package |
|---|---|---|---|
| 1. Binary Large Files (Artifacts) | Original business card photos (business_card.jpg v1, v2…) | Google Cloud Storage (GCS) | sessions.NewArtifactService (artifact/gcsartifact) |
| 2. Conversation and Graph Breakpoints (Sessions) | Graph Checkpoints, active_interrupt_id, draft_card | Vertex AI Agent Engine / Cloud SQL | sessions.NewService (session/vertexai, session/database) |
| 3. Structured Business Data (Domain DB) | Verified BusinessCard official records, JSON industry tags, hybrid retrieval | Cloud SQL for PostgreSQL | store.NewStore (internal/store/postgres.go) |
2. Why Decouple LINE Webhook Gateway and Agent Runtime into a Two-Tier Architecture?
In monolithic (All-in-One) mode, cmd/server receives LINE Webhooks, runs Vision OCR, and executes three parallel Grounding search nodes in the same process. However, in an enterprise-grade Google Cloud Agent Platform architecture, splitting the system into "Cloud Run LINE Webhook Gateway (Edge Layer)" and "Google Cloud Agent Runtime (Agent Execution Layer)" offers three major advantages:
- Isolation of Public Webhook Verification and Internal IAM Permissions:
- LINE Messaging API requires a public HTTPS Endpoint (
POST /webhook) to push events, usesLINE_CHANNEL_SECRETto verify theX-Line-Signature, and must immediately return200 OKwithin 1-2 seconds. - Meanwhile, the actual Agent Runtime only needs internal authorization via Google Cloud IAM / Application Default Credentials (ADC), without needing exposure to unauthenticated public traffic or holding the LINE Channel Secret.
- LINE Messaging API requires a public HTTPS Endpoint (
- Multiple Channels Sharing the Same Session-backed Remote Agent:
- After decoupling, whether a user sends a business card photo from LINE Bot, queries contacts from internal Gemini Enterprise (A2A), or a developer tests from ADK WebUI, they all call the same
app.NewRootAgenthosted on the Agent Runtime!
- After decoupling, whether a user sends a business card photo from LINE Bot, queries contacts from internal Gemini Enterprise (A2A), or a developer tests from ADK WebUI, they all call the same
- Extreme Deployment Flexibility (One-click switch between All-in-One vs. Decoupled):
- During local development or small projects, if
AGENT_RUNTIME_URLis not set,cmd/serverautomatically runs in All-in-One monolithic mode. - In production, simply setting
AGENT_RUNTIME_URL=https://...in Cloud Run automatically switchescmd/serverto a lightweight stateless LINE Webhook Gateway!
- During local development or small projects, if
Let's look at the cross-layer interaction under the two-tier decoupled architecture with a complete system sequence diagram:
sequenceDiagram autonumber actor User as LINE User participant GW as Cloud Run<br/>LINE Webhook Gateway<br/>(cmd/server + gateway.Client) participant AR as Google Cloud Agent Runtime<br/>(adkrest.Server / app.NewRootAgent) participant GCS as Cloud Storage<br/>(GCSArtifactService) participant Sess as Vertex AI / Cloud SQL<br/>(SessionService) participant PG as Cloud SQL PostgreSQL<br/>(PostgresStore) User->>GW: 1. Send business card photo (POST /webhook) GW-->>User: 2. Verify X-Line-Signature and immediately return HTTP 200 OK GW->>GW: 3. Download image from LINE Blob API []byte GW->>AR: 4. POST /api/apps/app/users/{user}/sessions/{id}<br/>+ POST /api/run (With Google Cloud ADC ID Token and image InlineData) AR->>GCS: 5. RouterNode automatically saves image to GCS Artifact ("business_card.jpg" v1) AR->>AR: 6. VisionOCRNode -> AddFanOut (Deduplication / Google Search company background / Conversation context) -> BuildPreviewNode AR->>Sess: 7. HITLConfirmNode triggers NewRequestInputEvent<br/>Persist active_interrupt_id="hitl_confirm_1" AR-->>GW: 8. Return Events and Session State (including flex_preview_json) GW-->>User: 9. Push LINE Flex Message business card preview card (⏸️ Waiting for user confirmation) Note over GW,AR: ☁️ Gateway is completely stateless; the next request can land on any Gateway instance User->>GW: 10. Input modification: "Change title to CTO" or click "✅ Confirm Save" GW->>AR: 11. HasPendingHITL -> GET /api/apps/app/users/{user}/sessions<br/>Automatically retrieve remote paused {SessionID, active_interrupt_id} GW->>AR: 12. POST /api/run (Send adk_request_input FunctionResponse) AR->>PG: 13. If confirming save: SaveCardNode calls PostgresStore.Save() Upsert to write to Cloud SQL AR->>Sess: 14. Clear active_interrupt_id="" and record saved_card_id AR-->>GW: 15. Return save success and AI icebreaker follow-up draft GW-->>User: 16. Push save confirmation and icebreaker to user! 2. Implementation Breakdown (1): Building the Cloud SQL PostgreSQL Business Card Library (internal/store/postgres.go) — Issue #5
In our project, since sessions/sessions.go already introduced gorm.io/gorm and gorm.io/driver/postgres to support SESSION_TYPE=cloud_sql, we can directly use GORM to build a production-grade PostgresStore for CardStore without extra dependency bloat!
1. Extending the store.CardStore Interface: Native BroadSearch Support for All Backends
While checking the original internal/workflow/nodes_search.go, we found a type assertion for *store.MemoryStore in BroadSearchNode:
// ❌ Old way: Only MemoryStore supports BroadSearch; other Stores fall back to ListByUser if memStore, ok := cardStore.(*store.MemoryStore); ok { cards, err = memStore.BroadSearch(ctx, userID, keywords) } else { cards, err = cardStore.ListByUser(ctx, userID) } To ensure PostgresStore and MemoryStore share a consistent hybrid retrieval and fuzzy fallback experience, we officially included BroadSearch in the CardStore standard interface in internal/store/store.go:
// CardStore defines the persistence and search interface for business cards. type CardStore interface { Save(ctx context.Context, card *models.BusinessCard) error GetByID(ctx context.Context, id string) (*models.BusinessCard, error) CheckDuplicate(ctx context.Context, userID, name, company, email string) (*models.BusinessCard, bool, error) HybridSearch(ctx context.Context, filter models.SearchFilter) ([]*models.BusinessCard, error) BroadSearch(ctx context.Context, userID string, keywords []string) ([]*models.BusinessCard, error) ListByUser(ctx context.Context, userID string) ([]*models.BusinessCard, error) } This way, BroadSearchNode only needs to cleanly call one line: cardStore.BroadSearch(ctx, userID, keywords)!
2. Designing GORM Schema and Upsert Mechanism (BusinessCardRecord)
In internal/store/postgres.go, we defined BusinessCardRecord corresponding to the PostgreSQL business_cards table, added indexes to common query fields (user_id, name, company, email, met_date, updated_at), and stored Tags []string as a JSON string in tags_json:
// BusinessCardRecord is the GORM database model for persisting a models.BusinessCard // in PostgreSQL / Cloud SQL. type BusinessCardRecord struct { ID string `gorm:"primaryKey;size:128"` UserID string `gorm:"index;size:128;not null"` Name string `gorm:"index;size:255"` Title string `gorm:"size:255"` Company string `gorm:"index;size:255"` Email string `gorm:"index;size:255"` Phone string `gorm:"size:64"` Address string `gorm:"type:text"` TaxID string `gorm:"size:32"` Website string `gorm:"size:512"` RawOCR string `gorm:"type:text"` CompanySummary string `gorm:"type:text"` CompanyInfo string `gorm:"type:text"` TagsJSON string `gorm:"column:tags_json;type:text"` MetContext string `gorm:"type:text"` RawImageURL string `gorm:"type:text"` MetDate time.Time `gorm:"index"` CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"` } When a user takes a photo of an existing contact's business card (e.g., the person was promoted from "Senior Engineer" to "VP of Engineering", and CleanAndDedupNode identifies the duplicate and reuses the original card.ID), PostgresStore.Save performs an atomic Upsert using PostgreSQL's native ON CONFLICT (id) DO UPDATE (clause.OnConflict{UpdateAll: true}):
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column, UpdateAll: true, }).Create(rec).Error; err != nil { return fmt.Errorf("upsert business card %q: %w", cp.ID, err) } 3. The store.NewStore(ctx) Factory: Auto-sensing Cloud SQL Unix Sockets and Environment Variables
When connecting to Cloud SQL from Cloud Run or Agent Runtime, the official recommendation is to use the Unix Domain Socket (/cloudsql/<PROJECT>:<REGION>:<INSTANCE>) established by the Cloud SQL Auth Proxy; locally or on GKE, DATABASE_URL or DB_HOST are common. We support all three modes in BuildPostgresDSNFromEnv() and store.NewStore(ctx), and automatically fall back seamlessly to MemoryStore preloaded with 4 Taiwan sample business cards when no database environment variables are set:
// BuildPostgresDSNFromEnv resolves a PostgreSQL / Cloud SQL connection string from environment variables. // Priority: // 1. CARD_STORE_DSN / DATABASE_URL / POSTGRES_DSN // 2. Cloud SQL Unix socket when INSTANCE_CONNECTION_NAME + DB_PASS are set // 3. Standard TCP host when DB_HOST + DB_PASS are set func BuildPostgresDSNFromEnv() string { for _, key := range []string{"CARD_STORE_DSN", "DATABASE_URL", "POSTGRES_DSN"} { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } } dbUser := envOrDefault("DB_USER", "postgres") dbName := envOrDefault("DB_NAME", "postgres") dbPass := strings.TrimSpace(os.Getenv("DB_PASS")) instanceConn := strings.TrimSpace(os.Getenv("INSTANCE_CONNECTION_NAME")) if instanceConn != "" && dbPass != "" { return fmt.Sprintf( "host=/cloudsql/%s user=%s password=%s dbname=%s sslmode=disable", instanceConn, dbUser, dbPass, dbName, ) } // ... } This way, as long as your environment has INSTANCE_CONNECTION_NAME and DB_PASS set for SESSION_TYPE=cloud_sql, SessionService and CardStore can share the same Cloud SQL PostgreSQL instance, without spending an extra penny on database costs!
3. Implementation Breakdown (2): Building a Stateless LINE Webhook Gateway and Remote AgentRuntimeClient (internal/gateway/client.go) — Issue #6
Next is the core engineering of Phase 3: How to make the cmd/server LINE Bot Webhook Handler forward all business card recognition, HITL recovery, search, and icebreaker requests to a remote Agent Runtime via HTTP instead of running workflow.Engine locally?
Remember how we had the foresight to separate the Webhook Handler and Workflow Engine with a linebot.WorkflowRunner interface in the first version of internal/linebot/handler.go?
// internal/linebot/handler.go type WorkflowRunner interface { HasPendingHITL(userID string) bool RecordUserMessage(userID, text string) StartIngest(ctx context.Context, userID string, imageBytes []byte, recentMessages []string) (*WorkflowResponse, error) ResumeHITL(ctx context.Context, userID string, userReply string) (*WorkflowResponse, error) RunSearch(ctx context.Context, userID string, query string) (*WorkflowResponse, error) RunEmailDraft(ctx context.Context, userID string, cardID string) (*WorkflowResponse, error) } Because of this clean interface, we don't need to change a single line of code in linebot.Handler! We just need to implement a gateway.Client that satisfies the linebot.WorkflowRunner interface in internal/gateway/client.go!
1. gateway.NewClient Supporting Automatic Google Cloud ADC Token Issuance
When the LINE Webhook Gateway on Cloud Run needs to call an IAM-protected remote Agent Runtime (or another ADK REST Server on an internal Cloud Run), gateway.NewClient automatically obtains the current Service Account's Google Cloud ADC credentials via google.golang.org/api/idtoken (or golang.org/x/oauth2/google) and attaches them to the Authorization: Bearer <token> header of every HTTP request:
func NewClient(ctx context.Context, baseURL string, useADC bool, opts ...ClientOption) (*Client, error) { trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") if trimmed == "" { return nil, fmt.Errorf("agent runtime baseURL cannot be empty") } if !strings.HasSuffix(trimmed, "/api") { trimmed += "/api" } hc := &http.Client{Timeout: 60 * time.Second} if useADC { if idClient, err := idtoken.NewClient(ctx, strings.TrimSuffix(trimmed, "/api")); err == nil { hc = idClient } else if oauthClient, err := google.DefaultClient(ctx, "https://www.googleapis.com/auth/cloud-platform"); err == nil { hc = oauthClient } } // ... } 2. Cross-Layer Image Forwarding: Gateway Downloads Image $\rightarrow$ Agent Runtime Automatically Writes to GCS Artifact
When a user uploads a business card photo on LINE:
- Gateway downloads
imageBytesfrom LINE Blob API. -
gateway.Client.StartIngestcalls the remote ADK REST APIPOST /api/apps/{appName}/users/{userID}/sessions/{sessionID}to create a session. - Then it calls
POST /api/run, wrappingimageBytesin theInlineDataofnewMessage.Partsto send to the remote Agent Runtime:
func (c *Client) StartIngest(ctx context.Context, userID string, imageBytes []byte, recentMessages []string) (*linebot.WorkflowResponse, error) { sessionID := "ingest-" + uuid.NewString() initialState := map[string]any{ workflow.StateKeyInputType: workflow.RouteIngest, workflow.StateKeyUserID: userID, workflow.StateKeyRecentMessages: recentMessages, } if err := c.createSession(ctx, userID, sessionID, initialState); err != nil { return nil, err } Source:https://dev.to/gde/agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-persistence-60g![[Agent Platform in Practice (4)] Towards Cloud Two-layer Decoupling and Full Persistence: Completing the LINE AI Business Card M](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%2Fyihnwxfymge8t2hbqs6u.png)