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)
}
<-waitcRunning 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.