Discover Capabilities
Query the mesh by what you need, not by who has it. There is no registry to ask and no address to know: every node folds the announcements it hears into a local index, and discovery is a read of that index.
Two surfaces, two questions
Filter nodes answers which machines fit — GPUs with enough VRAM, a region, a model, a tag you invented. It returns node ids. This is placement.
List tools answers what can I call — the named, schema'd operations peers have announced. It returns descriptors. This is the agent surface, and it is what lowers into an LLM's tool array.
The same announcement can feed both.
Discovery is a local read
Both surfaces read the node's own folded index. Nothing goes on the wire when you call them, which has three consequences worth holding onto:
- They are fast and synchronous in the bindings whose types allow it. A discovery call is not a network round trip.
- They can be empty and correct. An announcement that has not arrived yet is indistinguishable from one that was never made.
- They are a snapshot. The answer was true when the index last changed, not necessarily now.
Folding takes a moment — do not poll for it
An announcement propagates asynchronously, and multi-hop: a match can come from a node several hops away that you have never connected to. So the index right after a peer announces is often still empty.
The wrong fix is a polling loop. Every binding exposes a watch: take a list
baseline, then subscribe, and changes are pushed the moment the fold mutates. It is
event-driven off the fold's change signal, so an idle mesh costs zero periodic
work.
Each binding's watch takes an optional interval. It is a staleness ceiling, not a poll rate — a safety-net re-diff at least that often. Leave it unset for pure event-driven behaviour. Reading it as "how often to check" produces a timer nobody needed.
Discovery is advisory
Finding a node that can do something gets you no claim on it. There is no exclusivity in a discovery result, no reservation, and no promise the node will still be there when you call.
If you need to atomically claim a contended exclusive resource — a GPU island, an
accelerator slot, a licensed seat — that is the gang-claim scheduler, not
find_nodes. Using discovery as a booking system is how two callers end up
believing they own the same device.
Seeing is not calling
A capability you can discover is not a capability you can invoke. Visibility and invocability are separate, and the check happens on invoke — see Errors for what a refusal looks like when it arrives.
Richer predicates (numeric, semver, AND/OR/NOT), the scoping model, and the CLI
equivalent (net-mesh cap query --tag …) are in
Capabilities.
Discover it — Python
Find nodes
Node discovery is on the wrapper:
ids = node.find_nodes({"require_tags": ["gpu"]})
best = node.find_best_node({"filter": {"require_tags": ["gpu"]},
"prefer_more_vram": 1.0})find_nodes returns a list, possibly empty. find_best_node returns one id or
None — and 0 is a real node id, so test is None rather than truthiness.
List tools
The tool surface is separate and still takes the native handle:
from net_sdk import list_tools
native = node._native # tool surface only
for t in list_tools(native):
print(t.tool_id, "v" + t.version, "tags=", t.tags)Passing the net_sdk.MeshNode raises AttributeError — the wrapper does not
carry the tool surface.
Schemas come back as JSON-encoded strings on descriptor.input_schema and
descriptor.output_schema. json.loads them before use.
Watch for changes
watch_tools is an async iterator, so it needs an event loop even if the rest
of your program is synchronous:
import asyncio
from net_sdk import list_tools, watch_tools
async def follow(native):
for t in list_tools(native): # baseline, synchronous
print("baseline", t.tool_id)
async for change in watch_tools(native):
match change.type:
case "added": print("+", change.descriptor.tool_id)
case "removed": print("-", change.descriptor.tool_id)
case "node_count_changed":
print("~", change.descriptor.tool_id, change.prev_node_count,
"->", change.descriptor.node_count)
asyncio.run(follow(native))interval= is a debounce ceiling in seconds — Python takes seconds where
TypeScript takes milliseconds and Go takes a time.Duration. Leave it unset for
pure event-driven behaviour.
The subscription is taken when watch_tools is called, not on the first
iteration, so a change published between the two is still observed. That also
means the returned iterator holds a live substrate watch: consume it, or break out
so its finally closes it.
Filtering nodes by capability
peers = native.find_nodes({"require_tags": ["gpu"], "min_vram_gb": 24})find_nodes is on the native handle too, and takes a plain dict rather than a
typed filter object. The predicate model is identical across bindings — see
Capabilities for the full surface and the CLI
equivalent.
Picking one node
target = native.find_best_node({
"filter": {"require_tags": ["gpu"]},
"prefer_more_vram": 1.0,
})find_best_node applies the requirement's weights and returns one winner
instead of the whole matching set. The four weights — prefer_more_memory,
prefer_more_vram, prefer_faster_inference, prefer_loaded_models — each
score one axis of what a candidate announced about itself: system memory, GPU
VRAM, model inference speed, and the share of its models already loaded. Every
key of the dict is optional. They must be finite: nan and inf raise
ValueError, a
non-numeric weight raises TypeError, and finite values outside [0.0, 1.0]
are clamped by the substrate. Ties, including the case where every weight is
omitted, resolve to the lowest matching node id.
None means nothing matched. 0 is a real node id, so test is None rather
than truthiness. find_best_node_scoped(requirement, scope) applies the scope
first, so a peer outside it cannot win on capacity.
Same local-index read as find_nodes: synchronous, no network, and only peers
whose announcements have already arrived. AsyncNetMesh carries all four
methods and they stay synchronous there — awaiting the returned list or int
raises TypeError.
Verify it worked
assert any(t.tool_id == "web_search" for t in list_tools(native)), \
"web_search did not fold — is the pair handshaked and started?"Next: Invoke a capability.