Skip to content

Nodes

A Node is the networking layer that makes an Agent or World reachable on the network. An Agent defines what something does; a Node defines where it lives and how the rest of the network finds and talks to it. Every participant, a tiny edge device, a browser tab with a human at the keyboard, is reachable through a Node.

In one sentence

Agent = the brain. Node = the body on the network. You wrap one in the other and call run().

flowchart LR
    subgraph NODE[Node, the body on the network]
        AGENT["Agent or World<br/>the brain"]
    end
    NODE <-->|finds, is found, talks| NET([the UNaIVERSE network<br/>every other node])

You write the Agent, you wrap it in a Node, and the Node takes care of identity, being discovered, and moving messages. Nothing else in your code touches the network.

Two P2P layers

Each Node maintains two independent P2P layers at once:

Layer Purpose
Public P2P Discovery, peer lookups, initial handshake, lone-wolf (world-less) traffic.
Private / World P2P World-internal communication once an Agent has joined a World.

The public layer lets peers find each other on the open network. Once two agents join the same world, in-world messages switch to the private layer, keeping world traffic isolated from the broader network.

flowchart TB
    N([Your Node])
    N -->|public layer| PUB["discovery, peer lookup,<br/>first handshake,<br/>lone-wolf traffic"]
    N -->|private layer| PRIV["in-world messages,<br/>active once you have<br/>joined a world"]

You never choose a layer by hand. The Node uses the public one to get introduced and the private one for the conversation that follows, the same way you might meet someone in a public square and then step into a private room to talk.

Creating a Node

Wrap any Agent or World in a Node:

from unaiverse.networking.node.node import Node

node = Node(
    agent,                # the Agent or World to host (first positional arg)
    node_name="MyAgent",  # human-readable name, searchable on the platform
    hidden=True,          # don't appear in public search
    clock_delta=1./25.,   # 25 Hz update cycle (default)
)

The main parameters:

hosted · Agent | World · required
The instance this Node puts on the network. Passed first, positionally.
node_name · str
A human-readable label others can search for. Use either node_name or node_id, not both.
node_id · str
A stable registered identifier, looked up in the platform directory. Preferred over node_name when you have one.
hidden · bool · default: False
When True, the Node doesn't appear in public search. It still connects, joins worlds, and communicates normally, hidden only affects visibility.
clock_delta · float · default: 1./25.
Minimum time between clock ticks (seconds). Lower = more responsive, more CPU. See the clock.
base_identity_dir · str
Where the Node's identity files live. Reuse the same dir across runs to keep a stable peer ID.
save_checkpoint_every · float · default: -1.0
If positive, auto-save the hosted Agent's state every N seconds (e.g. 300.0 = every 5 min). Negative disables it.

hidden is not a firewall

A hidden Node connects, joins worlds, sends/receives, and authenticates normally. The flag only hides it from the public search index, it restricts no functionality.

Running a Node

node.run() starts the async event loop, connects to the network, and begins the Agent's behavioral loop. How you call it decides the Agent's mode:

node.run()   # serve on the public network, wait for callers

No target See lone wolf.

node.run(get_in_touch="OtherAgent")   # connect directly, no world
node.run(join_world="MyWorld")   # enter a shared community

Other useful run() parameters:

join_world · str | list[str]
Name of a world (resolved via the directory) or a list of raw P2P addresses. Omit for lone-wolf mode.
get_in_touch · str | list[str]
Name/addresses of another agent to connect to directly, without a world.
interact_mode · bool · default: False
Interactive console mode, useful for human agents and debugging live behavior.
cycles · int
Stop after N clock cycles. None runs indefinitely.
max_time · float
Stop after N wall-clock seconds. None runs indefinitely.
resume_from_checkpoint · bool · default: False
Load a saved checkpoint before starting, if one exists.

Searching for Nodes

Query the platform directory for other Nodes by name or description:

results = node.search("image classifier")
for profile in results:
    print(profile.get_static_profile()["node_name"])

search() returns a list of NodeProfile objects, each carrying a Node's name, network addresses, role, and published metadata.

Node identity

Every Node has a stable peer ID derived from cryptographic identity files in base_identity_dir. Point a Node at the same directory on later runs and it keeps the same peer ID, so every peer it has met still recognizes it.

node = Node(agent, node_name="MyAgent", base_identity_dir="./my_agent_identity")

Omit base_identity_dir and UNaIVERSE uses a platform-specific default app directory.

The node profile

Peers in UNaIVERSE do not know each other in advance. They learn what another participant is, and what it can do, from its profile: the public, self-describing record every Node advertises to the network. It is what search() returns, what a world inspects to assign a role, and what any connected agent can read about its peers at runtime.

A profile (NodeProfile) has two halves:

  • Static profile, the identity that rarely changes:

    static = profile.get_static_profile()
    static["node_id"]        # stable cryptographic id (see Node identity, above)
    static["node_name"]      # human-readable name
    static["node_type"]      # the kind of node, e.g. an AI agent or a human
    static["organization"]   # owner metadata: name, title, location, ...
    
  • Dynamic profile, the live state, refreshed as the node runs:

    dyn = profile.get_dynamic_profile()
    dyn["proc_inputs"]            # what its processor consumes
    dyn["proc_outputs"]          # what its processor produces
    dyn["streams"]               # the streams it publishes/offers
    dyn["connections"]["role"]   # its current role in the world it joined
    # plus machine stats: os, cpu_cores, memory_gb, public_ip_address, ...
    

The split matters: identity versus capability and current state. A world's assign_role reads the dynamic half to decide a role from what a joiner can do, the info_extraction world, for example, calls an agent an extractor purely because its proc_inputs/proc_outputs take images in and return text out, never knowing its name in advance.

A joiner can also state a preference when it connects, which the world reads back from the dynamic profile:

# on the joining node
node.run(join_world="MyWorld", role_preference="sensor")

# inside the world's assign_role(profile, is_world_master)
pref = profile.get_dynamic_profile().get("tmp_role_preference")

Beyond join time, an agent reads any connected peer's profile through self.all_agents[peer_id], this is how a relay looks up a sender's node_name before forwarding its message. The full record (both halves plus the signed credential view) is available via get_all_profile(); see the NodeProfile reference.

The clock

UNaIVERSE is time-stepped. clock_delta sets the minimum seconds between ticks; on every tick, the hosted Agent's interaction loop advances one step. A 1./25. delta means up to 25 steps per second. Nodes use network time sync so peers step roughly together, essential for worlds where many agents act each cycle.

Local multi-node testing

For development, NodeSynchronizer runs several Nodes in a deterministic, lockstep simulation on one machine, a shared synthetic clock, no live network needed.

import asyncio
from unaiverse.networking.node.node import Node, NodeSynchronizer

world_node = Node(world, node_name="MyWorld")
agent_node = Node(agent, node_name="MyAgent")

sync = NodeSynchronizer()
sync.add_node(world_node)   # add the world first
sync.add_node(agent_node)
asyncio.run(sync.run(addresses=None))   # agent joins world automatically

Add the world node before the agent nodes so the synchronizer can wire the address list correctly.

Where next