Building autonomous coding agents or workflow automations often tricks developers into choosing heavyweight persistent servers or expensive background worker fleets. For solo developers and lean technical teams, paying hundreds of dollars per month for idle compute is an operational anti-pattern.
In this guide, we break down the Zero-Idle Architecture: running single-binary Go services on Google Cloud Run, saving state deterministically to Google Cloud Firestore, and executing autonomous ReAct cycles with strict non-interactive execution gates.
1. The ReAct State Loop Architecture
An autonomous agent operates in a continuous loop consisting of three phases:
- Thought (Reasoning): The LLM inspects the current conversation transcript and tool definitions to determine the next action.
- Action (Tool Invocation): The Go runtime executes the specified tool with validated arguments.
- Observation (Feedback): The execution result or compiler diagnostic is fed back to the model context.
"An agent's reliability is bounded by the determinism of its tool interfaces, not the size of its system prompt."
2. Structuring Typed Tool Handlers in Go
Rather than allowing arbitrary shell execution, every tool in our architecture is bound to a strict Go struct with explicit validation. Here is an example of an atomic file edit gateway:
package agent
import (
"context"
"fmt"
"os"
"strings"
)
type EditFileRequest struct {
FilePath string `json:"file_path"`
OldChunk string `json:"old_chunk"`
NewChunk string `json:"new_chunk"`
}
type ToolResult struct {
Success bool `json:"success"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
// ExecuteAtomicEdit handles safe, non-destructive file replacements
func ExecuteAtomicEdit(ctx context.Context, req EditFileRequest) (*ToolResult, error) {
content, err := os.ReadFile(req.FilePath)
if err != nil {
return &ToolResult{
Success: false,
Error: fmt.Sprintf("target file not found: %v", err),
}, nil
}
// Ensure atomic diff replacement
updated := strings.Replace(string(content), req.OldChunk, req.NewChunk, 1)
if err := os.WriteFile(req.FilePath, []byte(updated), 0644); err != nil {
return nil, fmt.Errorf("failed writing file: %w", err)
}
return &ToolResult{
Success: true,
Output: fmt.Sprintf("Successfully edited %s", req.FilePath),
}, nil
}
3. Persisting Conversation State in Firestore
When Cloud Run scales to zero after handling an execution step, in-memory state disappears. We store the state machine's conversation transcript in Firestore using a single flat document model:
package store
import (
"context"
"time"
"cloud.google.com/go/firestore"
)
type AgentSession struct {
SessionID string `firestore:"session_id"`
Status string `firestore:"status"` // RUNNING, WAITING_USER, DONE
Steps []SessionStep `firestore:"steps"`
CreatedAt time.Time `firestore:"created_at"`
UpdatedAt time.Time `firestore:"updated_at"`
}
func (s *Client) SaveStep(ctx context.Context, sessionID string, step SessionStep) error {
docRef := s.client.Collection("sessions").Doc(sessionID)
_, err := docRef.Update(ctx, []firestore.Update{
{
Path: "steps",
Value: firestore.ArrayUnion(step),
},
{
Path: "updated_at",
Value: firestore.ServerTimestamp,
},
})
return err
}
4. Scaling to Zero on Google Cloud Run
Because Go compiles to a single static binary with microsecond initialization, cold starts on Cloud Run typically resolve in under 250 milliseconds. Setting the minimum instances to 0 ensures you pay zero cents when your agents are not actively processing commands.