Table of Contents

Build an echo protocol

Write a protocol handler, serve it with a Router, and connect two processes with an endpoint ticket.

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

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:

// 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:

// 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)
}
go run ./echo
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:

Part 2 — two processes, one ticket

An endpoint 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:

// 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:

// 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:

go run ./echo-server
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:

go run ./echo-client 'endpointade7dxku6e7gise7…' 'hello over a ticket'
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:

Last updated: 2026-08-20