documentation/docs/guides/roaming-agents.md
Roaming agents let you reach a running goose agent from another machine over a peer-to-peer connection — no open ports, no VPN, no server to host. It's built on iroh (QUIC), so two machines can connect directly or via a relay, typically without any firewall changes.
:::warning Opt-in build required
Roaming is an optional, experimental feature that is not included in
released goose binaries. Every command in this guide requires a goose built
from source with the roaming feature enabled:
cargo build --release -p goose-cli --features roaming
On a default build, goose roam reports an unrecognized subcommand.
:::
Roaming is designed to be embedded: the transport is a standalone Rust crate
(goose-roaming) with no dependency on goose's agent internals, the CLI exposes
it as goose roam commands, and there are wasm bindings for browser apps. If
you build on goose — or just want an authenticated p2p ACP transport — you can
use the same pieces directly. The web client (covered near the end) is a
reference client built entirely on this public surface.
Use it to drive your laptop's agent from another device, hand a one-shot task to a remote agent, expose a remote agent to any local ACP client (like an editor), or wire p2p agent access into your own application.
Roaming does exactly one thing: it provides an authenticated, peer-to-peer ACP transport. The host runs goose's real ACP server; the connecting side is an ACP client. That's it.
Everything that feels "session-shaped" is therefore just plain ACP that happens to run over a roaming connection — not a bespoke roaming feature:
| You want to… | It's just ACP… | Command |
|---|---|---|
| List the remote's sessions | session/list | roam delegate <target> --list-sessions |
| Continue a specific session | session/load | roam delegate <target> --session <id> "…" |
| Run a fresh one-shot task | session/new + session/prompt | roam delegate <target> "…" |
| Drive a remote agent from a real UI | full ACP surface | roam bridge → Zed or another ACP editor |
| Quick interactive peek | a built-in REPL | roam connect |
Because the connection carries the full ACP surface, the connecting side can enumerate, create, and resume the host's sessions with no roaming-specific protocol. Higher-level behaviours (saved peers) sit above the transport and are described below.
:::note
Roaming is an optional, experimental feature. It's available when goose is built
with the roaming feature (cargo build -p goose-cli --features roaming).
:::
Trust is a mutual, public-key relationship — like WireGuard or SSH known-hosts, and deliberately infrastructural. Each node has one long-lived identity and produces a connection card: a shareable string containing its public key and how to reach it (relay URLs). Nothing in a card is secret — possessing one grants no access.
To let a peer reach you, you each:
goose roam id prints yours; send it over any channel).goose roam peers accept …).Since a card is just a string, it can travel however is convenient — including
as a QR code: goose roam id --qr and goose roam share --qr also render the
card as a QR code in the terminal, which you can scan from a phone camera (or
directly from the web client's camera, see below) instead of copy-pasting.
A connection only succeeds when the host has accepted the dialer's key. The transport (iroh QUIC-TLS) proves each side holds the private key for the identity in its card, so no one can impersonate a key, and a leaked card lets no one in. There is no bearer token that grants access by possession.
┌────────────┐ swap cards ┌────────────┐
│ Machine A │ ◀───────────────▶ │ Machine B │
│ │ each accepts the │ │
│ roam share│ other's key │ roam connect│
│ (agent) │ ◀═══ ACP over ══▶ │ /delegate/ │
└────────────┘ iroh + relay │ bridge │
└────────────┘
Each connecting client gets its own agent and drives its own sessions over the full ACP surface. (Simultaneous multi-viewer "co-driving" of one live session is a possible future feature, not part of this ACP-transport model.)
Say machine B wants to drive machine A's agent. Both run goose roam id and send
each other the card it prints. Then:
On machine A (the host): add B's card and accept its key.
goose roam peers add 'goose+roam://…B…' laptop-b
goose roam peers accept laptop-b # grants control by default
goose roam share # serve to accepted peers
share keeps running and prints A's card too. The agent runs in the directory
share was started in (override with --cwd <dir>); the connecting side's own
directory is always ignored.
On machine B (the client): add A's card and connect.
goose roam peers add 'goose+roam://…A…' laptop-a
goose roam connect laptop-a
You get an interactive prompt that drives the agent on machine A. Type a message
and press enter; /quit or Ctrl-D to leave.
connect is a minimal built-in chat loop — handy for a quick sanity check. For
real work, prefer bridge (drive the remote agent from a full ACP client) or
delegate (scriptable one-shot tasks).
For the common "pair a new device" case there is also a one-step helper:
goose roam pair shows this node's card as a QR code, reads the device's card
from stdin, and saves + accepts it in one go (the equivalent of
peers add + peers accept).
:::tip
Compare the short fingerprint shown by roam id / peers accept out of band
(e.g. read it aloud) to be sure you accepted the key you meant to.
:::
To send a single task and get the answer back — no interactive session:
goose roam delegate 'goose+roam://…' "Summarize the last 5 commits in this repo."
The remote agent runs the task with its own tools and prints its final response.
delegate is a thin ACP client, so it can also work with the remote's existing
sessions — all plain ACP under the hood:
# List the remote agent's sessions (session/list)
goose roam delegate 'goose+roam://…' --list-sessions
# Continue a specific session instead of starting fresh (session/load)
goose roam delegate 'goose+roam://…' --session <SESSION_ID> "Now fix the first failure."
connect and delegate embed goose's own ACP client. bridge does the
opposite: it exposes a remote agent as a local ACP endpoint, so any ACP
client — Zed or another editor — can drive it as if
it were running locally. It runs no UI and no agent
of its own; it transparently proxies ACP bytes between the local client and the
remote agent.
Bridge over stdio (the default — for a client that launches goose as a subprocess):
goose roam bridge 'goose+roam://…'
Configure your ACP client to run goose roam bridge '<card>' as its agent
command. It will speak ACP on the process's stdin/stdout, and every request is
forwarded to the remote agent.
Or bridge over a local TCP port, for a client that connects to an address:
goose roam bridge laptop --listen 127.0.0.1:8900
This accepts a single ACP connection on that address and proxies it to the remote agent. Saved peer names work here too.
Because a default share serves the full ACP surface, a bridged client gets
everything — it can list, create, and load the host's sessions, not just a
single pre-selected one.
:::note A bridge serves one client connection. The remote host still runs the agent, imposes its own working directory, and authorizes the connection. :::
Everything above is built on the goose-roaming crate
(crates/goose-roaming), and you can use it directly. The crate deliberately
has zero dependency on goose core — it knows nothing about agents or
sessions, only about identity, trust, and authenticated byte streams — so you
can embed it in any Rust application, with or without goose.
The surface a consumer touches:
RoamingIdentity — a persisted ed25519 node key whose public half is
the iroh endpoint id (RoamingIdentity::generate() for ephemeral,
default_key_path for the on-disk one goose uses).RoamingConfig — a builder for a node: RoamingConfig::new(identity)
plus chainers like .with_relay(RelaySettings::…) and
.with_bind_addr(addr). Defaults to iroh's public relays and an empty
allowlist (accepts no one), so the safe default is built in.RoamingNode — the node itself. RoamingNode::bind(config) binds the
endpoint; node.share(server) hosts an agent to accepted peers;
node.connect(&card, label) / node.connect_with_addr(addr, label) dial a
remote and return a RoamingClientStream (use .into_futures_io() to get
plain async read/write halves); node.card() produces the shareable card.AcpStreamServer — the trait your host side implements to plug in "the
agent". It has two methods — serve_stream (drive your protocol over an
authorized stream for an accepted peer) and agent_id (a display id sent in
the handshake ack) — and that's the entire integration seam. goose-cli's
FullAcpBridge implements it by handing the stream to goose's real ACP
serve; your app can serve anything.TrustBook — the mutual allowlist of accepted peer keys, with durable
persistence and fail-closed reload. node.trust() gives you a handle to
accept or revoke keys at runtime.ConnectionCard — the non-secret identity + reachability string
(goose+roam://…), with encode() / parsing and a short fingerprint()
for out-of-band verification.A minimal end-to-end example (condensed from
crates/goose-roaming/examples/echo_roundtrip.rs, which runs both ends in one
process — cargo run -p goose-roaming --example echo_roundtrip):
use std::sync::Arc;
use goose_roaming::{
AcpStreamServer, EndpointId, RoamingConfig, RoamingIdentity, RoamingNode,
};
// Your "agent": anything that can serve an authorized byte stream.
struct EchoServer;
impl AcpStreamServer for EchoServer {
fn serve_stream(
&self,
_client: EndpointId,
recv: Box<dyn futures::io::AsyncRead + Send + Unpin>,
send: Box<dyn futures::io::AsyncWrite + Send + Unpin>,
) -> futures::future::BoxFuture<'static, anyhow::Result<()>> {
Box::pin(async move { /* echo recv back on send … */ Ok(()) })
}
fn agent_id(&self) -> String { "echo-agent".to_string() }
}
async fn demo() -> anyhow::Result<()> {
// Host: bind a node and share the agent to accepted peers.
let host = RoamingNode::bind(RoamingConfig::new(RoamingIdentity::generate())).await?;
host.share(Arc::new(EchoServer)).await?;
println!("share this card: {}", host.card().encode()?);
// Client: a separate node dials the host's card.
let client = RoamingNode::bind(RoamingConfig::new(RoamingIdentity::generate())).await?;
// Trust step: the HOST must accept the client's key, or the dial is refused.
host.trust().lock().await.accept(&client.endpoint_id());
let stream = client.connect(&host.card(), Some("example".into())).await?;
let (send, recv, _conn) = stream.into_futures_io();
// … speak your protocol (ACP, or anything) over send/recv …
Ok(())
}
A few notes for integrators:
AcpStreamServer yourself: goose serve --roam runs goose's regular agent
server and exposes it over roam in one process. It works headless, writes
its card to <data-dir>/roam/serve.json, and prints it on startup.@aaif/goose-roam-web, built from the goose-roaming-web crate in
the goose-mobile repo)
expose a RoamClient to JavaScript — generate an identity, print your card,
dial a host's card, and drive ACP from inside a browser tab, with no server in
between. The web client below is built on these bindings.The hosted web client at
aaif-goose.github.io/goose-mobile
is a reference client built on the pieces above: the @aaif/goose-roam-web
wasm bindings for transport, and goose's ui/sdk GooseClient for the ACP
protocol layer. The browser tab is itself a roam peer: iroh compiled to
WebAssembly runs inside the tab and connects through the same relays with the
same mutual key trust — there is no server in between, and no traffic goes
through the site's origin. Anything it does, your own app can do with the same
bindings.
Pairing works exactly like any other peer. The tab generates its own identity and shows its card; you accept it once on the host:
goose roam peers accept 'goose+roam://…tab…' phone
To get the host's card into the browser, paste it — or run
goose roam share --qr and scan the QR code with the web client's camera.
Once connected, the tab can list and open the host's sessions, start new ones, stream responses, steer a running turn, and group sessions by project. You can connect several hosts at once; their sessions appear in one merged list.
The source lives in the goose-mobile repo
(mobile-web/) — the README there has build details if you want to host it
yourself (it builds to a static site).
Save a peer's card under a nickname so you don't paste cards each time. A saved
card is just an address-book entry — it does not let that peer connect to
you (use peers accept for that):
goose roam peers add 'goose+roam://…' laptop # save to the address book
goose roam connect laptop
goose roam delegate laptop "run the tests and report failures"
goose roam peers list # show saved peers + which keys you accept
goose roam connections # show observed connections
goose roam id # print this node's connection card
Access is granted only by accepting a peer's public key — there is no bearer token that works by possession. You accept a peer by saved name or inline card:
goose roam peers accept laptop # accept a saved peer
goose roam peers accept 'goose+roam://…' # accept an inline card (also saves it)
goose roam peers accept 'goose+roam://…' laptop # accept + save under a nickname in one go
goose roam peers list # see who is accepted
goose roam peers revoke laptop # stop accepting (name, card, or raw id)
An accepted peer gets goose's full ACP surface — it can drive its own sessions on this machine (new/list/load/prompt), which is effectively remote shell access. There are no finer-grained roles: acceptance is all-or-nothing.
Acceptance is durable and live: it is stored on disk, and a running
share re-reads it on each connection and polls the trust file (about every
two seconds) to enforce it against connections that are already open. Revoking
a peer therefore takes effect within seconds even against a live peer — the
share force-closes any of its open connections. No restart on either side.
Because trust is keyed on the peer's public key and the transport authenticates that key cryptographically, a card can be shared over any channel — it is not a secret, and a leaked card lets no one in.
:::warning Accepting a peer grants full control — the peer can run the agent's tools, including its shell. Only accept machines and people you trust, and verify the fingerprint out of band. :::
With the roaming feature enabled, goose can delegate to other agents itself. Ask
it to, and it can run goose roam delegate <peer> "<task>" via its shell — for
example, "delegate this to my work laptop and summarize what it finds." It sends
one self-contained task and relays the response.
Because saved peers are just an address book, the agent can discover what
remotes it has available (goose roam peers list) and route work to the right
one — e.g. run a build on the machine that has the toolchain, then bring the
result back. Each delegation is a self-contained task with a bounded response,
so this composes into multi-machine workflows without any shared state.
GOOSE_ROAM_RELAYS config key or environment variable to point at your own
deployment.connect, delegate, and bridge all accept either a saved peer name or a
raw goose+roam://… card. Remember the peer must also have accepted your key.GOOSE_DISABLE_KEYRING=1 to skip the keychain entirely.