Invoke a Capability
Discovery returns candidate providers visible to the caller. Invocation addresses one provider or a service name, applies provider-side admission, and returns a typed result or failure.
Two ways to address a call
By node id. You pin a specific provider. Use this when the provider holds a session or other state the caller already selected.
By service or tool name. The mesh selects from providers that advertise it. This allows another eligible provider to be selected after the current provider becomes unavailable.
Prefer name-addressed calls unless you have a reason to pin. The reason is usually state — a provider holding a session you already started.
The tool call and the RPC call are the same call
call_tool and its equivalents are sugar over nRPC. A tool call is a
name-addressed, JSON-coded nRPC request whose service name is the tool id. When you
want request/response without the tool abstraction — your own service name, your
own codec — use nRPC directly. Nothing is lost by dropping down and nothing extra
is gained by staying up.
Both paths carry deadlines. Both surface the same typed failures.
Deadlines are a caller-side promise about waiting, not a cancel
A deadline bounds how long you wait. When it elapses the caller does emit a cancel for that call id, so a cooperating provider can drop the in-flight handler — but "can drop" is not "did not run." When a deadline elapses you learn that no answer arrived in time. You do not learn whether the work happened.
That distinction is the whole of ambiguous execution, and it is the reason a retry is not free: retrying a call whose deadline elapsed may run the work a second time. Make the operation idempotent, or carry an idempotency key, or accept the duplicate. This is not a Net peculiarity; it is what a network deadline means everywhere, stated here because the typed API makes it easy to forget.
Provider admission happens at invocation
Seeing a capability does not grant the right to invoke it. Discovery may itself be scoped, but provider admission is still evaluated for the call.
The provider enforces scope at call time, against the authenticated caller origin. An owner-only capability refuses a caller outside its scope regardless of who can see it in the fold. A grant that has expired or been revoked stops working at the next call, not at the next announcement — there is no cached permission to go stale.
What a caller gets back is a typed denial, not a silence and not a generic error: a distinct failure that says the authority check refused, so your code can tell "you may not" apart from "nobody answered." The four bindings name it differently; Errors has each one and demonstrates the refusal.
For wrapped MCP tools this is the owner-scope and consent model in Wrap an MCP Server and Expose Net as MCP. Deadlines, cancellation and streaming in depth are in Typed RPC with nRPC.
Invoke it — Go
Call a tool
import "context"
type WebSearchReq struct {
Query string `json:"query"`
}
type WebSearchResp struct {
Results []string `json:"results"`
}
raw, err := net.NewMeshRpc(node)
if err != nil {
log.Fatal(err)
}
rpc := net.NewTypedMeshRpc(raw)
resp, err := net.CallTool[WebSearchReq, WebSearchResp](
context.Background(),
rpc,
"web_search",
WebSearchReq{Query: "how does the capability fold work"},
)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Results)CallTool[Req, Resp](ctx, rpc, toolID, request) takes the *TypedMeshRpc.
NewMeshRpc gives you a *MeshRpc; the extra NewTypedMeshRpc wrap is not
optional and the compiler will say so.
CallToolStreaming[Req] returns a *ToolEventStream for tools that emit multiple
chunks — drain it with Recv() until ok is false.
Deadlines are the context
Go is the only binding where the deadline is not an option field. It is the
context.Context you already have:
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
resp, err := net.CallTool[WebSearchReq, WebSearchResp](ctx, rpc, "web_search", req)Cancelling the context mid-stream closes the underlying stream and emits CANCEL on
the wire, so ctx is the cancellation path as well as the deadline.
Serve and call over nRPC directly
handle, err := net.TypedServe[SummarizeReq, SummarizeResp](
rpc, "summarize",
func(req SummarizeReq) (SummarizeResp, error) {
return SummarizeResp{Summary: req.Text[:min(40, len(req.Text))]}, nil
},
)
if err != nil {
log.Fatal(err)
}
defer handle.Close()
resp, err := net.TypedCall[SummarizeReq, SummarizeResp](
ctx, rpc, providerNodeID, "summarize", SummarizeReq{Text: "…"},
)TypedCall pins a node id; TypedCallService resolves by service name through the
capability index, which is what makes failover to a standby possible. CallTool
is a thin wrapper over TypedCallService.
A typed application error
body := []byte(`{"error":"invalid_request"}`)
return SummarizeResp{}, net.AppError(net.NrpcTypedBadRequest, body)AppError(code, body) is how a handler returns a typed application status rather
than an opaque failure. Any other error a handler returns surfaces as
Internal, so a handler that returns a bare fmt.Errorf has thrown away the
distinction the caller needs to decide whether to retry.
On the caller side, Go does not give you the code and body as fields.
RpcError carries Kind (a coarse transport-level discriminator: no_route,
timeout, server_error, transport, codec_encode, codec_decode,
capability_denied, unknown) and Message. The application status and body
arrive inside Message, so a Go caller that needs to branch on them has to parse
the string. Rust preserves the structured shape.
Go also has no cancelled kind. A context cancellation surfaces as Go's own
context.Canceled or context.DeadlineExceeded from the call, not as an
RpcError — so check the context error first and errors.As into *RpcError
second.
TypedServe already does this for you on a request that will not unmarshal: the
caller gets Application(NrpcTypedBadRequest) with
{"error":"invalid_request","detail":…}, which is the same contract every binding
on this spine implements.
Verify it worked
resp, err := net.CallTool[WebSearchReq, WebSearchResp](
ctx, rpc, "web_search", WebSearchReq{Query: "ping"},
)
if err != nil {
log.Fatal(err)
}
if len(resp.Results) == 0 {
log.Fatal("the provider answered, but with nothing")
}
fmt.Printf("invoked: %d result(s)\n", len(resp.Results))The Go binding has no live-mesh RegisterTool + CallTool test yet — the
descriptor, merge and codec helpers are unit-tested and the round trip is not. Run
this against a Rust or TypeScript provider if you want the strongest signal.
Next: Watch the event stream.