Next: Introduction [Contents]
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.
Next: Building, Previous: guile-iroh, Up: guile-iroh [Contents]
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: Usage, Previous: Introduction, Up: guile-iroh [Contents]
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: API, Previous: Building, Up: guile-iroh [Contents]
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.
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.
make-endpoint. With the default relay URL or one you provide the endpoint can be reached from outside its own network.
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.
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.
connection-open-bi and the other takes it with
connection-accept-bi. Each side gets a send half and a receive half.
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.
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.
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.
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: Usage, Up: guile-iroh [Contents]
The following is the reference of the modules provided by guile-iroh.
Next: (iroh connection), Up: API [Contents]
Block until an inbound connection arrives and return it, or #f when the endpoint is closed.
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.
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.
Return ENDPOINT’s identifier (its public key) as a string.
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: (iroh address-lookup), Previous: (iroh endpoint), Up: API [Contents]
Accept the next inbound bidirectional stream. Returns (SEND-STREAM . RECV-STREAM).
Open a bidirectional stream (initiator side). Returns (SEND-STREAM . RECV-STREAM).
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.
Signal that no more data will be written to STREAM.
Write COUNT bytes from BYTEVECTOR at START to STREAM, blocking until done.
Previous: (iroh connection), Up: API [Contents]
Undocumented procedure.
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.