guile-iroh: GNU Guile bindings for iroh

Next:   [Contents]

guile-iroh

guile-iroh provides GNU Guile FFI bindings for iroh, a peer-to-peer networking library. It exposes generic, synchronous iroh primitives like endpoints, connections and bidirectional streams with blocking read/write with a Guile Scheme interface.

This manual documents guile-iroh, GNU Guile FFI bindings for iroh, a peer-to-peer networking library.

Copyright © 2026 Giacomo Leidi.

Permission is granted to copy, distribute and/or modify this document under the terms of the GNU General Public License, Version 3 or any later version.

Table of Contents


Next: , Previous: , Up: guile-iroh   [Contents]

1 Introduction

iroh is written in Rust and its API is asynchronous (it needs a tokio runtime), so it exposes no C ABI that Guile’s (system foreign) can call directly. The thin Rust shim crate compiled to a shared library, libguile_iroh.so, fills this role: it creates a tokio runtime and exposes each iroh operation as a blocking C function. The Guile modules under (iroh) dynamically links that shared library.

Finally, a heartfelt thank you to the GNU Guix project which, on top of producing excellent software, also produced the stylesheet of this manual and the syntax highlighting of its code examples.


Next: , Previous: , Up: guile-iroh   [Contents]

2 Building

The build system is generated with guile-hall from hall.scm. Every time the hall.scm file is changed, the following must be run:

guix shell -L $(pwd)/.guix/modules -m manifest.scm -- bash -c '
  bash scripts/hall_refresh.sh
  autoreconf -vif && ./configure && make'

If you have direnv already installed and enabled, it should be sufficient to:

scripts/hall_refresh.sh
autoreconf -vif && ./configure && make'

make byte-compiles the Guile modules and substitutes the shim path hardcoded by pkg-config into (iroh config).


Next: , Previous: , Up: guile-iroh   [Contents]

3 Usage

guile-iroh is a low-level peer to peer transport library. A program binds an endpoint, connects to other nodes, opens a bidirectional streams, and moves bytes across the network.

3.1 Connecting two peers across a NAT

A common use is to let two programs on two different machines talk straight to each other, even when one or both sit behind a home router that blocks incoming connections.

  1. Each side binds an endpoint with make-endpoint. With the default relay URL or one you provide the endpoint can be reached from outside its own network.
  2. Each side reads its own address with endpoint-address. The address holds a node id, which names the peer, together with routing hints: the relay URL and any direct socket addresses the peer found for itself.
  3. The two sides swap their addresses. guile-iroh does not do this step for you. Send the address over any channel that both machines can already reach or by copying it across by hand.
  4. One side dials the other with endpoint-connect, passing the address it received. The other side waits for the call with endpoint-accept. The connection is checked against the node id, so a wrong or altered routing hint cannot hand you a different peer.
  5. iroh does the NAT traversal, the connection is started through the relay, and it switches to a direct link between the two machines once the two sides manage to reach each other.
  6. Once connected, one side opens a bidirectional stream with connection-open-bi and the other takes it with connection-accept-bi. Each side gets a send half and a receive half.
  7. Now the two sides can exchange bytes. send-stream-write! puts the bytes of a bytevector onto the stream, and recv-stream-read! copies bytes off it into a bytevector. Both calls block until they are done. Since the stream is bidirectional, both sides can write and read at the same time. When a side has nothing more to send it calls send-stream-finish!.

Every call here blocks the thread that makes it, so a real program should usually drive them from its own event loop or from lightweight threads.

The two programs below exemplify the whole workflow. The relay URL is one of the public relays run by the iroh developers: any relay both peers can reach works. listen.scm prints its own address, waits for one peer and echoes the first message back:

;; listen.scm
(use-modules (iroh)
             (rnrs bytevectors))

(define endpoint
  (make-endpoint "guile-iroh/example/0"
                 #:relay-url "https://euw1-1.relay.iroh.network./"))

;; Print the three parts of our address, for dial.scm's command line.
(define address (endpoint-address endpoint))
(format #t "~a ~a ~a~%"
        (endpoint-address-node-id address)
        (endpoint-address-relay-url address)
        (string-join (endpoint-address-direct-addresses address) " "))
(force-output)   ;flush now: the next call blocks for a long time

(define connection (endpoint-accept endpoint))
(define streams (connection-accept-bi connection))
(define send (car streams))
(define recv (cdr streams))

(define buffer (make-bytevector 1024 0))
(define n (recv-stream-read! recv buffer))
(send-stream-write! send buffer 0 n)    ;echo the message back
(send-stream-finish! send)

dial.scm takes the printed address on its command line, connects, sends one message and reads the echo:

;; dial.scm
(use-modules (iroh)
             (rnrs bytevectors))

(define arguments (cdr (command-line)))
(define address
  (make-endpoint-address (car arguments)
                         #:relay-url (cadr arguments)
                         #:direct-addresses (cddr arguments)))

(define endpoint
  (make-endpoint "guile-iroh/example/0"
                 #:relay-url "https://euw1-1.relay.iroh.network./"))

(define connection (endpoint-connect endpoint address #:timeout-ms 30000))
(define streams (connection-open-bi connection))
(define send (car streams))
(define recv (cdr streams))

(send-stream-write! send (string->utf8 "hello over iroh"))
(send-stream-finish! send)

(define buffer (make-bytevector 1024 0))
(define n (recv-stream-read! recv buffer))
(define echoed (make-bytevector n))
(bytevector-copy! buffer 0 echoed 0 n)
(format #t "echoed back: ~a~%" (utf8->string echoed))

Start the listener on one machine and copy its output line:

$ guile -L . listen.scm
19cfef96db… https://euw1-1.relay.iroh.network./ 81.245.224.248:39090 …

Then dial from the other machine, pasting that line as the arguments:

$ guile -L . dial.scm 19cfef96db… https://euw1-1.relay.iroh.network./ …
echoed back: hello over iroh

Both invocations expect the shim to be locatable, see Locating the shim at run time and guile-iroh to be available to Guile. Note that the two endpoints must use the same protocol string (the first argument of make-endpoint), or the dial is refused.

3.2 Address lookup

Swapping addresses out of band works as long as the routing hints stay valid. They can go stale: the peer may have moved to another relay since the address was written down, and a peer introduced indirectly may be known only by its node id, with no hints at all. For these cases guile-iroh provides two building blocks, leaving the policy of when to use them to the application:

  • (make-endpoint … #:address-lookup '(mainline)) keeps the endpoint’s address published on the BitTorrent Mainline DHT, so peers that only know its node id can find it.
  • (endpoint-lookup endpoint node-id #:timeout-ms …) resolves a node id through the DHT and returns a fresh endpoint-address, ready for endpoint-connect.

endpoint-connect itself never consults the DHT: it dials exactly the address you give it. Whether to look a peer up first, only after the hints failed, or never, is your call.

Publishing takes some seconds to propagate. Finally, a record is signed by the endpoint’s key, so a lookup cannot be tampered with.

3.3 Falling back to the DHT

Here we show a potential fallback policy built from guile-iroh: dial the hints when they are present and they still work, otherwise resolve via the DHT the peer’s node id and dial the fresh address. The listener is listen.scm from the previous example with one added line, so that it stays resolvable:

(define endpoint
  (make-endpoint "guile-iroh/example/0"
                 #:relay-url "https://euw1-1.relay.iroh.network./"
                 #:address-lookup '(mainline)))

The dialer takes the peer’s node id, and optionally its routing hints. With stale or missing hints it falls back to the DHT:

;; dial-with-fallback.scm
;; first argument: the peer's node id
;; further arguments (optional): its relay URL and direct addresses, as printed
;; by listen.scm.
(use-modules (iroh)
             (ice-9 exceptions)
             (rnrs bytevectors))

(define arguments (cdr (command-line)))
(define node-id (car arguments))
(define hints (cdr arguments))

(define endpoint
  (make-endpoint "guile-iroh/example/0"
                 #:relay-url "https://euw1-1.relay.iroh.network./"
                 #:address-lookup '(mainline)))

(define (dial address)
  (endpoint-connect endpoint address #:timeout-ms 30000))

(define (dial-via-lookup)
  (dial (endpoint-lookup endpoint node-id #:timeout-ms 60000)))

;; The fallback policy.
(define connection
  (if (null? hints)
      (dial-via-lookup)
      (guard (exception ((iroh-endpoint-error? exception)
                         (dial-via-lookup)))
        (dial (make-endpoint-address node-id
                                     #:relay-url (car hints)
                                     #:direct-addresses (cdr hints))))))

(define streams (connection-open-bi connection))
(send-stream-write! (car streams) (string->utf8 "hello over iroh"))
(send-stream-finish! (car streams))

This example, when called with the full address line, behaves like dial.scm. When called only with the node id it waits for the DHT lookup instead. Real applications should probably refine the policy from here: cache the address a lookup returned, publish fresh hints over already open connections, or skip the hints entirely if they are known to be old.

3.4 Locating the shim at run time

When developing, guile-iroh resolves the shared library through the GUILE_IROH_SOFILE_DEV environment variable, (in production to the library directory configured at build time if you installed guile-iroh from your distro). The value is the path of libguile_iroh.so without the .so extension. When running your own programs against an uninstalled checkout, export it:

export GUILE_IROH_SOFILE_DEV="$PWD/shim/target/release/libguile_iroh"

With the variable set, run the examples of the previous sections from the top of the checkout:

$ guile -L . listen.scm

Previous: , Up: guile-iroh   [Contents]

4 API

The following is the reference of the modules provided by guile-iroh.


Next: , Up: API   [Contents]

4.1 (iroh endpoint)

4.1.1 Procedures

Procedure: endpoint-accept endpoint

Block until an inbound connection arrives and return it, or #f when the endpoint is closed.

Procedure: endpoint-address endpoint KEY: #:online-timeout-ms #:poll-interval-ms #:poll-iterations

Return ENDPOINT’s address as an endpoint-address record. When ENDPOINT has a relay, first wait up to ONLINE-TIMEOUT-MS for it to come online (so the address carries the relay and hints for NAT traversal), then poll for a direct address up to POLL-ITERATIONS times, POLL-INTERVAL-MS apart.

Procedure: endpoint-connect endpoint address KEY: #:timeout-ms

Dial ADDRESS, an endpoint-address, using ENDPOINT’s protocol. The connection is authenticated against ADDRESS’s node id. Blocks until connected, or up to TIMEOUT-MS if it is positive (the default 0 waits indefinitely). Raises an &iroh-endpoint-error. Returns a connection.

The dial uses exactly the routing hints in ADDRESS, never the endpoint’s address lookup: to resolve a peer through the DHT, call endpoint-lookup and dial the address it returns.

Procedure: endpoint-id endpoint

Return ENDPOINT’s identifier (its public key) as a string.

Procedure: make-endpoint protocol KEY: #:relay-url #:secret-key #:address-lookup

Bind an iroh endpoint that accepts connections for PROTOCOL (an ALPN). When RELAY-URL is a non-empty string, the endpoint uses that relay for NAT traversal, otherwise it runs relay-less (direct connections only). SECRET-KEY, a 32-byte bytevector, fixes the endpoint’s identity, when #f a fresh random key is used. ADDRESS-LOOKUP is a list of address lookup mechanisms; the only one supported currently is the symbol 'mainline, which publishes this endpoint’s address on the BitTorrent Mainline DHT and enables endpoint-lookup. Connecting never consults the DHT on its own: resolving a peer is always an explicit endpoint-lookup call.


Next: , Previous: , Up: API   [Contents]

4.2 (iroh connection)

4.2.1 Procedures

Procedure: connection-accept-bi connection

Accept the next inbound bidirectional stream. Returns (SEND-STREAM . RECV-STREAM).

Procedure: connection-open-bi connection

Open a bidirectional stream (initiator side). Returns (SEND-STREAM . RECV-STREAM).

Procedure: recv-stream-read! stream bytevector OPT: start count

Read up to COUNT bytes from STREAM into BYTEVECTOR at START, blocking until some data is available. Returns the number of bytes read, or 0 at end of the stream.

Procedure: send-stream-finish! stream

Signal that no more data will be written to STREAM.

Procedure: send-stream-write! stream bytevector OPT: start count

Write COUNT bytes from BYTEVECTOR at START to STREAM, blocking until done.


Previous: , Up: API   [Contents]

4.3 (iroh address-lookup)

4.3.1 Procedures

Procedure: address-lookup->spec address-lookup

Undocumented procedure.

Procedure: endpoint-lookup endpoint node-id KEY: #:timeout-ms

Resolve NODE-ID (a string) through ENDPOINT’s address lookup (the Mainline DHT) and return the peer’s published address as an endpoint-address record, ready to be passed to endpoint-connect. Blocks until the record is found, the lookup ends without one, or up to TIMEOUT-MS if it is positive (the default 0 waits indefinitely). Raises an &iroh-endpoint-error when ENDPOINT has no address lookup configured or the record cannot be found.