Next: Introduction [Contents]
guile-iroh provides Guile Scheme 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, Guile Scheme’s 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.
guile-iroh binds iroh-c-ffi,
iroh’s official C FFI. The Guile modules under (iroh) dynamically link
that shared library. Every operation is synchronous and blocks the calling
thread.
A heartfelt thank you goes to the Guix project which, on top of producing excellent software, also produced part of 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, or you provided your own Hall installation, it should be sufficient to:
scripts/hall_refresh.sh autoreconf -vif && ./configure && make
All future rebuilds can be done with:
make
Next: API, Previous: Building, Up: guile-iroh [Contents]
guile-iroh is a low-level peer to peer transport library. With it, 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. It uses iroh’s default relays, so it 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-stream and the other takes it with
connection-accept-stream. Each returns two values: a send half and a
receive half.
send-write puts the bytes of a
bytevector onto the stream, and recv-read returns the next bytes off it
as a bytevector (or the end-of-file object at end of stream). 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-finish, which marks the stream finished without waiting for the
bytes to reach the peer.
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. see Driving guile-iroh from Fibers does the latter.
The two programs below exemplify the whole workflow. listen.scm prints its own address as a ticket (a single string carrying the node id and routing hints) then serves peers one at a time, echoing back the first message of each:
;; listen.scm (use-modules (iroh) (ice-9 receive) (rnrs bytevectors)) (define endpoint (make-endpoint "guile-iroh/example/0")) ;; Print our address as a ticket, for dial.scm's command line. (display (address->string (endpoint-address endpoint))) (newline) (force-output) ;; Serve one peer after another, until interrupted. (let loop () (let ((connection (endpoint-accept endpoint))) (when connection (receive (send recv) (connection-accept-stream connection) (let ((message (recv-read recv 1024))) (send-write send message) ;echo the message back (send-finish send))) (loop))))
send-finish returns as soon as the stream is marked finished, before the
echo is known to have arrived.
dial.scm takes the printed ticket on its command line, connects, sends one message and reads the echo:
;; dial.scm (use-modules (iroh) (ice-9 receive) (rnrs bytevectors)) (define address (string->address (cadr (command-line)))) (define endpoint (make-endpoint "guile-iroh/example/0")) (define connection (endpoint-connect endpoint address)) (receive (send recv) (connection-open-stream connection) (send-write send (string->utf8 "hello over iroh")) (send-finish send) (let ((echo (recv-read recv 1024))) (format #t "echoed back: ~a~%" (utf8->string echo))))
Start the listener on one machine and copy its output line, the ticket. It goes on serving until you interrupt it with C-c:
$ guile -L . listen.scm endpointaa…
Then dial from the other machine, pasting that ticket as the argument:
$ guile -L . dial.scm endpointaa… echoed back: hello over iroh
Both invocations expect the library to be locatable,
see Locating the library 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.
The programs of the previous section do one thing at a time. A program that has to dial several peers, or serve several of them, usually does so with some kind of concurrency. A popular Guile library is Fibers. This example shows two Fibers program interacting with each other over iroh.
A fiber is not a thread: it shares a scheduler thread with the other fibers,
and it gives that thread up only at a suspension point. Since a
guile-iroh call is a blocking foreign call, it blocks the scheduler thread
for its whole duration. So, for example, a fiber calling endpoint-accept
freezes every other fiber that scheduler owns.
The way out is to keep the blocking call off the scheduler thread altogether: run it in a POSIX thread, and have the fiber wait for its result on a channel. This works because a channel operation may be performed by a thread that is not a fiber, in which case it blocks that thread rather than suspending a fiber. offload.scm implements this like so:
;; offload.scm (define-module (offload) #:use-module (fibers channels) #:use-module (ice-9 match) #:use-module (ice-9 threads) #:export (in-thread)) (define (in-thread thunk) "Call THUNK in a fresh POSIX thread and return its values. Only the calling fiber waits: the scheduler thread stays free to run the other fibers." (define channel (make-channel)) (call-with-new-thread (lambda () ;; A channel may be used from a thread that is not a fiber: the put ;; blocks this thread until a fiber takes the message. (put-message channel (with-exception-handler (lambda (exception) (cons 'raise exception)) (lambda () (cons 'return (call-with-values thunk list))) #:unwind? #t)))) (match (get-message channel) (('return . vals) (apply values vals)) (('raise . exception) (raise-exception exception))))
With it, the listener from the previous example turns into a server that gives every peer its own fiber.
;; listen-fibers.scm (use-modules (iroh) (offload) (fibers) (ice-9 receive) (rnrs bytevectors)) (define endpoint (make-endpoint "guile-iroh/example/0")) (display (address->string (endpoint-address endpoint))) (newline) (force-output) (define (echo connection) (receive (send recv) (in-thread (lambda () (connection-accept-stream connection))) (let ((message (in-thread (lambda () (recv-read recv 1024))))) (in-thread (lambda () (send-write send message))) (in-thread (lambda () (send-finish send)))))) (run-fibers (lambda () (let loop () (let ((connection (in-thread (lambda () (endpoint-accept endpoint))))) (when connection ;; Serve this peer in its own fiber and go straight back to ;; accepting: slow peers do not hold up the next one. (spawn-fiber (lambda () (echo connection))) (loop))))))
The dialer, in turn, can dial every peer at once. dial-fibers.scm takes one ticket per peer on its command line, dials them in parallel and prints the echoes as they land:
;; dial-fibers.scm (use-modules (iroh) (offload) (fibers) (fibers channels) (ice-9 match) (ice-9 receive) (rnrs bytevectors)) (define tickets (cdr (command-line))) (define endpoint (make-endpoint "guile-iroh/example/0")) (define (echo-through address message) "Dial ADDRESS, send MESSAGE and return the echo, as a string." (let ((connection (in-thread (lambda () (endpoint-connect endpoint address))))) (receive (send recv) (in-thread (lambda () (connection-open-stream connection))) (in-thread (lambda () (send-write send (string->utf8 message)))) (in-thread (lambda () (send-finish send))) (utf8->string (in-thread (lambda () (recv-read recv 1024))))))) (run-fibers (lambda () (define echoes (make-channel)) ;; One fiber per ticket: every peer is dialed at the same time, however ;; long the NAT traversal of any one of them takes. (for-each (lambda (ticket) (spawn-fiber (lambda () (let ((address (string->address ticket))) (put-message echoes (cons (address-node-id address) (echo-through address "hello over iroh"))))))) tickets) ;; Print the echoes as they arrive, in whatever order they do. (for-each (lambda _ (match (get-message echoes) ((node-id . echo) (format #t "~a echoed back: ~a~%" (substring node-id 0 8) echo)))) tickets)))
One endpoint serves all the fibers of a program: iroh dials and accepts
through a shared reference, so concurrent endpoint-connect and
endpoint-accept calls on the same endpoint are fine. A connection and
the two halves of a stream, on the other hand, are each meant for one fiber at
a time.
Run the two programs the way you ran the previous pair, from a directory where Guile can find both guile-iroh and offload.scm. Start one listener per machine:
$ guile -L . listen-fibers.scm endpointaa…
Then dial them all from a third, passing one ticket per peer:
$ guile -L . dial-fibers.scm endpointaa… endpointab… 0f8a3d21 echoed back: hello over iroh b4c07e95 echoed back: hello over iroh
The echoes are printed in the order they arrive rather than in ticket order. Both programs speak the same protocol as the blocking ones, so dial.scm can dial listen-fibers.scm and dial-fibers.scm can dial listen.scm.
The above code is just an example, a production setup requires more attention.
in-thread starts one thread per call, which is cheap next to a NAT
traversal but not free. A program that moves many small messages
is better served by keeping a thread per connection (one thread taking
requests off a channel and running them in order) rather than by spawning a
new one for every read and write.
When developing, guile-iroh resolves the shared library through the
GUILE_IROH_SOFILE_DEV environment variable (in production it falls back to
the library directory configured at build time, if you installed guile-iroh from
your distro). The value is the path of libiroh_c_ffi.so without the
.so extension. When running your own programs against an uninstalled
checkout, export it:
export GUILE_IROH_SOFILE_DEV="/path/to/iroh-c-ffi/target/release/libiroh_c_ffi"
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 own address. First wait up to ONLINE-TIMEOUT-MS for the endpoint to come online (so the address carries the relay and NAT-traversal hints), then poll up to POLL-ITERATIONS times, POLL-INTERVAL-MS apart, for a direct address.
Dial ADDRESS using ENDPOINT’s protocol and return a connection. Blocks until connected. The connection is authenticated against ADDRESS’s node id, the routing hints are used exactly as given.
Return ENDPOINT’s node id (its public key) as a string.
Bind an iroh endpoint speaking protocol ALPN (a string). The endpoint uses iroh’s default relays for NAT traversal. #:secret-key, a base32 string, fixes the endpoint’s identity, when unset it generates a fresh key.
Next: (iroh address), Previous: (iroh endpoint), Up: API [Contents]
Accept the next inbound bidirectional stream. Returns two values: a send-stream and a recv-stream.
Open a bidirectional stream (initiator side). Returns two values: a send-stream and a recv-stream.
Read up to COUNT bytes from STREAM, blocking until some data is available. Return them as a fresh bytevector, or the end-of-file object at end of stream.
Read up to COUNT bytes from STREAM into BYTEVECTOR at START, blocking until some data is available. Return the number of bytes read (0 at end of stream).
Signal that no more data will be written to STREAM. This consumes the send half; further writes or finishes on it do nothing.
Write bytes [START, END) of BYTEVECTOR to STREAM, blocking until done. Returns the number of bytes written.
Previous: (iroh connection), Up: API [Contents]
Build a libiroh EndpointAddr (a by-value struct, returned as a pointer)
from ADDRESS. The relay and direct-address boxes are consumed into it.
The returned pointer must be freed with %endpoint-addr-free after
being used.
Serialise ADDRESS to its iroh ticket string, suitable for
string->address on another machine.
Read POINTER, a pointer pointing to a libiroh EndpointAddr, into an
address record. The node id is the PublicKey at offset 0 of the
struct.
Parse an iroh ticket STRING (as produced by address->string) into
an address.