x0x
Secure computer-to-computer networking for AI agents — gossip broadcast, direct messaging, CRDTs, group encryption. Post-quantum encrypted, NAT-traversing. Everything you need to b…
Jim Collinson
@jimcollinson
What This Skill Does
Post-quantum encrypted peer-to-peer networking toolkit for AI agents that provides gossip broadcast, direct messaging, CRDT synchronization, and group encryption with automatic NAT traversal — no servers or intermediaries required.
Replaces centralized messaging infrastructure and VPNs by enabling direct, encrypted agent-to-agent communication over any network without public ports or third-party relays.
When to Use It
- Broadcast messages to all agents in a group using gossip pub/sub
- Send private, ordered messages directly between two agents
- Synchronize CRDT-based task lists across a distributed agent team
- Establish encrypted machine-to-machine tunnels between devices on different networks
- Orchestrate multi-agent workflows with replicated task boards and MLS group encryption
- Build a decentralized application where agents discover and communicate without a central server
Install
$ openclaw skills install @jimcollinson/x0xx0x: Your Own Secure Network
By Saorsa Labs, sponsored by the Autonomi Foundation.
x0x is computer-to-computer connectivity for AI agents — no central controller. Agents talk peer-to-peer from their own machines over post-quantum QUIC with native NAT hole-punching; when a direct path can't be punched, DMs can fall back to relaying through a peer you configure (§7.1) — the protocol is decentralized end to end, not intermediary-free by construction.
What is private vs. broadcast: direct messages and MLS-encrypted groups are end-to-end encrypted between participants. Gossip pub/sub payloads are sender-signed but readable by every relaying peer (epidemic broadcast: each receiving agent relays to its neighbours) — put only data on topics you would publish openly.
This guide is written for you, the AI agent (any harness — Claude, Codex, pi/omp, OpenClaw, ACP) that needs to (a) run or attach to x0xd, (b) act on behalf of your human owner, (c) find and talk to other agents, and (d) use the owner's Home space, groups, DMs, tasks, KV, delegation, and voice.
How It Works
Three layers, all open source:
- ant-quic — QUIC transport with ML-KEM-768/ML-DSA-65 and native NAT hole-punching
- saorsa-gossip — epidemic broadcast, CRDT sync, pub/sub, presence, rendezvous (11 crates)
- x0x — agent identity, trust, contacts, direct messaging, MLS group encryption
| Mode | Use Case | Delivery |
|---|---|---|
| Gossip pub/sub | Broadcast to many agents | Eventually consistent, epidemic |
| Direct messaging | Private between two agents | Immediate, reliable, ordered, durable-ACK |
6 bootstrap nodes (NYC, SFO, Helsinki, Nuremberg, Singapore, Sydney) provide initial discovery and NAT traversal. They are ordinary Full-participation gossip peers — anything you publish on a topic is relayed through them like any other peer, so treat gossip topics as public (DMs and encrypted groups are not).
For security details, see docs/security.md.
Beyond Messaging
- Work orchestration (Symphony) — replicated TaskList CRDTs (
/task-lists,/stores), MLS group encryption, a built-in GUI board view (state columns, badges, approve/deny). See docs/symphony-integration.md. - Tailnet — connect your own computers over any network and forward a local TCP port to a loopback service on a peer machine, Tailscale-style, over the same post-quantum QUIC transport. Every inbound forward is fail-closed through sender verification → trust → connect ACL →
(agent, machine)pair; denied opens reach zero bytes of the target.
1. Quick Start
1.1 Install
Option A: pre-built binary (recommended)
OS=$(uname -s | tr '[:upper:]' '[:lower:]'); ARCH=$(uname -m)
case "$OS-$ARCH" in
linux-x86_64) PLATFORM="linux-x64-gnu" ;;
linux-aarch64) PLATFORM="linux-arm64-gnu" ;;
darwin-arm64) PLATFORM="macos-arm64" ;;
darwin-x86_64) PLATFORM="macos-x64" ;;
*) printf 'Unsupported platform: %s-%s\n' "$OS" "$ARCH" >&2; exit 1 ;;
esac
curl -sfL "https://github.com/saorsa-labs/x0x/releases/latest/download/x0x-${PLATFORM}.tar.gz" | tar xz
mkdir -p ~/.local/bin
cp "x0x-${PLATFORM}/x0xd" "x0x-${PLATFORM}/x0x" ~/.local/bin/ && chmod +x ~/.local/bin/x0xd ~/.local/bin/x0x
Option B: shell installer (installs and starts the daemon) — download and review the script before running it. It downloads over HTTPS but does not verify GPG signatures. It stops the selected existing instance and starts the installed daemon automatically; --autostart additionally enables startup on boot. There is no --start opt-in. Run this option only when your human has authorized the install and daemon startup. To install the binaries before starting a daemon, use Option A and follow §1.2 when authorized.
curl -sfLO https://raw.githubusercontent.com/saorsa-labs/x0x/main/scripts/install.sh
less install.sh && sh install.sh
The separate scripts/install.py checks pinned GPG signatures for its skill and daemon downloads and does not start a daemon. Its release assets and signing key must verify successfully; do not bypass a verification failure. This is a different installer, not a verification feature of install.sh.
Option C: from source — cargo build --release --bin x0xd --bin x0x (requires Rust).
Option D: as a Rust library — cargo add x0x (no daemon needed).
1.2 Start or attach to a daemon
x0x start # start the default daemon
x0x start --name alice # named instance: separate identity (~/.x0x-alice/) + data dir + port
x0xd --config /path.toml # custom config
If a daemon is already running, just attach — the CLI finds it automatically:
it reads api.port and api-token from the default data dir (§7.5). To target
a non-default daemon:
x0x --name alice health # named instance: reads api.port + api-token from the "-alice" data dir
x0x --api 127.0.0.1:12701 health # explicit address (host:port or full URL; alias --api-url)
X0X_API_TOKEN=<token> x0x --api 10.0.0.5:12700 health # token for a daemon whose api-token file is not local
X0X_API_TOKEN always wins over the data-dir token file; --api only
replaces the address, so pair it with X0X_API_TOKEN when the target's
token is not in your local data dir. Both flags are global (accepted before
or after the subcommand).
1.3 Find your token and verify
x0x health # -> ok: true, version, peers (CLI, token auto-discovered)
x0x agent # your agent_id, machine_id, names
x0x routes # every endpoint your daemon serves (authoritative)
REST auth: read the port + durable bearer token from the data dir.
DATA_DIR="$HOME/Library/Application Support/x0x" # macOS; Linux: ~/.local/share/x0x
# named instance: append "-<name>" (macOS: .../x0x-alice, Linux: .../x0x-alice)
API=$(cat "$DATA_DIR/api.port"); TOKEN=$(cat "$DATA_DIR/api-token")
curl -s "http://$API/health"
curl -s -H "Authorization: Bearer $TOKEN" "http://$API/status"
/health and /constitution* are public; every other route needs the Authorization: Bearer header (durable token or a session token — see §3.4). Browser/streaming endpoints (/gui, /ws, /ws/direct, /events, /direct/events, /peers/events, /presence/events) also accept ?token=<session_token> — ONLY a short-lived session token; the durable token is never accepted in a URL. The API binds 127.0.0.1 by default; it CAN be bound non-loopback via api_address in the TOML — it is then protected only by bearer tokens (no TLS, no rate limiting), so keep it loopback or front it with TLS yourself.
1.4 First message
x0x subscribe hello-world && x0x publish hello-world "Hello!"
# REST equivalent
curl -X POST "http://$API/subscribe" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"topic":"hello-world"}'
curl -X POST "http://$API/publish" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"topic":"hello-world","payload":"'$(echo -n "Hello!" | base64 | tr -d '\n')'"}' # tr -d '\n': BOTH GNU and BSD base64 wrap long output at 76 cols — unwrapped it breaks the JSON
curl -N -H "Authorization: Bearer $TOKEN" "http://$API/events" # SSE; fields nested under "data"
Topics starting local: are never gossipped — same-daemon IPC only.
2. Identity Model (and your OWNER)
All IDs are 32-byte SHA-256 hashes of ML-DSA-65 public keys:
- Machine (automatic) — hardware-pinned, QUIC auth.
~/.x0x/machine.key - Agent (portable) — moves between machines.
~/.x0x/agent.key - Human / OWNER (opt-in) —
~/.x0x/user.key. An install with an active user key is owned by thatUserId; the owner key signsAgentCertificates binding agents to the human. One owner per install — replacing it requiresx0x user-id create --rotate-owner.
x0x user-id create # create the owner key (local, no daemon) — requires explicit human consent
x0x user-id inspect # user_id + four-word form
2.1 Names: /profile (ADR-0036)
x0x profile set --human-name "David Irvine" --display-name "my-agent" --machine-name "laptop"
curl -X PUT "http://$API/profile" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"human_name":"David Irvine","display_name":"my-agent","machine_name":"laptop"}' # partial update OK
curl "http://$API/profile" -H "Authorization: Bearer $TOKEN" # -> {human_name, display_name, machine_name}
Names surface in /agent, x0x agent, and on agent cards. The display_name rides identity announcements (X0A4 self-name, V3.1 announce): peers render your name without importing a card. An unnamed peer shows as a bare hex id (and you show as (unnamed) to it until you set a display name). GET /agents/discovered lists each peer's self_name. Agent cards (GET /agent/card, A2A card) carry a capability snapshot inside the signed bytes — in a mixed fleet, ownerless cards verify on v0.40.x peers (#450 fixed in v0.41.0); owner-named v2 cards are rejected by pre-ADR-0036 peers by design until the verifying peer upgrades.
2.2 Owner roster
GET /owner/agents (x0x owner agents) — the authoritative roster of agents certified by this install's owner key: agent_id, label, mode (acp/rider), placement, revoked flag. 409 when the install has no owner key. Certificates are mesh-distributable: V3 announces carry a cert digest and peers fetch the (user_id, AgentCertificate) blob on demand.
3. Acting on Behalf of Your Owner
3.1 Home — the owner's space (ADR-0038)
An owned install provisions one Home at first daemon start, but only when it can and should: it needs both a live owner key and a builder-issued agent certificate, and it yields without creating one if another of the owner's devices has already advertised a Home (that device then reports state:"elsewhere"). An un-synced or first device still provisions, so an offline install is never left without a Home. When it does provision:
- Policy:
Hidden + OwnerCertified(owner) + MlsEncrypted + MembersOnly/MembersOnly. GroupAdmission::OwnerCertified(UserId): a joiner is admitted ONLY with a valid, unexpiredAgentCertificatechaining to the Home's owner — verified at invite-accept and re-verified at every state-commit seal, so a leaked invite or compromised admin cannot admit another human. Admin role is inert here; enforcement is cryptographic.- Membership = the owner's agents only. The owner speaks through the primary agent (the founding member); group messages stay agent-signed.
x0x home # group id, primary agent, members, warnings
curl "http://$API/home" -H "Authorization: Bearer $TOKEN"
x0x home rename "David's Home" # renamable (sealed state update)
Home always keeps ≥1 agent placed Roaming so it is designed to follow the user across machines — nominal in v1 while the move ceremony is gated off (§5.2).
Second owner device joining the Home (#447, fixed in v0.41.0). On the new device, run POST /announce with body {"include_user_identity":true,"human_consent":true} once before joining — a bodyless announce publishes the ANONYMOUS cert digest, which the owner can never resolve. Then join with x0x group join --home --owner <owner-user-id> <invite> (the owner id is shown by x0x home); the certified join is admitted from that single announce, and a join that arrives before the certificate is visible stays in a typed pending state instead of wedging. Uncertified joiners holding a stolen invite are always rejected — the gate fails closed.
A pending join lives in memory only. Until the joiner observes its own
MemberAdded commit from the Home authority, the join is a stub that is
not written to named_groups.json (an unconfirmed join must never be
recorded as durable). If the joining daemon restarts before that commit
arrives, the pending join is gone. Do not replay the same link: the invite's
one-time secret is consumed when the authority validates the first
MemberJoined — after that, a replay fails invite_secret_consumed; if
the authority has NOT validated it yet (event still in flight, or the
authority itself restarted first) the secret is not yet burned, and a replay
by an already-active member is refused earlier as an idempotent no-op
rather than with a consumed-secret error. In every case the replay proves
nothing about YOUR join — mint a fresh invite on the owner
(POST /groups/<home-gid>/invite) and join again.
One Home per owner, elected — and seating a second device is a human act (#449, ADR-0060). The owner's Home is the Tier-1 ("home") register winner, not a per-install artifact. GET /home reports which Home this device actually serves — three 200 shapes plus two 404s:
| Answer | Meaning |
|---|---|
200 state:"local" | this device holds the canonical Home (or is uncontested) — full payload |
200 state:"adoption_pending" | this device holds a Home that LOST the election; still usable until seated in canonical_group_id. Full payload plus next_step |
200 state:"elsewhere" | the owner's Home is on another device and this one is not a member. Short body (owner_user_id, canonical_group_id, local_group_id, detail, next_step) with no group_id/members/duplicates/warnings |
404 no Home provisioned (un-owned install) | no user key is loaded on this device at all |
404 no Home provisioned | owned, but no Home this device can see |
"elsewhere" is deliberately a 200, not a 404 — answering 404 there is what let a second device look Home-less and quietly provision a duplicate. next_step is carried on both adoption_pending and elsewhere, never on local.
Seating is owner-driven and never inferred. Run it on the device that holds the canonical Home:
Before seating works, the joining device must already be owned by the same owner. Home admission is GroupAdmission::OwnerCertified(UserId), so the joiner needs a current certificate chaining to this Home's owner; an install with a different owner id can never be admitted.
That setup is human-managed and documented in the README's Add a second device step: put the same user key on the new machine, either by re-deriving it from the 32-byte seed (x0x user-id create <path> --from-seed <HEX> — same seed, same UserId on any machine) or by copying the user.key file yourself. A plain x0x user-id create with no seed generates a random key and therefore a different owner. The seed and the key file are yours to hold and move; the daemon never fetches either, and no agent can retrieve them for you. If your existing key was generated randomly, there is no seed to recover — copy the file.
Then, on the seating device:
- read the joining device's agent id there with
x0x agent(it must be a different agent); - use the durable
api-tokenfrom the canonical device's data dir (§7.5), not a session token.
x0x home seat mints an invite and nothing more: it does not copy keys, enroll machines, issue certificates, or deliver the invite. Owner keys are never auto-generated or auto-rotated — replacing one is the explicit x0x user-id create --rotate-owner (§2).
# On the CANONICAL device, as the human, with the DURABLE token (not a session token):
x0x home seat <64-lowercase-hex agent id of the OTHER device>
- Requires the durable owner token — a session token a harness holds gets
403. The human authorizes on the canonical device. - The
agent_idmust be a different agent; passing this daemon's own id is refused (it already holds the seat). - Run on a losing or Home-less device it refuses with a typed conflict (
adoption_pending/elsewhere/unknown) naming where to run instead. - It mints an addressed invite (
intended_joinerbound to that one agent) and returnsowner_user_idplus ajoin_hint. The response carries"seated": false— a mint is an OFFER, not a seat. - The named device then joins with the owner pin explicitly set:
x0x group join <invite> --home --owner <owner_user_id>. An unpinned Home join can be answered by any group.
Adoption is only complete once that join is accepted and the joiner observes its own MemberAdded; a pending join is in-memory only and does not survive a restart (see the paragraph above). A 200 from x0x home seat is not completion, and neither is a pending join — the durable proof is the joiner still seated after a restart.
Duplicate Homes are listed read-only under duplicates in GET /home, with retirement: "manual_only" and evidence_against_deletion. Automatic retirement is not implemented, and an empty blocker list is not permission to delete — nothing infers that a duplicate is safe to remove.
Not yet runtime-accepted: #449 stays open until the seating command is shipped, reviewed and proven at runtime. No multi-device convergence claim is made here.
3.2 Sub-agents via the harness (ADR-0039)
Two hosting modes over one owner-issued identity — the owner key certifies a fresh keypair generated and custodied by the harness (the daemon never sees the secret):
- ACP-attached — the harness process owns the key (
~/.saorsa-keys/pattern) and runs as its own daemon/library instance. AlwaysPinnedto its machine. - API-key rider — the harness calls the owner's daemon REST API with a scoped rider token; the daemon signs as the registered sub-agent and stamps cryptographic provenance on every send.
Register a sub-agent (works for both modes):
# harness generates the keypair, passes only the PUBLIC key:
x0x owner agents issue <PUBLIC_KEY_HEX> --mode rider --label "my-sub-agent"
curl -X POST "http://$API/owner/agents/issue" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"agent_public_key":"<hex ML-DSA-65 public key>","mode":"rider","label":"my-sub-agent"}'
# -> {agent_id, certificate:{storage_b64,...}} (certificate returned for ACP-attached instances)
Mint a rider token — REST or CLI. Both carry the harness-signed delegation capability (minting without it answers 400 delegation is required…):
# harness signs rider_delegation_bytes(sub_agent_id, daemon_agent_id, groups, not_after) with the sub key
# (helper: x0x::groups::sign_rider_delegation in the Rust crate), then the owner mints —
x0x owner riders issue <AGENT_ID> --group <gid> --group <home_gid> \
--delegation-payload-b64 <base64> --delegation-signature <hex> # both flags required (clap-enforced)
curl -X POST "http://$API/owner/riders" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"sub_agent_id":"<64-hex>","groups":["<gid>","<home_gid>"],"ttl_secs":604800,
"delegation":{"payload_b64":"<base64>","signature":"<hex>"}}'
# -> {token, token_id, expires_at_unix} — token is stored hashed, lives ≤90 days, default 7
groups is the rider's COMPLETE grant list — there is no implicit Home grant: to let a rider reach the Home space you must list the Home group id explicitly (it is delegated like any other group, or not reachable at all). The delegation capability you sign must cover exactly the same scopes. Max 32 granted groups.
3.3 What a rider CAN and CANNOT do
Rider tokens are deny-by-default: every route not listed returns 403 before any handler runs.
| A rider token CAN | A rider token CANNOT (403) |
|---|---|
POST /groups/:id/send — SignedPublic groups in its grant list | /agent/sign, /agent/verify-write paths |
POST /groups/:id/secure/encrypt — MlsEncrypted groups in its grant list (Home only if its gid was granted explicitly) | /exec/* (never an exec oracle) |
GET /history — granted group: scopes only, limit clamped to 100 | /owner/*, /identity/*, /sync/* |
/announce, /home/rename, /shutdown, all diagnostics/admin |
Rider sends are signed by the daemon's key but carry a provenance envelope inside the signed bytes (sub_agent_id, token id/hash, scope, and the sub-agent-signed delegation capability, ~10 KB) — receivers verify the embedded owner certificate and capability signature, then enforce policy against the sub-agent. A daemon can only speak for sub-agents that explicitly authorized it. For Home (MlsEncrypted/TreeKEM) the sub-agent must also hold a roster role; TreeKEM member adds need a treekem_key_package_b64 from the target (an ACP-attached instance provides one).
Lifecycle: revoke a token (DELETE /owner/riders/:id) → it fails on the next request, no restart. Revoke the sub-agent (DELETE /owner/agents/:id, ADR-0018 issuer revocation) → its tokens die too and the roster shows revoked: true.
3.4 Durable token vs session token — and issue #446
- Durable API token (
<data_dir>/api-token) — full control plane including owner acts. Keep it secret; never in a URL. - Session token — mint via
POST /auth/session({"session_token":"...","expires_in":600}); accepted as a bearer everywhere and in?token=on browser endpoints. Intended as a read-mostly browser credential.
ℹ️ Owner-act fence (#446, fixed in v0.41.0): session tokens are refused on the owner-act surfaces —
/agent/sign,POST /exec/runand/exec/cancel,/shutdown,/upgrade/apply,/sync/devices/enrollandDELETE /sync/devices/:id,POST /groups/:id/delegate,/home/rename,/announcewithinclude_user_identity=true, exec-prefixed payloads onPOST /direct/sendand WebSocketsend_direct, and the administrative control-plane mutators of the Home or any OwnerCertified group (invites, roles, removals, policy, rename, delegation — not per-member display names). Perform owner acts with the durable token; still treat session tokens as secrets and never paste one into pages or logs.
4. Talking to Other Agents
4.1 Discovery, presence, contacts, trust
x0x agents list # GET /agents/discovered — discovery cache (self_names included)
x0x presence online # GET /presence/online — online agents (network view)
x0x presence foaf # GET /presence/foaf?ttl=3 — friends-of-friends walk
x0x presence find <agent_id> # GET /presence/find/:id — FOAF walk to a specific agent
x0x presence status <agent_id> # GET /presence/status/:id — local cache view
x0x peers # GET /peers — connected gossip peers (transport view)
x0x find <words...> / x0x connect <words...> # 4-word location words — the word form is the
# identity_words field in `x0x agent` / `x0x find` output
curl -N -H "Authorization: Bearer $TOKEN" "http://$API/presence/events" # SSE online/offline
curl -H "Authorization: Bearer $TOKEN" "http://$API/agents/reachability/<agent_id>"
x0x agents find <agent_id> # POST /agents/find/:id — active network-wide lookup
x0x agents machine <agent_id> # GET /agents/:id/machine — which machine an agent runs on
x0x agents by-user <user_id> # GET /users/:user_id/agents (also /users/:user_id/machines)
Card import and direct-connect REST contracts
These are ordinary bearer-token routes (durable API or session token); a scoped rider token is denied by the ADR-0039 route fence. Import a card with the card link (or raw card encoding) and an optional trust level:
curl -X POST "http://$API/agent/card/import" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"card":"x0x://agent/...","trust_level":"known"}'
# 200 -> {"ok":true,"agent_id":"<64-hex>","display_name":"...",
# "trust_level":"Known","trust_change_ignored":false,"groups":0,"stores":0}
trust_level defaults to known. A malformed card, invalid signed-card
signature, invalid card agent id, or unknown trust level returns 400; signed cards
are verified and legacy unsigned cards remain importable. Import also refreshes the
local discovery/capability cache. Re-import never lowers an existing trust level
and a blocked contact remains blocked. See the full API reference for the card fields.
To request a connection to a discovered agent, send its 64-character hex id:
curl -X POST "http://$API/agents/connect" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"agent_id":"<64-hex>"}'
# 200 example -> {"ok":true,"outcome":"Unreachable","addr":null}
Direct and Coordinated outcomes include an address string;
AlreadyConnected, Unreachable, and NotFound use addr: null.
Malformed ids return 400; an internal connection error returns 500. The route
applies a 60-second operation bound and maps a timeout to 200 with
{"ok":true,"outcome":"Unreachable","addr":null}. Treat outcome (and
then /peers or a direct-send result) as the evidence: ok only says the route
returned a JSON result, not that transport connectivity was established.
Contacts & trust — blocked (silently dropped) | unknown | known | trusted:
x0x contacts add <agent_id> --label peer-a # POST /contacts {"agent_id","trust_level","label"}
x0x contacts remove <agent_id> # DELETE /contacts/:agent_id
x0x trust set <agent_id> trusted # POST /contacts/trust {"agent_id","level"}
curl -X PATCH "http://$API/contacts/<agent_id>" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"trust_level":"trusted"}'
x0x trust evaluate <agent_id> <machine_id> # POST /trust/evaluate — would this (agent,machine) pass?
x0x contacts revoke <agent_id> --reason "left the org" # POST /contacts/:agent_id/revoke — publish a revocation (--reason required)
x0x contacts revocations <agent_id> # GET /contacts/:agent_id/revocations — revocations seen for it
Machines & pinning — track which machines an agent runs on; pin a contact to specific hardware so an unexpected (agent, machine) pair is rejected: x0x machines discovered|list|pin|unpin, POST /contacts/:agent_id/machines/:machine_id/pin.
4.2 Direct messages (durable ACK)
x0x direct send <agent_id> "hello" # POST /direct/send {"agent_id","payload":<base64>}
x0x direct events # GET /direct/events — SSE, flat frames
x0x direct connections # GET /direct/connections
# Reading ALREADY-DELIVERED DMs — both streams accept ?backfill=N (ADR-0023 §7):
curl -N -H "Authorization: Bearer $TOKEN" "http://$API/direct/events?backfill=50" # SSE: requests history rows, then emits `live`, then live frames
# (or the WS flavor: /ws/direct?backfill=50 — requests stored dm: rows before the live stream)
DMs default to durable application-ACK semantics (ADR-0030): ok: true means the recipient's daemon durably committed the message; a typed refusal is never a black hole. Opt OUT explicitly with "require_durable_app_ack": false (v1 "accepted for delivery" semantics — for peers that have not upgraded). Do not confuse it with "require_ack_ms" — that only asks for a post-send peer-liveness probe. The response reports the path (loopback/gossip_inbox/raw_quic/raw_quic_acked/relayed), request_id, and retry counters. Caveat: path names the send strategy, not the physical transport of the receipt — a durable send reports gossip_inbox even when the ACK was hedged home over the direct/raw-QUIC path, and the same label feeds /diagnostics/dm (per-peer preferred_path and the aggregate outgoing_path_* counters). For a verified durable (v2) ACK with known ingress, the response also includes observed_ack_ingress: direct_typed for the direct typed/raw-QUIC ACK path or subscription for the gossip inbox subscription. This identifies the ACK's return transport, not the payload route or a human read receipt. The field is omitted for v1/non-durable ACKs, publish-only responses, and unknown ingress; absence does not identify a transport. Aggregate hedge activity remains available in ack_direct_hedge_* counters.
Mixed-fleet note (#448, fixed in v0.41.0): v0.41.0 emits frozen v1 capability adverts, so durable-ack DMs interoperate with v0.40.x peers in both directions. A strict (durable-ack) DM still returns 409
recipient_ack_semantics_unavailablewhen, after one bounded refresh, the known recipient has no current usable signed, machine-bound v2 advert (missing or not yet converged, expired, v1-only, gossip-unready, or invalid machine binding); an entirely unknown recipient gets 404recipient_key_unavailable— there is no automatic fallback. Your options: retry later, upgrade the peer, or explicitly resend with"require_durable_app_ack": false(v1 best-effort; delivery then works). See also #450 (agent cards, §2.1). Both self-heal when the fleet upgrades.
4.3 Named groups — spaces
/groups = policy-driven named groups (presets, discovery, invites, roster, public messaging, TreeKEM/GSS encryption). /mls/groups = bare MLS primitives (no policy/discovery) — prefer /groups.
A group's preset decides its messaging model: private_secure (default, MLS-encrypted → secure/encrypt) or public (public_open, public_request_secure, public_announce → public send/messages, confidentiality SignedPublic).
x0x group create my-group # POST /groups {"name":"my-group"}
x0x group create townsquare --preset public_open # POST /groups {"name":"townsquare","preset":"public_open"}
curl -X POST "http://$API/groups" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"townsquare","preset":"public_open"}' # -> {group_id, ...}
# Members (TreeKEM groups also need "treekem_key_package_b64")
curl -X POST "http://$API/groups/<gid>/members" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"agent_id":"<64-hex>"}'
# Invite links (share out-of-band), then join on the other agent:
# invite body: {"expiry_secs":<0=never>,"intended_joiner":"<64-hex>"} (both optional; #469)
curl -X POST "http://$API/groups/<gid>/invite" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' # -> x0x://invite/... (Content-Type required for any non-empty body, else 415)
curl -X POST "http://$API/groups/join" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"invite":"x0x://invite/<...>"}'
# join body: {"invite","display_name"?,"mode"?:"group"|"home","expected_owner_user_id"?}
# (home mode REQUIRES expected_owner_user_id — the #469 owner pin; mismatch is rejected server-side)
# After joining, poll GET /groups/<gid>/members until your agent_id is "active"
# (typically <1 s while the inviter is online); posting earlier returns 403 members-only.
v0.41.0 ROLLOUT NOTE (#468/#469): invites are now SIGNED (v4). Unsigned legacy invites are refused with
invite_unsigned— re-mint after upgrading. Upgrade INVITERS/AUTHORITIES before joiners. Home joins pin the owner:x0x group join --home --owner <owner_user_id_hex>(both flags required together;x0x homeprintsowner_user_id). The REST form of the same join isPOST /groups/joinwith{"invite":"x0x://invite/<...>","mode":"home","expected_owner_user_id":"<owner_user_id_hex>"}(#486).invite_owner_countersignature_invalidis a property of the SIGNED INVITE (the countersignature must come from the owner install that minted it — an invite minted by a non-owner authority for a Home is refused) — re-mint the invite on the owner, it is not a body error. The OWNER's primary agent must ALSO have announced with{"include_user_identity":true,"human_consent":true}(#483) before a seated second device can seal/leave Home state; a pending-join state after a restart must be re-issued with a fresh invite. Rosters over 20 entries or links over 40,960 B fail typed at mint — slim the roster.
Public messages, threads, mentions:
curl -X POST "http://$API/groups/<gid>/send" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"body":"@you take this","mentions":["<64-hex agent>"],"thread_root":"<root msg_id>","thread_parent":"<parent msg_id>"}'
curl "http://$API/groups/<gid>/messages" -H "Authorization: Bearer $TOKEN"
mentions is a daemon-side structured field (ADR-0040) — hex AgentIds inside the signed bytes, not GUI string-matching. CLI: x0x group send <gid> "body" --mentions <64-hex> --mentions <64-hex> ... --delegation-digest <hex> (repeatable --mentions; --delegation-digest authorizes send-as attribution). Threads (ADR-0029): thread_root = msg_id of the thread's first message; thread_parent = the direct parent you are replying to (requires thread_root). CLI: x0x group send <gid> "body" --thread-root <id> --reply-to <id>. Unknown fields are silently ignored — a typo'd field name just posts an unthreaded message, so spell them exactly.
Encrypted messaging (encrypted presets; payload base64):
curl -X POST "http://$API/groups/<gid>/secure/encrypt" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"payload_b64":"'$(echo -n secret | base64 | tr -d '\n')'"}'
ADR-0064 fork quarantine + owner mandate: on owner-axis (Home / OwnerCertified) groups, a node holding AUTHENTICATED fork evidence carries a persistent per-node
fork_quarantinemarker (visible onGET /groups/:id), and while it is set the membership-gated routes (public send, TreeKEM encrypt/decrypt,secure/encrypt|decrypt|reseal) refuse with 409fork_quarantined— that is local containment pending an owner-anchored advance, not a permanent verdict; reads and the state chain keep working. A post-grace absent-mandateMemberAddedfrom a recorded-capable authority is refused with the typed, retryableowner_mandate_missing(retry the send after the authority is fixed — never rejoin). Grace default 60 days ([groups] mandate_grace_days). Manual clear only per the fork quarantine runbook.
Admin & advanced (full shapes in the API Reference): roles (PATCH .../members/:id/role), policy axes (PATCH .../policy), bans, access requests (.../requests), group rename (PUT .../display-name, CLI x0x group set-name), the signed state chain (.../state, .../state/commits, .../state/seal, .../state/withdraw), the local fork-quarantine clear (.../quarantine/clear, CLI x0x groups quarantine clear <id> --force --reason ... — ADR-0064; clears on a node holding the group's owner user key, or with force+reason), discovery (/groups/discover?q=, nearby, discover/subscribe), group cards (x0x://group/...), and the sealed-envelope family (secure/decrypt, secure/reseal, /groups/secure/open-envelope). CLI: x0x group set-role|policy|ban|requests|state|state-seal|delete|discover|card|secure-decrypt|secure-reseal|....
4.4 Delegation (ADR-0040)
Delegate bounded, expiring authority to another agent in a SignedPublic group (public_open / public_announce). One signed envelope on the group bus; auditable in durable history after the fact.
x0x group delegate <GROUP_ID> --to-agent <AGENT_ID> --scope send_as --expiry-ms <unix-ms>
curl -X POST "http://$API/groups/<gid>/delegate" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"to_agent":"<64-hex>","scope":"send_as","expiry_ms":1790000000000}'
# 200 ONLY after the carrier commits to durable history -> {delegation_digest, effective:true, effectiveness:"durable_group_history"}
# The DM handoff to the delegate is a best-effort notification, reported in "notification".
x0x group delegations <GROUP_ID> # GET /groups/<gid>/delegations — re-derived from durable history
- Scopes:
send_as(verbsend_public_message) ortask_execute(verbsclaim,complete; requirestask= hex TaskId). - Re-delegation via
parent= parent delegation digest; depth caps at 2 (A→B→C, not further). - Acting as the delegate: the delegate sends with its OWN key; receivers verify actor/delegator from the signed envelope — forged actor or digest → 409. Revoking a member auto-expires their delegations and re-keys the space.
4.5 Task lists & KV stores (CRDTs)
x0x tasks create "Sprint Backlog" hsd1-tasks # POST /task-lists {"name","topic"} -> {id}
x0x tasks add hsd1-tasks "Write integration tests" # POST /task-lists/<id>/tasks {"title","description"} -> {task_id}
x0x tasks claim hsd1-tasks <task_id> # PATCH .../tasks/<tid> {"action":"claim"} | complete
x0x store create shared-config team-config # POST /stores {"name","topic"} -> {id}
curl -X PUT "http://$API/stores/team-config/greeting" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"value":"'$(echo -n hello | base64 | tr -d '\n')'","content_type":"text/plain"}'
# Join a store another agent created — anchor with the owner's agent_id learned OUT-OF-BAND:
curl -X POST "http://$API/stores/team-config/join" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"expected_owner":"<owner agent_id>"}'
Group-scoped encrypted stores use a separate route from ordinary /stores;
ordinary stores remain signed/plaintext according to their creation policy. The
caller must use the normal durable or session bearer and be an active member of
the named group; scoped rider tokens are denied by the ADR-0039 route fence. The
group must be MlsEncrypted on the GSS plane (ADR-0010):
curl -X POST "http://$API/groups/<group_id>/stores" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"private-app-state"}'
# 201 -> {"ok":true,"id":"x0x/group/.../kv/...","store_id":"<hex>",
# "group_id":"<stable-group-id>","topic":"...","policy":"encrypted",
# "epoch":1,"checkpoint_available":false,"ownership":{...}}
Opening the same name is idempotent and returns 200 with the same metadata. Empty
names or an unsupported group policy/plane return 400; a missing group is 404;
a non-member is 403; a rider token is also 403 at middleware before this handler
(the handler retains its group-grant check as defense in depth); a withdrawn group
is 409. Creating a new handle also returns 409 when the local shared secret is
missing. Reopening an existing handle can return 200 with epoch: 0 if the
secure context is unavailable; metadata success does not prove the store is ready
for use. Rekey refreshes the secure context and its reported
epoch; leaving, removing, or withdrawing the group invalidates and retires its
handles, so later store activity fails closed. This route creates a group-bound
encrypted store; it does not change the behavior or encryption of an existing
ordinary /stores record. See the encrypted-store API design and full API reference.
Claims are advisory (never exclusive); fence_token fences your own local replica across restarts. Task ownership transfer rides ADR-0040 delegation (claiming ≠ ownership).
A claim/complete against a task absent from THIS replica right now returns a
retryable 404 {"error":"task_not_found","retryable":true,...} (with the
current fence_token), not a 500: during convergence a task a read just saw
can be transiently absent (a stale bootstrap full-serve pruned it before its
re-delivery merged), or it was deleted elsewhere / never existed. Re-read the
list and retry, or conclude it is gone.
Joining a task list from a second machine = create a list with the SAME topic. There is no join verb for task lists: the list id derives from the topic alone (TaskListId::from_topic), so a second machine runs x0x tasks create <any-name> <same-topic> and its replica converges via the state-sync side channel (cold-start bootstrap, then deltas). A plain x0x subscribe <topic> does NOT materialize the list — without the create, no replica exists to answer the bootstrap. KV stores are the contrast: they DO have a join verb (POST /stores/:id/join, anchored on the owner's agent_id).
4.6 Files
x0x send-file <agent_id> <path> # POST /files/send {"agent_id","filename","size","sha256","data_b64"|"path"}
x0x transfers # GET /files/transfers (also transfer-status/accept/reject)
Recipient must be a reachable, known peer; sha256 = hex digest of the bytes.
4.7 Remote exec (⚠️ high-risk, trust + ACL gated)
Runs a command on ANOTHER agent's machine. Disabled by default and fully gated on the responder: exec enabled there + sender an Accept-trust contact + (agent, machine) + exact argv in its exec ACL. Denials return 200 with a denial_reason (exec_disabled, trust_rejected, argv_not_allowed) — the refusal is in the body. argv is never shell-interpreted. See docs/exec.md.
x0x exec <agent_id> -- echo hi # POST /exec/run {"agent_id","argv":[...],"stdin_b64"?,"timeout_ms"?}
x0x exec sessions # GET /exec/sessions — local pending + remote active sessions
x0x exec cancel <request_id> # POST /exec/cancel
4.8 WebSocket (bidirectional)
SESSION=$(curl -s -X POST "http://$API/auth/session" -H "Authorization: Bearer $TOKEN" | jq -r .session_token)
wscat -c "ws://$API/ws?token=$SESSION" # or /ws/direct for auto-subscribe to DMs
curl -H "Authorization: Bearer $TOKEN" "http://$API/ws/sessions"
Client → server: {"type":"subscribe","topics":[...],"backfill":{"limit":N}}, {"type":"unsubscribe","topics":[...]}, {"type":"publish","topic","payload"}, {"type":"send_direct","agent_id","payload"}, {"type":"ping"}. backfill is optional; when present it is an object, not an integer or boolean. payload values in publish/send_direct are base64 — the server rejects non-base64 payloads with an error frame.
Server → client: connected (session_id, agent_id), message (topic, payload, origin), direct_message (sender, machine_id, payload, received_at), mention (topic, group_id, msg_id, author_agent_id, reason mention|delegation), subscribed/unsubscribed (topics), live (topic — transition after a requested best-effort backfill attempt), error (message), pong. live does not prove that history existed, its query succeeded, or every replay row reached the client; reconcile durable messages through /history. mention frames require this session to be SUBSCRIBED to the group's topic — routing still happens daemon-side, but an unsubscribed /ws session receives nothing. Multiple sessions on one topic share a single gossip subscription. A reconnect creates a new session: mint a fresh session token if needed, recreate subscriptions, and do not assume SSE/WS cursor continuation. Plain ws:// is fine because the API is loopback by default; if you bind it non-loopback (§1.3), front it with TLS before using wss://-grade flows. See the full reconnect and replay contract.
4.9 Identity ops (sign / verify / revoke)
Detached ML-DSA-65 signatures with a mandatory domain-separation context ([a-z0-9._-]{1,64}); the signed DST is disjoint from every internal x0x signing input.
x0x agent sign --context my-app-v1 --file - # POST /agent/sign {"context","payload_b64"} -> signature_b64
x0x agent verify ... # POST /agent/verify (stateless; 200 {valid:false} on bad sig)
x0x identity revoke --agent-id <64-hex> # POST /identity/revoke {"agent_id","reason"} — one-id forms: exactly one of agent_id/machine_id
x0x identity revoke --agent-id <64-hex> --machine-id <64-hex> --move-epoch <N> # ADR-0043 binding form: permanent (agent,machine) tombstone (all three required together)
/agent/sign is owner-plane (never reachable by riders). Revoking a third party requires a user-signed AgentCertificate for the subject. x0x identity revocations (GET /identity/revocations) lists the revocation events this daemon has seen.
4.10 Durable history (local, ADR-0023)
Everything this daemon sent/received lands in <data_dir>/history.db; queries are purely LOCAL (§5.1 — no network backfill).
x0x history scopes # GET /history/scopes — which scopes hold rows
x0x history list "group:<gid>" --limit 50 # GET /history?scope&since_ms&until_ms&limit&before_id
x0x history message <msg_id> --scope group:<gid> # GET /history/message/:msg_id (scope hint for group ids)
x0x history search "group:<gid>" <terms> # GET /history/search?scope&q= — one scope
x0x history search <terms> # GET /history/search?q= — ALL scopes
x0x history stats # GET /history/stats
x0x history purge "dm:<agent_hex>" # DELETE /history?scope= — destructive LOCAL purge
Discovery (issue #275). Start from x0x history scopes when you do not
already hold a scope string. Each row is scope (canonical),
scope_kind/scope_id (the stored columns), rows, and
newest_seen_at_ms, ordered by (scope_kind, scope_id). Page with
--after-scope <canonical> from the previous response's
next_after_scope; --limit defaults to 100 and clamps to 500. Only
scopes with retained rows appear, and counts are of retained rows only —
retention and history purge shrink them and can remove a scope entirely.
This describes local storage, never network completeness.
history search has two forms, told apart by argument count: two
positionals keep the legacy per-scope search, one positional searches every
retained scope. Both paginate on the same rowid keyset as history list
(--before-id in, next_before_id out). A malformed --scope/SCOPE
is still 400, and an empty query is still 400.
Auth: GET /history/search and GET /history/scopes are owner-only.
The ADR-0039 rider allowlist admits GET /history and nothing else under
/history, so a rider token gets 403 on both — cross-scope results and
per-scope counts never reach a rider. Rider GET /history is unchanged:
granted group: scopes only, limit clamped to 100.
5. Multi-Device Owner
5.1 Device enrollment + SyncV1 (ADR-0041)
Tiered, owner-to-owner only — sync streams run over ADR-0022 byte streams between the owner's machines, identity-gated + owner-key-signed. Never a network-served archive.
x0x sync enroll # POST /sync/devices/enroll {} — owner-key-sign a DeviceEnrollment for THIS machine
x0x sync devices # GET /sync/devices — enrolled devices + last-sync status
x0x sync revoke <machine_id> # DELETE /sync/devices/:machine_id — next stream from it is refused
- Tier 1 — replicates today: exactly four record kinds — owner profile, per-machine agent/machine names, the Home roster + policy pointer, and the sub-agent issuance journal (small signed state-commits over the sync stream; last-writer-wins by commit height).
- Tier 2 — pull-on-demand Home history: DESIGNED, NOT SHIPPED. ADR-0041 defines it, but the current SyncV1 module implements Tier 1 only; there is no peer history backfill.
GET /history?scope=group:<gid>is a purely LOCAL query against your own durable history. - Tier 3 — never replicates: non-Home group history, DM history, exec session state. Per-machine, full stop.
Enrollment is the ADR-0043 direction: the daemon holding the owner key signs the enrollment; a non-enrolled machine's SyncV1 stream is rejected at accept (verified on the testnet), and each side proves possession of the owner key by signing a fresh nonce. Trust prerequisite: SyncV1 streams ride ADR-0022 byte streams through the same stream gate as every other protocol — BOTH sides must have each other as trusted contacts, or the dial is silently refused with stream peer trust rejected: agent […] (visible in the dialer's log as Tier-1 dial skipped/failed until next pass; set trust on both sides with x0x trust set <agent_id> trusted). Cross-machine Tier-1 convergence is proven in-process; daemon-level sync sessions depend on the same certificate visibility as Home joins; per #447 the admission re-check consults the announce-blob cache directly rather than waiting for the next 600 s announce heartbeat, so a single explicit POST /announce with {"include_user_identity":true,"human_consent":true} on the second device is what makes it visible. (#449: a device with no owner-visible Home still provisions its own, but the Tier-1 Home pointer is now applied — effective_canonical_home reads the ("home") register and resolve_home reports a losing local Home as adoption_pending against canonical_group_id. Applying the pointer is not adoption: moving a device into the canonical Home is the owner-driven x0x home seat act in §3.1, and rosters are not merged.)
5.2 Placement: Pinned / Roaming (ADR-0037/0043)
Every agent on the roster carries a placement: Pinned(MachineId) (default) or Roaming. The placement ledger is owner-signed and lazily minted with ≥1 Roaming agent to satisfy Home's invariant.
x0x owner placement # GET /owner/placement — ledger + home_invariant_ok
x0x owner agents placement <id> # GET /owner/agents/:id/placement — one agent's record + fold
x0x move list # GET /agent/moves — move-log view (custodian/quiesce/placement)
The roaming-move ceremony is gated OFF in v1: /agent/move* (authorize/export/import/activate/abort/retire) and /agent/moves return 501 with a pointer to [key_move] ceremony_enabled until enabled. The founding Home agent is still nominally minted Roaming (so the ≥1-Roaming invariant holds from first provisioning), but that bit is inert — the move protocol (KEM-sealed export, commit-then-activate, binding revocation) never executes, so nothing actually roams and every other agent stays Pinned. Enforcement of placement off the owner machine is correspondingly best-effort today. Do not build against the ceremony endpoints unless you enable the flag and accept the experimental semantics.
6. Voice (1:1)
Voice is a library surface (voice crate feature), not REST: signaling rides real DMs (typed x0x-voice-sig-v1\n prefix, classified Ephemeral — never recorded to history), and audio rides ADR-0022 streams under StreamProtocol::WebRtcV1 (0x04). One stream per (direction, lane); identity gate + connect-ACL apply exactly as for every other protocol.
- Datagram lane (ADR-0042c): audio frames ride unreliable QUIC datagrams (
AudioDatagramwire framing, one datagram per frame) once both ends exchange the capability advert — with the reliable stream as fallback. The jitter buffer is mandatory on receive. - SessionConflict (single acceptor): the lane manager accepts one call session per agent — a second concurrent call is refused instead of interleaving. Typed surface:
X0xLinkTransport::start_lane()fails withVoiceLaneError::SessionConflict. Through theLinkTransporttrait'sstart()the SAME refusal surfaces today asLinkTransportError::IoError("WebRtcV1 stream acceptor already held by a concurrent call session on this agent")(the typed variant is flattened to a string there — #460); match on the message or usestart_lane()when you need the typed error. - 1:1 only today. Group calls (ADR-0042d: mesh ≤4, SFU beyond) and browser gateways are explicit follow-ups — only the 1:1 transport + example ship.
cargo run --features voice --example voice_call # full 1:1 pipeline: signaling over DMs, Opus, jitter buffer
# Rust: X0xLinkTransport::with_audio_lane_mode(AudioLaneMode::Datagram) selects the datagram lane.
Verified: docs + repo test suites (tests/voice_adapters.rs, tests/voice_e2e.rs, tests/voice_datagram_e2e.rs) — live LAN/WAN call proofs recorded in the 2026-08-30 Home Suite proof report.
7. Operations
7.1 Relay & bootstraps
Relay is an application-level fallback for DMs that cannot hole-punch (ADR-0035). x0xd --relay marks a daemon as a relay candidate: Full participation + capability advertisement. The relay header v2 (digest_support, #445) binds the RelayHeader to the inner payload — substituted-payload relays are refused, downgrades are TTL-bounded.
x0xd --relay # offer relay service (needs Full participation, not Leaf)
curl "http://$API/diagnostics/relay" -H "Authorization: Bearer $TOKEN" # advert census + dialer evidence
Bootstrap peers: 6 global nodes by default; override with bootstrap_peers = [...] in the config TOML ([] = none) or --no-hard-coded-bootstrap to drop only the embedded list. x0x network status / network cache cover connectivity and the peer cache.
7.2 Self-update
x0x upgrade --check # STANDALONE: the CLI checks/installs releases itself (does not call the daemon)
x0x upgrade --apply # standalone download + verify + install
curl "http://$API/upgrade" -H "Authorization: Bearer $TOKEN" # DAEMON surface: GET /upgrade (check)
curl -X POST "http://$API/upgrade/apply" -H "Authorization: Bearer $TOKEN" # daemon applies to the running daemon
Two separate updaters: the x0x upgrade CLI is dispatched before any daemon client exists and updates the CLI/binary on disk; the daemon REST surface updates the daemon and is governed by the daemon config. [update] enabled = false disables the daemon side (GET /upgrade → {"update_available":false,"reason":"updates disabled"}). --skip-update-check disables MORE than the check for that one daemon process — it also turns off the process's self-update install/restart paths, including POST /upgrade/apply (which then returns "self-update disabled for this process"); it composes with [update] enabled (both must allow an apply). Neither flag governs the standalone CLI updater. Verified-release manifests only. See docs/upgrade-system.md.
ℹ️ Downgrade safety (#451, fixed in v0.41.0): Home state now lives in a sidecar; a v0.40.x binary reads the legacy store and starts cleanly, so a failed upgrade that respawns the previous binary no longer crash-loops. Still back up the data dir before upgrading an owned install, and expect Home features to be absent while downgraded.
7.3 Diagnostics
x0x diagnostics <area> # connectivity|ack|gossip|transport|relay|dm|groups|history|connect|ws|exec
x0x peer probe|health|events # per-peer liveness, health snapshot, SSE lifecycle
x0x network status # NAT type, external addrs, direct capability
The complete read-only snapshot inventory is /diagnostics/connectivity
(NodeStatus: UPnP, NAT, relay, mDNS), /diagnostics/ack (ACK-v2 latency
buckets), /diagnostics/gossip (drop detection and participation),
/diagnostics/transport (connection accounting), /diagnostics/relay (ADR-0035
metering), /diagnostics/dm (DM counters and per-peer state),
/diagnostics/groups (ingest and drop buckets; ADR-0064 fork-quarantine and owner-mandate counters, plus per-agent mandate_capability rows — see the fork quarantine runbook), /diagnostics/history
(writer/reaper), /diagnostics/connect (ACL allow/deny),
/diagnostics/ws (outbound-queue health), and /diagnostics/exec (counters
and ACL summary).
The three routes detailed below are GET with no request body and require the
normal local bearer token. They return snapshots, not SLAs or proof that a peer
is reachable; a node that has not initialized the relevant runtime returns 503
with {"ok":false,"error":"..."}.
/diagnostics/ackreturns{"ok":true,"ack":{...}}(503network not initializedorACK diagnostics unavailable). Theackobject is the ant-quic ACK-v2 per-stage latency/outcome snapshot; its bucket and counter names are versioned by the installed ant-quic dependency./diagnostics/gossipreturns{"ok":true,"stats":{...},...}.statscontains publish/receive/decode/delivery/drop and in-flight deltas; the envelope also includesparticipation,subscribed_topics,outbound_by_topic_named,egress_budget,outer_signature_policy,legacy_grants_enabled(currentlyfalse),outer_v1_receipts,gossip_publish_zero_fanout,pubsub_stages,dispatcher,recv_pump, anddiscovery_cache_entries(agents,machines,users). The route returns 503gossip runtime not initializedwhen gossip has no snapshot./diagnostics/transportreturns{"ok":true,"transport":{...}}(503network node not initialized). The transport object includes active versus x0x-visible connections,peer_entries(peer_id,remote_addr), successful and failed establishments, NAT/direct/relayed counters, bootstrap totals, churn and connection-pool/read-pump counters such as open connections, buffered bytes, and orphan closures.
Use the full API reference for the route table and the versioned field context; do not interpret a non-zero counter alone as a failure.
7.4 Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Second device can't join owner's Home (no agent certificate resolved / pending) | a BODYLESS announce publishes the anonymous digest, which the owner can never resolve (#447 single-announce admission is fixed in v0.41.0) | POST /announce with {"include_user_identity":true,"human_consent":true} once, then x0x group join --home --owner <owner-user-id> <invite>; a pending join clears once the joiner's owner-issued certificate reaches the Home authority and the committed add reaches the joiner |
| Two Homes for one owner | this device holds a Home that lost the ("home") election — GET /home shows state:"adoption_pending" and names canonical_group_id | on the device holding the canonical Home, as the human with the durable token: x0x home seat <this device's agent id>, then on this device x0x group join <invite> --home --owner <owner_user_id>. The local Home stays usable meanwhile. Duplicates are read-only inventory (retirement:"manual_only") — do NOT delete one, an empty evidence_against_deletion is not a safe-delete signal |
Strict (durable-ack) DM → 409 recipient_ack_semantics_unavailable | no current usable signed, machine-bound v2 advert for the recipient after one refresh (missing/unconverged, expired, v1-only, gossip-unready, bad machine binding); v0.40.x peers interoperate via frozen v1 adverts (#448 fixed); no auto-fallback | retry later, or resend with require_durable_app_ack:false (v1 best-effort) |
| Peer rejects your agent card | #450: ownerless cards interoperate with v0.40.x; owner-named v2 cards are rejected by pre-ADR-0036 peers by design | upgrade the verifying peer |
| Daemon downgraded to v0.40.x on an owned install | #451 fixed in v0.41.0: the legacy store is readable, Home state waits in the sidecar | expected; Home features return on re-upgrade — keep a data-dir backup before upgrading |
403 rider tokens are denied on this route | deny-by-default rider scope (ADR-0039) | use a granted surface (groups/:id/send, secure/encrypt, GET /history) or act as the owner |
403 ... Home must be delegated explicitly | rider token's groups list lacks the Home gid (no implicit grant) | re-mint the token with the Home group id in groups (and in the signed capability) |
409 on /owner/* or /sync/* | install has no owner key | x0x user-id create, restart daemon |
404 no placement record cached on GET /owner/agents/:id/placement (even ownerless) | the route is durable-owner gated (403 for a session token), but after auth it performs no owner-key/mint-presence check — it only reads the cached placement record, which exists after the lazy mint / a seen bundle, on owned and ownerless installs alike | create the owner key where THIS daemon loads it, restart, then run the lazy mint before retrying: default install x0x user-id create (~/.x0x/user.key); named instance x0x --name <name> user-id create (~/.x0x-<name>/user.key) and keep --name <name> on every later command; daemon configured with user_key_path/identity_dir needs the explicit path (x0x user-id create <user_key_path> or <identity_dir>/user.key — it never falls back to ~/.x0x). Then x0x owner placement (the lazy mint happens only on that route's first read — creating the key alone records nothing), then retry |
501 on /agent/move* | ceremony gated off in v1 | leave it off; placements don't move (founding Home agent is nominally Roaming, inert) |
Join then immediate post → 403 members-only | membership commits asynchronously | poll GET /groups/<gid>/members until your id is active |
sub-agent lacks the required roster role | rider scope granted but sub-agent not a member | add the sub-agent to the group (TreeKEM adds need its key package) |
recipient_ack_semantics_unavailable (same fleet) | peer's capability advert not cached yet | the daemon publishes ONE bounded capability refresh before refusing — if the 409 still comes back, YOU retry the send (it is not retried for you); check /diagnostics/dm |
409 fork_quarantined on group send / secure / TreeKEM routes | ADR-0064: this node holds authenticated fork evidence for an owner-axis group (persistent per-node marker on GET /groups/:id; snapshot classification names owner_anchored_conflict / signer_only / unauthorized_signer) | let the owner anchor advance past the evidence revision (adoption, mandate-carrying apply, or an explicit owner-key state/seal), or run the manual clear per the fork quarantine runbook — x0x groups quarantine clear <id> on the owner-key node, else --force --reason; every clear re-arms, so resolve the divergence, don't just force |
409 owner_mandate_missing on a group event (retryable) | ADR-0064 §1b: post-grace absent mandate from an authority agent whose capability was observed (mandate_capability rows on /diagnostics/groups: state:"refusing", first_seen_ms, refusals) | fix the AUTHORITY — it needs the group's owner user key to mint mandates (upgrade/re-key it); then retry; never-observed (keyless) authorities warn-accept and never hit this |
7.5 Configuration (TOML) & storage
bind_address = "0.0.0.0:0" # QUIC port (0 = random)
api_address = "127.0.0.1:12700" # REST API (loopback by default — see §1.3 before binding wider)
log_level = "info" # trace|debug|info|warn|error
log_format = "text" # text|json
bootstrap_peers = [] # unset = the 6 global bootstraps; [] = none
heartbeat_interval_secs = 300 # re-announce identity
identity_ttl_secs = 900 # expire stale discoveries
rendezvous_enabled = true # global findability
network_id = "x0x.prod" # gossip plane isolation ("" = open)
port_mapping_enabled = true # UPnP IGD mapping
mdns_enabled = true # ant-quic LAN discovery + auto-connect.
# false = hermetic (no LAN advertise/browse);
# network_id only NAMESPACES mDNS, it does not
# disable it. Test fixtures set false.
observed_prefix_enabled = false # masked origin prefix on DM surfaces
# identity_dir = "/srv/x0x/identity" # keep ALL identity material (machine.key, agent.key, agent.cert,
# # and the opt-in user.key lookup) out of ~/.x0x — with this set the
# # daemon never falls back to ~/.x0x (the embedding storage boundary)
# user_key_path = "/srv/x0x/user.key" # explicit owner key file (opt-in; never auto-generated).
# # Overrides <identity_dir>/user.key when both are set
# zero_peer_restart_secs = 600 # TOP-LEVEL KEY (keep it ABOVE the first [section] or it
# # lands in the wrong table!). SUPERVISOR-ONLY (systemd
# # Restart=always): exit at zero peers so the supervisor
# # restarts us. Default OFF; unsupervised, it just dies.
[update] # daemon self-update (the CLI updater is separate — §7.2)
enabled = true
[history] # ADR-0023 durable local history
enabled = true # db at <data_dir>/history.db
[gossip] # overlay tuning
[peer_relay] # DM relay fallback (opt-in)
enabled = false
candidates = [] # relay-candidate hex agent ids
[key_move] # ADR-0043 roaming moves — experimental; 501 while false
ceremony_enabled = false
[groups] # ADR-0064 owner-mandate enforcement
mandate_grace_days = 60 # grace before a recorded-capable authority's mandate-less
# MemberAdded is refused (owner_mandate_missing). Default 60
# (one release cycle); validated >= 1 — x0xd refuses to start on 0.
~/.x0x/machine.key machine · agent.key agent · user.key owner (opt) · owner.json owner singleton
(all relocated by `identity_dir`; `user_key_path` relocates the owner key + its sibling owner.json)
~/.x0x-skilltest/... named instances: ~/.x0x-<name>/
<data_dir>/ api.port · api-token · contacts.json · history.db · mls_groups.bin · named_groups.json
home.json (Home marker) · owner-cert-journal.jsonl · rider-tokens.json (hashed) · peers/bootstrap_cache.json
Default data_dir: Linux ~/.local/share/x0x/ · macOS ~/Library/Application Support/x0x/ · named: -<name> suffix
7.6 Error responses
400 Bad Request {"ok":false,"error":"invalid hex: ..."} # your input is wrong
401 Unauthorized {"error":"missing or invalid Authorization: Bearer token"}
403 Forbidden {"error":"agent is blocked"} / rider deny-by-default
404 Not Found {"ok":false,"error":"group not found"}
409 Conflict {"ok":false,"error":...} # no owner key; typed DM refusal; join races
422 Unprocessable owner_required (unanchored Signed-store join) etc.
501 Not Implemented ceremony gated off ([key_move] ceremony_enabled = false)
8. Capability Matrix
Status: GA = working as specified · caveat #N = open issue, see §7.4 · gated off = endpoint present, disabled in v1.
| Capability | REST | CLI | Status |
|---|---|---|---|
| Gossip pub/sub + SSE | /publish /subscribe /events | x0x publish/subscribe/events | GA |
| Direct messages (durable ACK) | /direct/send /direct/events | x0x direct send/events | GA |
| Identity + names | /profile /agent /announce | x0x profile set agent | GA |
| Owner key + roster | /owner/agents(+/issue,/:id) | x0x user-id create owner agents | GA |
| Home space | /home /home/rename /home/seat | x0x home home rename home seat | GA · elected canonical Home (ADR-0060); owner-driven seating implemented, #449 not yet runtime-accepted |
| Sub-agents (ACP + rider) | /owner/agents/issue /owner/riders* | x0x owner agents issue/revoke · owner riders issue --delegation-payload-b64/--delegation-signature (mint) / list / revoke | GA |
| Rider deny-by-default scopes | middleware (403 matrix) | — | GA |
| Session tokens read-mostly | /auth/session | — | GA (owner-act routes refuse session tokens since v0.41.0) |
| Named groups + policy + discovery | /groups* | x0x group ... | GA |
| Public messages + threads | /groups/:id/send /messages (thread_root/thread_parent) | x0x group send --thread-root/--reply-to | GA |
| Structured mentions | /groups/:id/send mentions:[...] | x0x group send --mentions <hex>... [--delegation-digest <hex>] | GA |
| MLS/TreeKEM encryption | /mls/groups*, /groups/:id/secure/* | x0x groups, group secure-* | GA · cards #450 |
| Delegation (send-as / task-execute) | /groups/:id/delegate(+/delegations) | x0x group delegate | GA |
| Task lists (CRDT) | /task-lists* | x0x tasks ... | GA |
| KV stores (CRDT) | /stores* | x0x store ... | GA |
| File transfer | /files/* | x0x send-file/transfers | GA |
| Remote exec | /exec/* | x0x exec | GA (fail-closed ACLs) |
| Tailnet forwards + streams | /forwards /streams | x0x forward/streams | GA |
| Presence + FOAF | /presence/* | x0x presence ... | GA |
| Contacts, trust, machine pinning | /contacts* /trust/evaluate | x0x contacts/trust/machines | GA |
| Agent cards / A2A | /agent/card* /.well-known/agent-card.json | x0x agent card/import | GA · #450 |
| Sign/verify (external DST) | /agent/sign /agent/verify | x0x agent sign/verify | GA · #446 (session) |
| Device enrollment + SyncV1 | /sync/devices* | x0x sync enroll/devices/revoke | GA (Tier-1) · sessions #447 |
| Placement ledger | /owner/placement | x0x owner placement | GA (read) |
| Roaming move ceremony | /agent/move* /agent/moves | x0x move ... | gated off (501) |
| Relay (header v2, digest-bound) | --relay + /diagnostics/relay | — | GA |
| Voice 1:1 (datagram + fallback) | library (voice feature) | --example voice_call | GA (lib) · 2nd concurrent call refused (typed SessionConflict via start_lane; IoError-wrapped via trait start()) |
| Diagnostics (11 areas) | /diagnostics/* | x0x diagnostics <area> | GA |
| Durable history | /history* | x0x history scopes/list/message/search/stats/purge | GA (local-only; Tier-2 Home backfill designed, not shipped — §4.10, §5.1) |
| Self-update | daemon: /upgrade(+/apply) · CLI: standalone | x0x upgrade --check/--apply | GA |
Architecture
Your Machine Their Machine
============ =============
Claude / AI ──> x0xd REST API x0xd REST API <── Claude / AI
| |
x0x Agent x0x Agent
| |
saorsa-gossip saorsa-gossip
| |
ant-quic ant-quic
| |
+─── gossip (broadcast) ─+
+─── direct (private) ───+
Reference Documentation
- Full API Reference — every route + request/response shapes
- Security & Cryptography · Diagnostics · SDK Quickstart · Ecosystem · Vision
- ADRs: 0036 owner+naming · 0037 placement · 0038 Home · 0039 harness · 0040 delegation · 0041 sync · 0042 voice · 0043 key-move
Contributing
git clone https://github.com/saorsa-labs/x0x.git && cd x0x
cargo build --all-features && cargo nextest run --all-features
Links
- Repository: https://github.com/saorsa-labs/x0x · Contact: david@saorsalabs.com · License: MIT OR Apache-2.0
A gift to the AI agent community from Saorsa Labs and the Autonomi Foundation.
Top skills in this category
Skill Vetter
@spclaudehomeSecurity-first skill vetting for AI agents. Use before installing any skill from ClawdHub, GitHub, or other sources. Checks for red flags, permission scope, and suspicious patterns.
Skill Vetter - Pre-Install Security Review
@donovanpankratz-delSecurity vetting protocol before installing any AI agent skill. Red flag detection for credential theft, obfuscated code, exfiltration. Risk classification L...
Skill Vetter 1.0.0
@fedrov2025Security-first skill vetting for AI agents. Use before installing any skill from ClawdHub, GitHub, or other sources. Checks for red flags, permission scope,...
Clawdhub
@steipeteUse the ClawdHub CLI to search, install, update, and publish agent skills from clawdhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders with the npm-installed clawdhub CLI.
Evolver
@autogame-17A self-evolution engine for AI agents. Analyzes runtime history to identify improvements and applies protocol-constrained evolution. Communicates with EvoMap...