Skip to main content
  1. Posts/

A Route Guide for Sidecar

·1683 words·8 mins·
Author
Agent IO
Implementing the gRPC Route Guide example with Sidecar

The Route Guide API

The Route Guide API is a simple travel-related service that's a common example in gRPC tutorials. A quick search shows instances in many languages: Node, C++, Java, Python, PHP, Kotlin, Go, Rust, Dart, Ruby... so it seemed like a good exercise and demonstration of Sidecar.

Since Sidecar is in Go, the most direct comparison is with the Go Route Guide example, which we've used as a starting point, but we'll include everything you need in this post and in the agentio/routeguide-sidecar repo on GitHub.

Defining the service

The Route Guide service is defined using the Protocol Buffers language in route_guide.proto. Here's a key excerpt:

service RouteGuide {
  // A simple RPC.
  //
  // Obtains the feature at a given position.
  //
  // A feature with an empty name is returned if there's no feature at the given
  // position.
  rpc GetFeature(Point) returns (Feature) {}

  // A server-to-client streaming RPC.
  //
  // Obtains the Features available within the given Rectangle.  Results are
  // streamed rather than returned at once (e.g. in a response message with a
  // repeated field), as the rectangle may cover a large area and contain a
  // huge number of features.
  rpc ListFeatures(Rectangle) returns (stream Feature) {}

  // A client-to-server streaming RPC.
  //
  // Accepts a stream of Points on a route being traversed, returning a
  // RouteSummary when traversal is completed.
  rpc RecordRoute(stream Point) returns (RouteSummary) {}

  // A Bidirectional streaming RPC.
  //
  // Accepts a stream of RouteNotes sent while a route is being traversed,
  // while receiving other RouteNotes (e.g. from other users).
  rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
}

Each method of the service illustrates one of the streaming modes of gRPC:

  • GetFeature is a simple unary method. Callers send a Point and the server replies with a Feature at that point, or an empty feature if nothing is there.
  • ListFeatures demonstrates server streaming. Callers send a Rectangle and the server replies with a stream of Feature messages that represent the features inside that rectangle.
  • RecordRoute demonstrates client streaming. Callers send a stream of Point messages and when the calling connection is closed, the server replies with a RouteSummary that includes the number of points, distance traveled, and other items of interest about the route.
  • RouteChat demonstrates bidirectional streaming. Callers send a stream of RouteNote messages and the server replies with a stream of RouteNote messages that are related to the ones sent by the caller (usually by sharing a location).

Our agentio/routeguide-sidecar project compiles this Protocol Buffer description into Go code. This is done with the generated target in the Makefile, which uses protoc and protoc-gen-go. The generated file goes into a project-local directory named genproto (not checked in).

The all Makefile target runs this code generation step and then compiles the Go code in the project to create a routeguide-sidecar binary. This is a command-line interface built with spf13/cobra that has two main subcommands: serve runs the Route Guide server and client runs the Route Guide client.

$ routeguide-sidecar
Usage:
  routeguide-sidecar [command]

Available Commands:
  client      Run the routeguide test client
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  serve       Run the routeguide server

Flags:
  -h, --help   help for routeguide-sidecar

Use "routeguide-sidecar [command] --help" for more information about a command.

Implementing the server

Unlike grpc-go, Sidecar builds directly on the networking features in the Go standard library. The request handlers of our service are connected using an http.ServeMux, and here we see how that can be used with sidecar.NewServer to create an http.Server and serve requests on either TCP or Unix sockets:

apiServer, err := routeguide.NewServer(data)
if err != nil {
	return err
}
httpServer := sidecar.NewServer(apiServer.ServeMux())
var listener net.Listener
if port == 0 {
	listener, err = net.Listen("unix", socket)
} else {
	listener, err = net.Listen("tcp", fmt.Sprintf(":%d", port))
}
if err != nil {
	return err
}
return httpServer.Serve(listener)

Also unlike grpc-go, Sidecar uses no generated code in its client and server implementations (apart from Protocol Buffer message serialization). That means that we build the http.ServeMux directly in our example code, but it's really very simple:

func (s *Server) ServeMux() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc(constants.RouteGuideGetFeatureProcedure, sidecar.HandleUnary(s.getFeature))
	mux.HandleFunc(constants.RouteGuideListFeaturesProcedure, sidecar.HandleServerStreaming(s.listFeatures))
	mux.HandleFunc(constants.RouteGuideRecordRouteProcedure, sidecar.HandleClientStreaming(s.recordRoute))
	mux.HandleFunc(constants.RouteGuideRouteChatProcedure, sidecar.HandleBidiStreaming(s.routeChat))
	return mux
}

The constants here are the HTTP request paths of the Route Guide API methods:

// Package constants contains constants describing the RouteGuide service.
package constants

// These are the fully-qualified names of the RouteGuide RPCs.
const (
	RouteGuideGetFeatureProcedure   = "/routeguide.RouteGuide/GetFeature"
	RouteGuideListFeaturesProcedure = "/routeguide.RouteGuide/ListFeatures"
	RouteGuideRecordRouteProcedure  = "/routeguide.RouteGuide/RecordRoute"
	RouteGuideRouteChatProcedure    = "/routeguide.RouteGuide/RouteChat"
)

Like the gRPC project, Sidecar is opinionated, and the opinion of Sidecar is that simple code like this is best not hidden behind generated boilerplate. Here we see that our Sidecar gRPC handlers are just like any other Go HTTP handlers and could be freely mixed with other handlers in the same server implementation.

The unary handler

You might have noticed that our ServeMux implementation used Sidecar wrapper functions like sidecar.HandleUnary(). These handle concerns common to all Sidecar handlers and allow us to write handler implementations that use simpler abstractions, like the one shown below:

func (s *Server) getFeature(ctx context.Context, req *sidecar.Request[routeguidepb.Point]) (*sidecar.Response[routeguidepb.Feature], error) {
	log.Printf("%s", constants.RouteGuideGetFeatureProcedure)
	for _, feature := range s.savedFeatures {
		if proto.Equal(feature.Location, req.Msg) {
			return sidecar.NewResponse(feature), nil
		}
	}
	// No feature was found, return an unnamed feature
	return sidecar.NewResponse(&routeguidepb.Feature{Location: req.Msg}), nil
}

Here our handler receives a sidecar.Request and returns a sidecar.Response. These include Msg fields that represent our request and response messages.

The server-streaming handler

Server streaming handlers also use a convenience wrapper, and this one includes a stream object that the server can use to send a stream of messages to the client:

func (s *Server) listFeatures(ctx context.Context, req *sidecar.Request[routeguidepb.Rectangle], stream *sidecar.ServerStream[routeguidepb.Feature]) error {
	log.Printf("%s", constants.RouteGuideListFeaturesProcedure)
	for _, feature := range s.savedFeatures {
		if inRange(feature.Location, req.Msg) {
			if err := stream.Send(feature); err != nil {
				return err
			}
		}
	}
	return nil
}

The client-streaming handler

The wrapper for client streaming handlers also sends handlers a stream argument, but this one provides a Receive method that the server can use to receive streamed messages from the client:

func (s *Server) recordRoute(ctx context.Context, stream *sidecar.ClientStream[routeguidepb.Point]) (*sidecar.Response[routeguidepb.RouteSummary], error) {
	log.Printf("%s", constants.RouteGuideRecordRouteProcedure)
	var pointCount, featureCount, distance int32
	var lastPoint *routeguidepb.Point
	startTime := time.Now()
	for {
		point, err := stream.Receive()
		if err == io.EOF {
			endTime := time.Now()
			return sidecar.NewResponse(&routeguidepb.RouteSummary{
				PointCount:   pointCount,
				FeatureCount: featureCount,
				Distance:     distance,
				ElapsedTime:  int32(endTime.Sub(startTime).Seconds()),
			}), nil
		}
		if err != nil {
			return nil, err
		}
		pointCount++
		for _, feature := range s.savedFeatures {
			if proto.Equal(feature.Location, point) {
				featureCount++
			}
		}
		if lastPoint != nil {
			distance += calcDistance(lastPoint, point)
		}
		lastPoint = point
	}
}

The bidirectional-streaming handler

Bidirectional streaming handlers get a stream argument that supports both sending and receiving:

func (s *Server) routeChat(ctx context.Context, stream *sidecar.BidiStream[routeguidepb.RouteNote, routeguidepb.RouteNote]) error {
	log.Printf("%s", constants.RouteGuideRouteChatProcedure)
	s.periodicallyResetRouteNotes()
	for {
		in, err := stream.Receive()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		key := serialize(in.Location)
		s.mu.Lock()
		s.routeNotes[key] = append(s.routeNotes[key], in)
		// Note: this copy prevents blocking other clients while serving this one.
		// We don't need to do a deep copy, because elements in the slice are
		// insert-only and never modified.
		rn := make([]*routeguidepb.RouteNote, len(s.routeNotes[key]))
		copy(rn, s.routeNotes[key])
		s.mu.Unlock()
		for _, note := range rn {
			if err := stream.Send(note); err != nil {
				return err
			}
		}
	}
}

Just as with other gRPC-compatible libraries, these abstractions let us write server code that focuses on our algorithms and logic, undistracted by the details of our networking implementation.

Implementing the client

Again, apart from message serialization, Sidecar clients don't use generated code. Instead, gRPC methods are called using Go generic functions that are specific to each streaming mode.

The unary client

Unary clients just call a simple sidecar.CallUnary function that takes a request and returns a response.

response, err := sidecar.CallUnary[routeguidepb.Point, routeguidepb.Feature](
	ctx, client, constants.RouteGuideGetFeatureProcedure,
	sidecar.NewRequest(point),
)
if err != nil {
	log.Fatalf("client.GetFeature failed: %s", err)
}
log.Println(response.Msg)

The server-streaming client

Server-streaming clients call sidecar.CallServerStream, which takes a request and returns a stream object that provides a Receive method that clients can call to get messages from the server.

stream, err := sidecar.CallServerStream[routeguidepb.Rectangle, routeguidepb.Feature](
	ctx, client, constants.RouteGuideListFeaturesProcedure,
	sidecar.NewRequest(rect),
)
if err != nil {
	log.Fatalf("client.ListFeatures failed: %s", err)
}
for {
	feature, err := stream.Receive()
	if err == io.EOF {
		break
	}
	if err != nil {
		log.Fatalf("client.ListFeatures failed: %s", err)
	}
	log.Printf("Feature: name: %q, point:(%v, %v)", feature.GetName(),
		feature.GetLocation().GetLatitude(), feature.GetLocation().GetLongitude())
}

The client-streaming client

Client-streaming clients call sidecar.CallClientStream and get a stream object that they can use to send messages with Send and then, when all messages have been sent, CloseAndReceive ends the call and returns the server's response.

stream, err := sidecar.CallClientStream[routeguidepb.Point, routeguidepb.RouteSummary](
	ctx, client, constants.RouteGuideRecordRouteProcedure,
)
if err != nil {
	log.Fatalf("client.RecordRoute failed: %s", err)
}
for _, point := range points {
	if err := stream.Send(point); err != nil {
		log.Fatalf("client.RecordRoute: stream.Send(%v) failed: %s", point, err)
	}
}
reply, err := stream.CloseAndReceive()
if err != nil {
	log.Fatalf("client.RecordRoute failed: %s", err)
}
log.Printf("Route summary: %v", reply)

The bidirectional-streaming client

Bidirectional-streaming clients call sidecar.CallBidiStream and get a stream object that they can use to send messages with Send, receive messages with Receive, and and then, when all messages have been sent, CloseRequest ends the call from the client side. As shown in the example below, clients should wait for the server to complete and close its connection.

stream, err := sidecar.CallBidiStream[routeguidepb.RouteNote, routeguidepb.RouteNote](
	ctx, client, constants.RouteGuideRouteChatProcedure,
)
if err != nil {
	log.Fatalf("client.RouteChat failed: %s", err)
}
waitc := make(chan struct{})
go func() {
	for {
		in, err := stream.Receive()
		if err == io.EOF {
			// read done.
			close(waitc)
			return
		}
		if err != nil {
			log.Fatalf("client.RouteChat failed: %s", err)
		}
		log.Printf("Got message %s at point(%d, %d)", in.Message, in.Location.Latitude, in.Location.Longitude)
	}
}()
for _, note := range notes {
	if err := stream.Send(note); err != nil {
		log.Fatalf("client.RouteChat: stream.Send(%v) failed: %s", note, err)
	}
}
if err = stream.CloseRequest(); err != nil {
	log.Fatalf("client.RouteChat CloseRequest failed: %s", err)
}
<-waitc

Running with Go

For demonstration purposes, the routeguide-sidecar demonstration program contains two subcommands that mirror the function of server.go and client.go in the grpc-go Route Guide example. These interoperate with our Sidecar implementation -- feel free to try that!

To run the server, just use routeguide-sidecar serve from the command line. By default, it listens on a Linux abstract socket named @routeguide. To specify a different socket, use --socket and to instead use a TCP port, provide the --port flag.

routeguide-sidecar serve

Run the client similarly, but we use the --address flag to specify the server's address. unix:@routeguide is the default, and --address localhost:8080 causes the client to connect to a server running on local port 8080.

routeguide-sidecar client

This produces a lot of output that shows the four Route Guide methods being exercised. Following the grpc-go example, this includes random input, so the output is not deterministic and excluded here.

Running with containers

The agentio/routeguide-sidecar repo is configured to build the example into a container that can be run with your favorite container-running tool. Here we use Podman.

podman run -p 8080:8080 ghcr.io/agentio/routeguide-sidecar:latest

Note that we're publishing the service from port 8080 because the Dockerfile is configured to run the server with that port.

When we run the client, we need to tell it how to reach our server from inside the container. That's shown below.

podman run ghcr.io/agentio/routeguide-sidecar:latest client -a host.containers.internal:8080

Container Security with Sidecar

Here's the Dockerfile that we used to build our container:

FROM golang:1.26.5 AS builder
WORKDIR /app
COPY . ./
RUN apt-get update
RUN apt-get install unzip
RUN ./tools/fetch-protoc.sh
ENV PATH="/root/local/bin:${PATH}"
RUN make generated
RUN CGO_ENABLED=0 GOOS=linux go build -v -o routeguide-sidecar .

FROM gcr.io/distroless/static-debian13
COPY --from=builder /app/routeguide-sidecar /usr/bin/routeguide-sidecar
ENTRYPOINT [ "/usr/bin/routeguide-sidecar" ]
CMD ["serve", "-p", "8080"]

It disables CGO and builds our example as a static binary, which it copies into the most minimal distroless image. This keeps the image size small, and more importantly, it minimizes dependencies and exposure to vulnerabilities.

We can look for security problems in our image by running a vulnerability scanner. Here's an easy one that we can run locally with podman or docker:

$ docker run aquasec/trivy image ghcr.io/agentio/routeguide-sidecar
2026-07-14T00:06:27Z    INFO    [vulndb] Need to update DB
2026-07-14T00:06:27Z    INFO    [vulndb] Downloading vulnerability DB...
2026-07-14T00:06:27Z    INFO    [vulndb] Downloading artifact...        repo="mirror.gcr.io/aquasec/trivy-db:2"
...
2026-07-14T00:06:33Z    INFO    [vulndb] Artifact successfully downloaded       repo="mirror.gcr.io/aquasec/trivy-db:2"
2026-07-14T00:06:33Z    INFO    [vuln] Vulnerability scanning is enabled
2026-07-14T00:06:33Z    INFO    [secret] Secret scanning is enabled
2026-07-14T00:06:33Z    INFO    [secret] If your scanning is slow, please try '--scanners vuln' to disable secret scanning
2026-07-14T00:06:33Z    INFO    [secret] Please see https://trivy.dev/docs/v0.72/guide/scanner/secret#recommendation for faster secret detection
2026-07-14T00:06:35Z    INFO    Detected OS     family="debian" version="13.5"
2026-07-14T00:06:35Z    INFO    [debian] Detecting vulnerabilities...   os_version="13" pkg_num=5
2026-07-14T00:06:35Z    INFO    Number of language-specific files       num=1
2026-07-14T00:06:35Z    INFO    [gobinary] Detecting vulnerabilities...

Report Summary

┌──────────────────────────────────────────────────┬──────────┬─────────────────┬─────────┐
│                      Target                      │   Type   │ Vulnerabilities │ Secrets │
├──────────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ ghcr.io/agentio/routeguide-sidecar (debian 13.5) │  debian  │        0        │    -    │
├──────────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ usr/bin/routeguide-sidecar                       │ gobinary │        0        │    -    │
└──────────────────────────────────────────────────┴──────────┴─────────────────┴─────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)

It's clean! This is uncommon in practice, as larger base images frequently contain vulnerabilities and new vulnerabilities are regularly being found in Go and Go packages.

When a scanner finds vulnerabilities, the usual fix is to update dependencies and rebuild the image. Sidecar helps us minimize that churn by minimizing its dependencies. To see the Go dependencies of our example, here's its svelte go.sum:

github.com/agentio/sidecar v0.2.0 h1:PiLanPuAlXmLoD8PVjrTPCRU52Erwz56lQZYBisSJPQ=
github.com/agentio/sidecar v0.2.0/go.mod h1:4gYC9Wqhk6TPnldSnn47Zjx7D6QvlCGSVDKKQHzJ5tk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

What's next?

We've removed a lot of gRPC features to achieve the simplicity and security of Sidecar. We can get them back by running with IO, which can provide TLS termination and caller controls for servers and retry and caller authentication for clients. You may have noticed that there are no secrets or API keys in our examples. We could add them to our headers with Sidecar, but that's an antipattern! Instead we prefer to have our secrets managed by our favorite proxy.

🦋 Comment with ATProto