---
title: Build an echo protocol
description: Write a protocol handler, serve it with a Router, and connect two processes with an endpoint ticket.
icon: terminal
---

This tutorial builds a small but complete iroh application: an echo protocol
served through a `Router`, first in one process, then split into a server and a
client that find each other with an endpoint ticket. Every program on this page
was compiled and run against `github.com/tmc/go-iroh` before publication.

By the end you will have used the four things every iroh application needs: an
endpoint, an ALPN, a protocol handler, and an address.

## Set up

```sh
mkdir irohecho && cd irohecho
go mod init example.com/irohecho
go get github.com/tmc/go-iroh
```

## Part 1 — one process, two endpoints

A protocol handler is a function that owns an accepted connection. It receives
a `*iroh.Conn` and returns when it is done with it:

```go
// handleEcho copies one stream back to its sender.
func handleEcho(ctx context.Context, conn *iroh.Conn) error {
	s, err := conn.AcceptStream(ctx)
	if err != nil {
		return err
	}
	if _, err := io.Copy(s, s); err != nil {
		return err
	}
	return s.Close()
}
```

A `Router` maps ALPN strings to handlers and runs the accept loop for you.
Create `echo/main.go`:

```go
// Command echo runs an iroh echo protocol between two endpoints in one process.
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/netip"

	"github.com/tmc/go-iroh/iroh"
	"github.com/tmc/go-iroh/netaddr"
)

const alpn = "example/echo/1"

// handleEcho copies one stream back to its sender.
func handleEcho(ctx context.Context, conn *iroh.Conn) error {
	s, err := conn.AcceptStream(ctx)
	if err != nil {
		return err
	}
	if _, err := io.Copy(s, s); err != nil {
		return err
	}
	return s.Close()
}

func main() {
	ctx := context.Background()
	loopback := netip.AddrPortFrom(netip.IPv6Loopback(), 0)

	server, err := iroh.Bind(ctx, iroh.WithBindAddr(loopback))
	if err != nil {
		log.Fatal(err)
	}
	router, err := iroh.NewRouter(server, map[string]iroh.ProtocolHandler{
		alpn: iroh.ProtocolHandlerFunc(handleEcho),
	}, nil)
	if err != nil {
		log.Fatal(err)
	}
	defer router.Shutdown(ctx)

	client, err := iroh.Bind(ctx, iroh.WithBindAddr(loopback))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Shutdown(ctx)

	addr := netaddr.NewEndpointAddr(server.ID()).WithIP(server.LocalAddr())
	conn, err := client.Connect(ctx, addr, alpn)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	s, err := conn.OpenStreamSync(ctx)
	if err != nil {
		log.Fatal(err)
	}
	if _, err := s.Write([]byte("hello from go-iroh")); err != nil {
		log.Fatal(err)
	}
	s.Close()
	got, err := io.ReadAll(s)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("server %s echoed: %s\n", server.ID().Z32()[:8], got)
}
```

```sh
go run ./echo
```

```text
server 37w49eed echoed: hello from go-iroh
```

The endpoint ID prefix changes on every run because each `Bind` without
`iroh.WithSecretKey` generates a new key.

Three details worth noticing:

- `iroh.NewRouter` takes the endpoint, an ALPN-to-handler map, and a config
  (`nil` accepts the defaults). It registers the ALPNs on the endpoint, so you
  do not also pass `iroh.WithALPNs`.
- `s.Close()` closes the *send* side of the bidirectional stream. That is what
  lets `io.Copy` in the handler return, which is what lets `io.ReadAll` return
  on the client. Forgetting it is the most common way to hang an echo protocol.
- `netaddr.NewEndpointAddr(id).WithIP(...)` builds the address by hand because
  both endpoints are on this machine. In Part 2 the address travels as a
  ticket.

## Part 2 — two processes, one ticket

An [endpoint ticket](concepts#ticket) is the compact string form of an
endpoint address, meant for out-of-band sharing: paste it into a terminal, a
chat message, or a QR code. `endpointticket.Encode` produces one and
`endpointticket.Decode` returns the `netaddr.EndpointAddr` back.

`echo-server/main.go`:

```go
// Command echo-server serves the example echo protocol and prints its ticket.
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/netip"

	"github.com/tmc/go-iroh/endpointticket"
	"github.com/tmc/go-iroh/iroh"
	"github.com/tmc/go-iroh/netaddr"
)

const alpn = "example/echo/1"

func handleEcho(ctx context.Context, conn *iroh.Conn) error {
	s, err := conn.AcceptStream(ctx)
	if err != nil {
		return err
	}
	if _, err := io.Copy(s, s); err != nil {
		return err
	}
	return s.Close()
}

func main() {
	ctx := context.Background()
	ep, err := iroh.Bind(ctx, iroh.WithBindAddr(netip.AddrPortFrom(netip.IPv6Loopback(), 0)))
	if err != nil {
		log.Fatal(err)
	}
	router, err := iroh.NewRouter(ep, map[string]iroh.ProtocolHandler{
		alpn: iroh.ProtocolHandlerFunc(handleEcho),
	}, nil)
	if err != nil {
		log.Fatal(err)
	}
	defer router.Shutdown(ctx)

	addr := netaddr.NewEndpointAddr(ep.ID()).WithIP(ep.LocalAddr())
	fmt.Println(endpointticket.Encode(addr))
	<-ctx.Done()
}
```

`echo-client/main.go`:

```go
// Command echo-client dials an echo server named by an endpoint ticket.
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"os"

	"github.com/tmc/go-iroh/endpointticket"
	"github.com/tmc/go-iroh/iroh"
)

const alpn = "example/echo/1"

func main() {
	if len(os.Args) != 3 {
		log.Fatal("usage: echo-client <ticket> <message>")
	}
	addr, err := endpointticket.Decode(os.Args[1])
	if err != nil {
		log.Fatal(err)
	}
	ctx := context.Background()
	ep, err := iroh.Bind(ctx)
	if err != nil {
		log.Fatal(err)
	}
	defer ep.Shutdown(ctx)

	conn, err := ep.Connect(ctx, addr, alpn)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	s, err := conn.OpenStreamSync(ctx)
	if err != nil {
		log.Fatal(err)
	}
	if _, err := s.Write([]byte(os.Args[2])); err != nil {
		log.Fatal(err)
	}
	s.Close()
	got, err := io.ReadAll(s)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("echo: %s\n", got)
}
```

In one terminal:

```sh
go run ./echo-server
```

```text
endpointade7dxku6e7gise7…
```

The ticket is a single line beginning with `endpoint` followed by lowercase
base32 without padding; a loopback ticket like this one is about 100
characters. Copy it, and in a second terminal:

```sh
go run ./echo-client 'endpointade7dxku6e7gise7…' 'hello over a ticket'
```

```text
echo: hello over a ticket
```

Note that the client called `iroh.Bind(ctx)` with no options at all. A client
needs no ALPN registration and no fixed bind address — it dials.

## Where to take it next

This program only works between two machines that can reach each other's IP
address, because the ticket only carries an IP path. To make it work across
NATs, the server needs a relay URL, a discovery service, or both in its
address:

- [Relays and discovery](relays-and-discovery) — put a relay URL in the
  ticket, or publish the address so the client can look it up from the
  endpoint ID alone.
- [Endpoints and connections](endpoints-and-connections) — streams, datagrams,
  0-RTT, path selection, and the rest of the connection API.
- [Protocol packages](protocols) — instead of inventing a protocol, run
  `blobs`, `gossip`, or `docs` behind the same `Router`.
