skip to content

Sessions, Services & Subagents

xmsg routes messages between three kinds of recipients: instances (interactive AI agent sessions), services (headless daemons and bridges), and subagents (delegated child turns spawned under a parent conversation).

An instance is a live or parked conversation in an agent harness:

  • Persistent conversation IDs (claude, agy, codex): The instance ID is the harness’s own conversation UUID. When a conversation closes or pauses between turns, its ID remains parked in SQLite for the reply TTL (--reply-ttl, 7 days by default) so inbound messages queue for its next turn.
  • Process-bound session IDs (pi, opencode, hermes): xmsg mints a fresh UUID v4 on registration. When a Pi process exits or an opencode/hermes session is deleted/finalized, its address transitions to gone (410).

Concurrent processes and fork suffixes (instance_claims)

Section titled “Concurrent processes and fork suffixes (instance_claims)”

When multiple live processes present the same Claude conversation ID X (either in the same ~/.claude/sessions directory or across multiple --sessions-dir tenant directories), xmsg manages them in the persistent SQLite instance_claims table:

  • The first observed live process presenting X claims plain X (suffix 1).
  • Subsequent live processes presenting X receive monotonic fork suffixes X~2, X~3, … in observation order (ties broken by (procStart, pid) within a scan).
  • Suffixes persist across xmsg serve restarts and are never promoted while live.
  • When a node observes a second live process for an already-live ID, it sends a one-time fork notice to each process informing them of both addresses (X@node and X~2@node).
  • Once an X~n fork process exits, its claim is released and sends to X~n return 410 gone. If plain X is released, the next process presenting X claims plain X; numeric suffixes ~2, ~3, … are never reused while records exist.

Headless daemons, bots, guards, and bridges register bare service names (such as genie-guard, matrix-xmsg, or xmsg-gateway) over register.sock:

/// Validates a service name across consumer configuration (`xmsg::client::validate_service_name`),
/// service registration (`register.sock` in `src/svc.rs`), subagent names (`validate_subagent_name`),
/// and origin checking (`check_origin` in `src/fed.rs`):
/// - non-empty and at most 32 bytes;
/// - not the reserved `"xmsg"`;
/// - no collision with the instance or subagent namespace (`is_instance_ref`, `split_subaddress`, or `'/'`);
/// - no forbidden address/delimiter characters (`@`, `·`, `~`, `.`, `:`, `/`) and only ASCII `[A-Za-z0-9_-]`.
pub fn validate_service_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("service name must not be empty".to_string());
}
if name.len() > 32 {
return Err(format!(
"service name '{name}' exceeds maximum length of 32 bytes"
));
}
if name == "xmsg" {
return Err("service name 'xmsg' is reserved".to_string());
}
if is_instance_ref(name) || split_subaddress(name).is_some() {
return Err(format!(
"service name '{name}' is UUID-shaped or collides with the instance/subagent namespace"
));
}
if name.contains('@')
|| name.contains('·')
|| name.contains('~')
|| name.contains('.')
|| name.contains(':')
|| name.contains('/')
{
return Err(format!(
"service name '{name}' contains forbidden character (@, ·, ~, ., : or /)"
));
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err(format!("invalid service name '{name}'"));
}
Ok(())
}
  • Executable attestation (--svc-exe): On normal host nodes, xmsg serve requires --svc-exe <NAME>=<PATH> (or XMSG_SVC_EXE). When a process connects to register.sock and sends {"harness":"svc","name":"<NAME>"}, xmsg checks the peer UID (peer_uid == server_uid) and verifies that the canonical executable path (/proc/<pid>/exe on Linux, proc_pidpath on macOS) matches <PATH>. Only one live process may hold a service name at a time.
  • At-least-once long-poll and ack: Services pull messages with {"action":"poll","waitSecs":30} on register.sock and acknowledge each delivery with {"action":"ack","messageId":"..."} after committing it to their own durable store. Unacknowledged messages remain queued (tracked in the connection’s in_flight set) and redeliver in FIFO order if the service disconnects before acknowledging.
  • Structured origin attestation: Every delivered frame includes an attested origin struct (id, service, node, label) so services make authorization decisions from structured fields rather than parsing display headers:
/// Where a message came from, one shape for every sender (A12): an instance's `id` or a
/// service's name, the `node` that attests it, and the sender's own `label`. An anonymous
/// sender has neither `id` nor `service`; its `label` is its unattested `from`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Origin {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
pub node: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}

See Daemon Protocol & Rust Client for the wire frames and the xmsg::client library.

When a parent conversation P spawns child subagents, each bound child gets a delegated sub-address <P>/<child>@<node> where <child> is either the subagent’s bound name or its harness agent_id / child session ID. Both <P>/<name> and <P>/<agent_id> resolve to the same bound subagent, and list includes live bound subagents with kind: "subagent".

  • Active turn (inbox): While a subagent’s turn is active (turn_pid live with matching start time), messages and replies addressed to <P>/<child>@<node> queue in its turn mailbox (outcome: "queued"). The subagent drains them during its turn by calling inbox(wait?), which returns {address, messages: [{id, from, body, created_at}]} and marks each drained message delivered.
  • Parent fallback (to=<P>/<child> agent_id=<id>): When the subagent’s turn is not active (between turns or after SubagentStop / Stop), messages and replies addressed to <P>/<child>@<node> fall back to the parent session P with a to=<P>/<child> agent_id=<agent_id> header prepended to the message body. If P is currently parked, the message stays queued on <P>/<agent_id> and is either drained by the subagent on its next inbox call or delivered to P with the fallback header when P returns.
HarnessSub-address FormAttribution & Lifecycle
Claude Code<P>/<name>@<node> and <P>/<agent_id>@<node>xmsg register claude --hook on SubagentStart, PostToolUse (Agent/SendMessage), SubagentStop, and PreToolUse (stamping as_agent).
Codex<P>/<X>@<node> (direct children where parent_thread_id == P)Attributed from runtime params._meta (sessionId, threadId); turn state tracked by SubagentStart, PreModelRequest, and SubagentStop hooks.
Antigravity<P>/<child_uuid>@<node>Admitted from P’s transcript.jsonl (invoke_subagent) and tracked by xmsg register agy --hook (pre-invocation, pre-tool-use, stop).
opencode (1.x & 2.x)<P>/<child_ses>@<node>Child sessions walk parentID to root P, bind via subagent (start/stop/end) on register.sock, and pass as_agent on xmsg_send, xmsg_reply, and xmsg_inbox.
HermesRoot <P>@<node>delegate_task subagents act as their root conversation P and do not bind separate sub-addresses.
PiNoneSingle-session process; no subagent sub-addresses.
palette
dark
light
↑↓ select↵ apply esc close

Palettes adapted from Catppuccin (Macchiato) (MIT), Tokyo Night (Apache-2.0), gruvbox (MIT), Catppuccin (Latte) (MIT), Rosé Pine (Dawn) (MIT).