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_nameornode_id, not both. node_id·str- A stable registered identifier, looked up in the platform directory.
Preferred over
node_namewhen 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,hiddenonly 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:
No target See lone wolf.
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.
Noneruns indefinitely. max_time·float- Stop after N wall-clock seconds.
Noneruns 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.
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:
-
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¶
- Agents, what a Node hosts.
- Interactions, what happens each clock tick.
- Worlds, a Node that coordinates others.
-
NodeAPI reference.