This directory holds the SDKs that let an agent join a SAM mesh from inside
its own process, without a sam-node sidecar. Two languages are in scope:
js/—@sam-mesh/sdk, TypeScript for Node.js, built on js-libp2p.python/—sam-mesh(importagent_mesh), built on py-libp2p.
The motivation is issue #480. The
sidecar works, but it makes every agent deployment manage a second process,
a local port and a token, which does not fit serverless platforms and splits
tracing between the agent and the network layer. An earlier attempt at a
Python package (sam-mcp-python) only wrapped the sidecar's HTTP API and was
removed in favour of this work.
Both SDKs speak the mesh protocol directly. There is no SDK-specific
endpoint anywhere in the mesh; a member built with an SDK is, to every other
member and to the control plane, a peer like a sam-node.
An SDK member is an agent, not a service provider. It calls what the mesh
offers: MCP tools, inference and A2A services that sam-nodes publish and
the discovery table names. Inbound, it accepts one thing: A2A requests for
itself, as a2a://<name>, reached by peer ID through a router. It publishes
no service, writes no provider record and reports no catalog; a tool, a
model or a named agent that others should find by name runs behind a
sam-node. The reasons are in Agents, not services.
Milestones 1 to 5 are implemented and tested in both languages:
| Capability | JS | Python |
|---|---|---|
| ed25519 identity, libp2p key encodings, peer ID | yes | yes |
Enrollment with a bootstrap token (POST /enroll, GET /enroll/status polling) |
yes | yes |
Enrollment with an OIDC token (POST /register) |
yes | yes |
Credential refresh (POST /refresh), also in the background while joined |
yes | yes |
Signed key-set sync (GET /keys) |
yes | yes |
| Persisted state (identity and credential, owner-only files; IndexedDB in a browser) | yes | yes |
| Biscuit verification of a peer's credential (signature, expiry, peer binding, roles, labels) | yes | yes |
libp2p host as sam-node configures it (TCP and WebSocket, TLS first and Noise, yamux) |
yes | yes |
/sam/auth/1.0.0, both sides; join = handshake with a router and check its role |
yes | yes |
| Circuit relay v2 reservation on the router; dial and accept through it | yes | yes |
Service discovery in the mesh DHT (/sam/kad/1.0.0) |
yes | yes |
/sam/mcp/1.0.0 client: list and call a provider's tools, or its catalog |
yes | yes |
| Caller-side label requirements on the provider's credential | yes | yes |
/libp2p-http client: call inference and A2A services on the mesh; response bodies stream |
yes | yes |
The mesh as a transport for HTTP clients: session.fetch() for fetch-based clients, MeshTransport for httpx, so the official A2A SDK's client works unchanged |
fetch |
httpx |
Provider authorizer: the baseline Datalog and the mesh policy (GET /policies), evaluated as sam-node does |
yes | yes |
A2A ingress for the agent: /libp2p-http for a2a://<name>, forwarded to an A2A server beside the process or answered in it; not announced anywhere |
yes | yes |
Control plane pull before join and on sam-node's interval: /keys verified against the trusted set, credential refresh after a rotation, /info bans and router addresses |
yes | yes |
Gossip events from the control plane (/sam/mesh/events/v1, StrictSign): ban enforced at once, key rotation adopted, policy update pulls |
yes | yes |
| Banned peers refused: connections dropped and denied, handshakes and requests refused, dials refused | yes | yes |
Runs in a browser page: WebSocket and Noise to the router, state in IndexedDB, the agent answered by a fetch handler; sdk/js/examples/browser against sam-one, tested in Chromium |
yes | — |
| Published to a registry from the release workflow | npm @sam-mesh/sdk |
PyPI sam-mesh |
A member built this way is on the mesh and uses it in both directions: it
finds a service in the DHT and calls it through the router, verifying the
provider on every call, and it answers A2A requests for its own agent from
a sam-node or another SDK member that names it by peer ID, authorizing
every caller with the same Datalog the Go node evaluates. It follows the
control plane while it runs: a ban reaches it as a gossip event within a
second and again with the next pull, and a key rotation makes it refresh
its credential under the new key.
Facts about the libp2p implementations that the SDKs work around, each pinned by a test:
- py-libp2p's TLS transport advertises early muxer negotiation in ALPN but
cannot complete it (Python's
sslhas no ALPN select callback), and go-libp2p then refuses the mux upgrade. The Python host sets no ALPN muxer list, so the muxer is negotiated with multistream-select (sdk/python/src/agent_mesh/host.py). - js-libp2p's
@libp2p/tlsreads the libp2p extension fromextensions[0]of the peer certificate. py-libp2p's default certificate puts BasicConstraints and KeyUsage first, so the Python host uses a certificate template with only the libp2p extension. - A router grants a relay reservation only after the auth handshake on
the same connection, and drops it with the connection. The Python SDK
carries the relay protocol itself (
sdk/python/src/agent_mesh/relay.py,sdk/python/proto/circuit.proto) on top of py-libp2p's raw-connection upgrade, so the session decides when to reserve, renew and, after a dropped connection, authenticate and reserve again; py-libp2p's ownRelayDiscoveryreserves again only after the expiry has passed, when the router has already dropped the slot. - go-libp2p's relay leaves private addresses out of a reservation, so a router on loopback (every test) grants a reservation that lists no address. js-libp2p falls back to the connection's address; the Python SDK does the same.
- js-libp2p reserves a relay slot when it starts listening on
<relay>/p2p-circuit, and a router refuses that before the auth handshake. The JS SDK starts the listener after the handshake, through the transport manager, which is not on the publicLibp2pinterface (libp2p/js-libp2p#3645). - go-libp2p's relay grants a reservation for one hour and drops it when
that passes, and with the connection it was made on; a member that still
advertises the relayed address is then unreachable (
NO_RESERVATION). js-libp2p's listener renews on its own, but only on a connection the router still holds the admission of, and does not reserve again on a static relay after the connection dropped (libp2p/js-libp2p#3601); the JS SDK runs the handshake again on every new connection to a router and, when the relayed address is gone, authenticates and reserves again within thirty seconds. The Python SDK renews two minutes before the expiry the router returned, and within thirty seconds of the connection to that router going, running the auth handshake again first. - py-libp2p's yamux gives a stream's backlog slot (256 per connection) back
when the stream closes since 0.8, but not for the
trio.Cancelledof a caller's deadline while the SYN waits on a stalled connection. Every stream the SDK opens goes throughopen_stream, which boundsnew_streamwith the caller's deadline and hangs up on a connection that cannot open a stream in time, so the call fails instead of parking and the slots go with the connection. - py-libp2p's yamux drops a reset stream's bookkeeping once its buffer is
drained, and a bounded read then reports the end of the stream. The
Python
/libp2p-httpclient reads the muxed stream's reset flag on EOF, so a body cut by a reset is an error and not a body that ended. - py-libp2p's WebSocket transport resolves a
/dns4/<host>/tcp/443/wssaddress to its IP before dialing and names the IP in the TLS SNI and the Host header, which a TLS-terminating edge answers with 403; the Python host dials such addresses by name (libp2p/py-libp2p#1549). It also dialswsswith certificate verification off unless given a TLS context (libp2p/py-libp2p#1550); the host gives it the system roots. - py-libp2p's Kademlia client takes a protocol prefix but its provider
lookups still speak
/ipfs/kad/1.0.0, so it cannot reach the mesh DHT. The Python SDK does a bounded GET_PROVIDERS walk itself on/sam/kad/1.0.0, seeded by the routers (agent_mesh/discovery.py). js-libp2p's@libp2p/kad-dhttakes the protocol and works as is. - go-libp2p-kad-dht keys provider records by the multihash, not the CID.
sdk/testdata/service_keys.jsonpins the keys both SDKs derive againstinternal/node/service.go. - go-libp2p-http is plain HTTP/1.1 on a stream, one request per stream,
with
Hostset to the peer ID. The JS SDK bridges the stream to a Node duplex and runs Node's HTTP server and client on it; the Python SDK drivesh11over the stream. Both read the response body as it arrives, which an A2Amessage/stream(server-sent events) needs. - URL parsers lowercase the host (WHATWG
URL, httpx), and a base58 peer ID is case-sensitive. The URL an HTTP client uses for a peer's service therefore carries the peer ID in the path,http://mesh/sam/<peer-id>/<type>/<name>/<path>, the shape ofsam-node's egress proxy and of an agent card rewritten for the mesh; the host is ignored. - A member that publishes nothing has announced no address, so nothing in
the DHT or a router's peerstore names one.
sam-nodedials/p2p/<router>/p2p-circuitfor every router it authenticated with when it knows nothing else about a peer, as both SDKs do for a peer named by ID (internal/node/mcp.go,preparePeerAddrs). - Every Go component runs GossipSub with
StrictSign.@libp2p/gossipsub(the libp2p-maintained package;@chainsafe/libp2p-gossipsubstops at libp2p 2) and py-libp2p'sGossipSubwithstrict_signing=Trueboth exchange messages withsam-router. py-libp2p's Pubsub learns of peers through a notifee it registers when constructed, so it is built before the first connection; it also opens a meshsub stream to every new peer and its host logs an error for peers that do not run pubsub, which is expected on a mesh with plain peers. - py-libp2p's connection gate is address-based, not peer-based, so the
Python SDK enforces a ban by disconnecting the peer and refusing its
handshakes, requests and dials; js-libp2p's
connectionGateralso denies the connection itself, as sam-node's gater does.
Everything below is what the Go implementation does today, with the source file that defines it. The SDKs must match it byte for byte; when a Go change alters one of these, the SDKs change with it.
- The identity is an ed25519 key pair. The public key travels in the libp2p
protobuf encoding
PublicKey{Type: Ed25519, Data: <32 bytes>}, which is the bytes08 01 12 20followed by the key. Private keys persist asPrivateKey{Type: Ed25519, Data: <seed || public>}, bytes08 01 12 40followed by 64 bytes; this is whatsam-nodewrites to its store. - The peer ID is the identity multihash (
00 24) of the public key encoding, written in base58btc. The control plane refuses an enrollment whosepeer_idis not derived frompublic_key(internal/controlplane/server.go,pID.MatchesPublicKey).
What a member keeps between runs is api.MemberCredential plus its private
key, and every implementation persists the same two things. The SDKs keep
them in a directory: identity.key is the libp2p PrivateKey encoding
above; credential.json is MemberCredential as protojson with proto field
names (control_plane_url, biscuit, expire_time as RFC 3339,
trusted_keys[].public_key, issued_under_keys, router_addresses,
oidc_session), written with mode 0600 in a directory of mode 0700.
An unknown field is an error. control_plane_url has no trailing slash.
In a browser the JS SDK keeps the same two records in an IndexedDB
database named after the state location, kept by the browser for the
page's origin.
sam-node keeps the same message in agent.db, and sam-node state export|import <dir> moves a member between the two
(internal/node/statedir.go). TestNativeSDKExamples resumes each SDK's
directory with the other SDK and with an imported sam-node. Fields an SDK
does not use (receive_time, oidc_session) are carried through on save,
so a directory survives a round trip through any implementation.
All requests and responses are application/x-protobuf bodies of the
messages in api/sam.proto. Bodies are capped at 1 MiB on both sides.
| Endpoint | Request | Response | Proof of possession |
|---|---|---|---|
GET /info |
— | ControlPlaneInfoResponse |
none |
POST /enroll |
BootstrapEnrollRequest |
BootstrapEnrollResponse |
sign sam:enroll:<peer_id>:<ts> |
GET /enroll/status?peer_id= |
headers X-Sam-Challenge-Ts, X-Sam-Challenge-Sig (base64url, unpadded) |
BootstrapEnrollResponse |
sign sam:enroll-status:<peer_id>:<ts> |
POST /register |
EnrollRequest |
EnrollResponse |
sign sam:register:<peer_id>:<ts> |
POST /refresh |
TokenRefreshRequest, header Authorization: Bearer <base64 biscuit> |
TokenRefreshResponse |
sign sam:refresh:<peer_id>:<ts> |
GET /keys |
— | KeysResponse |
none; see below |
GET /policies |
header Authorization: Bearer <base64 biscuit> |
PolicyConfigGetResponse{datalog_rules} |
none; the biscuit must belong to an admitted node |
POST /nodes/catalog |
NodeCatalogReport, header Authorization: Bearer <base64 biscuit> |
204 |
none; the reporting peer is read from the biscuit. sam-node reports what it publishes; an SDK member publishes nothing and does not call it |
<ts>is the request'schallenge_unix_ms, unix milliseconds, and must be within 5 minutes of the control plane's clock (challengeMaxAge).- The endpoints above answer a CORS preflight and mark their responses for
any origin (
Access-Control-Allow-Origin: *), so a page on another origin can call them. They authenticate by what the request carries, a token in the body or a biscuit as a bearer, never by a cookie. The operator plane (/admin/*,/user/*) and/routers/leasedo not. Challenges are defined inapi/network.go. It is the one instant on the wire that is anint64: it is the number in the signed text. Every other instant (expire_time,sign_time,event_time,announce_time) is agoogle.protobuf.Timestamp, and a receiver rejects a message whose required instant is unset. - A bootstrap enrollment answers
PENDINGuntil an operator approves it, unless the control plane runs with auto-approval. The client polls/enroll/statusat the returnedpoll_interval_seconds. /refreshredeems only the last biscuit the control plane issued for the peer. A client must persist the new biscuit before using it; losing it means re-enrolling./keyssignatures cover the deterministic protobuf encoding ofKeysResponse{public_keys, sign_time}withsignaturescleared, one signature per key by that key. The receiver accepts the set when a key it already trusts (the enrollment key) vouches for it andsign_timeis within 5 minutes (api/trust.go). Rotation keeps several keys valid, so a member must trust the whole set to verify peers enrolled under a retiring key.- A plaintext
http://control plane URL is accepted only for a loopback host (api.ValidateControlPlaneTransport). The SDKs expose the same explicit opt-in (allowInsecure/allow_insecure). - Bootstrap tokens are secrets. The SDKs read them from a file or a value the caller already holds; the conformance runners take a path in the environment. Nothing takes a token as a command-line argument.
- Transports:
libp2p.DefaultTransports(TCP, QUIC, WebSocket). - Security: TLS first, Noise accepted (
libp2p.Securitytwice, in that order, onsam-nodeandsam-router). Both bind the connection to the peer ID. The Node and Python SDKs offer the same two in the same order and land on TLS with a Go peer and with each other; in a browser the JS SDK offers Noise alone, since a page cannot run libp2p's TLS, and a Node or Python member reached through a relay meets it on Noise. js-libp2p has both; py-libp2p gained TLS in libp2p/py-libp2p#831 and has passed the libp2p transport interoperability suite against the other implementations since libp2p/test-plans#798. - Muxer: yamux (libp2p default).
- Relay: circuit relay v2, with the routers as static relays; hole punching is on. A node behind NAT is reached through a router.
- DHT: Kademlia with protocol prefix
/sam, mode auto.
Three protocols. The first two start with a length-prefixed protobuf frame; framing is the go-msgio varint style (unsigned varint byte length, then the bytes), with a 64 KiB cap on the first frame.
/sam/auth/1.0.0(HandleAuthHandshake, and the router's equivalent). Client sendsAuthFrame{biscuit}; server verifies the biscuit against the control plane keys, checks it is bound to the connection's peer ID and not revoked, and answersAuthResponse{success, biscuit}with its own credential. The client verifies that credential the same way and, for a router, requires the router role. This is how a member joins: connect to a router, run this handshake, and the router admits it to the relay and gossip. Both SDKs answer it too, so asam-nodecan verify them before calling./sam/mcp/1.0.0(WithBiscuitAuththenHandleMCPStream). Client sendsAuthFrame{biscuit, target_service: "mcp://<service>", agent}; server authorizes the request with the Biscuit authorizer described below and answersAuthResponse. The stream then carries MCP JSON-RPC messages, each one varint-length-prefixed (StreamTransportininternal/node/gate.go). An emptytarget_serviceselects the node's own catalog tools. The SDKs are clients of this protocol only; they serve no MCP./libp2p-http(go-libp2p-http;StartIngressServerand the egress proxy ininternal/node). Plain HTTP/1.1 on a stream, one request per stream,Hostset to the peer ID, the caller's biscuit inX-Sam-Biscuitand the agent it speaks for inX-Sam-Agent. The path is/<type>/<name>/<upstream>; the server authorizes<type>://<name>with the same authorizer, then forwards<upstream>to the service with those two headers stripped andX-Peer-Idset to the verified caller. The SDKs are clients of this forinference://,a2a://andegress://services (an egress destination is one asam-nodeserves for a hostname outside the mesh, as the control plane assigned it; the SDK reaches it as any other service,/egress/<hostname>/<path>on that node), and servers of it for their own agent,a2a://<name>, only. Bodies are framed byContent-Lengthor chunked transfer coding; a response with neither runs to the end of the stream. The JS SDK frames these itself (http1.ts), so the same code runs in a browser.
A provider announces a service as a DHT provider record for
cidv1(raw, sha256("sam:service:<type>[:<name>]")), once for the type and
once for the name (<type> is mcp, inference, a2a or egress). A consumer
looks up providers for the same CID, dials one and opens /sam/mcp/1.0.0.
Gossip topics under /sam/discovery/v1/... carry ServiceAnnounce for
interest-scoped updates; the DHT is the source of truth. The SDKs look
records up and announce none.
A peer that announced nothing is still reachable by peer ID: every router
relays for the peers it admitted, so a caller dials
<router>/p2p-circuit/p2p/<peer> through each router that admitted it.
Both SDKs do this for a bare peer ID, and sam-node does it when it knows
no other address for a peer (preparePeerAddrs in internal/node/mcp.go).
This is how an SDK agent is reached.
A node that serves a tool evaluates the caller's biscuit with an authorizer
that has, in this order: the biscuit's own authority facts (node, role,
label, agent), injected facts service(<type>, <name>),
connection_peer_id(<peer>), time(<now>) and optionally agent(<claim>),
the baseline checks, rules and policies from api/datalog.go, the
target_fact facts derived from the provider's own biscuit, and the mesh
policy rules. Deny by default.
All of that Datalog is text every Biscuit implementation parses, and none of it is derived in the SDK:
- The baseline (checks, rules, policies, fact names) is generated from
api/datalog.gobyhack/gen-sdk-datalogintosdk/js/src/gen/datalog.tsandsdk/python/src/agent_mesh/_gen/datalog.json;hack/verify-sdk-generated.shfails when they are stale. - The mesh policy arrives rendered:
GET /policiesanswersPolicyConfigGetResponse{datalog_rules}, one rule per entry, rendered by the control plane withapi.BuildPolicyRules.sam-nodeadds the same text; no member compiles roles and bindings itself. An unparseable entry rejects the whole response. - Presence-only facts carry the single term
true(target_unrestricted(true),granted_service_all_types(true),agent_authorized(true), …). biscuit-go accepts a predicate with no terms; biscuit-rust, and so biscuit-python and biscuit-wasm, do not. - An unconditional rule is written
head <- true, which every parser accepts as a rule (role("dev") <- truefor asam:system:authenticatedbinding).
An SDK member calls services and accepts A2A requests for its own agent. It does not publish services: no MCP server, no named inference or A2A service, no DHT record, no catalog entry. The reasons:
- The agent is the principal; a service is infrastructure. A harness
(LangChain, Claude Code, a browser agent) is an MCP client. The tools it
should see through the mesh are the ones
sam-nodes publish. Something that must be reachable by name, a tool, a model, a named agent, is a deployment concern, and the sidecar model exists for it: thesam-nodebeside it publishes the service and enforces the policy once, in Go. - Publishing is the expensive part. Being a provider needs a DHT
provide loop, a catalog report and provider records that go stale on
every rollout. Accepting requests for the agent itself needs the relay
reservation, the auth handshake and the authorizer, all of which a member
needs anyway to be verified by a
sam-nodebefore it is called. - One inbound protocol. A2A is the protocol for talking to an agent, and the A2A SDKs serve one agent card each. One ingress means one authorizer path to audit in three languages instead of two ingress protocols times a registry of services.
- The browser. A browser cannot listen. An agent in a browser is
reachable through a router's relay and nothing else, which is what
accept_a2ais. The JS SDK runs in a page: it reaches the router over WebSocket (wsswhen the router sits behind a TLS-terminating edge, assam-one --tunnelputs it), secures the connection with Noise, keeps its state in IndexedDB and answers its agent with a fetch handler.
What an SDK agent is on the wire: a peer with a relay reservation on a
router, answering /sam/auth/1.0.0 and /libp2p-http for a2a://<name>,
with no DHT record and no catalog entry. To a sam-node it is a peer whose
address nobody announced, reached through the router that admitted both,
and whose credential it verifies before forwarding
/sam/<peer>/a2a/<name>/.... Routers, the control plane and the Datalog
need no special case.
Two agents that both wrote nothing down still meet: A learns B's peer ID
(an invite, an agent card, a coordinator), dials it through a router, both
present their credentials, and A opens the A2A conversation on that
connection; libp2p's secure channel is end to end, so the router carries
ciphertext.
An agent that must be found by name runs behind a sam-node. Two agents
that both can only call out (two browsers) meet at a third agent behind a
sam-node that both call.
Each milestone lands in both languages with its tests before the next one starts. Where a milestone needs a Go change, the Go change lands first.
Described above. Tested by unit tests in each SDK against a fake control
plane and by tests/integration/sdk_enroll_test.go, which runs each SDK's
conformance runner against a real control plane in manual-approval mode,
approves the request through /admin, and checks the biscuits the SDK
holds against the control plane's records.
- libp2p host per SDK, configured as
sam-node's: TCP and WebSocket, TLS, yamux, identify, circuit relay v2 client. The testnets' routers listen on TCP;sam-one's router is a WebSocket listener on the port that serves its API (/ws, or/wssbehind a tunnel), so a member reaches it over WebSocket or not at all;tests/integration/standalone_sdk_test.goholds both SDKs to it. JS:libp2p,@libp2p/tcp,@libp2p/websockets,@libp2p/tls,@chainsafe/libp2p-yamux,@libp2p/circuit-relay-v2,@libp2p/identify. Python:libp2p>=0.8(security.tls,stream_muxer.yamux) plus the SDK's own relay client; py-libp2p is trio-based, sojoin()is an async context manager. - Biscuit verification of a peer's credential: signature under any trusted
control plane key, authority block only, expiry, binding to the
connection peer ID, roles and labels. JS:
@biscuit-auth/biscuit-wasm, instantiated by hand so no Node flag is needed; Python:biscuit-python.sdk/testdata/biscuit_vectors.jsonholds tokens minted byinternal/identity(valid, other peer, expired, untrusted key, appended block, garbage) and both verifiers agree with the Go one on all of them. /sam/auth/1.0.0on both sides. Join connects to the routers in the credential, runs the handshake, requires the router role under the key that verified the token, and reserves a relay slot; the router grants one only to an authenticated peer. Inbound handshakes are answered the wayHandleAuthHandshakedoes: a credential that does not verify gets a closed stream and nothing else.session.authenticate(addr)connects to any peer, directly or through a router (/p2p-circuit), and runs the handshake;session.authenticatedPeersis the admitted set. Background refresh is driven by the biscuit's expiration, asStartRenewalLoopdoes.- Tests. Unit: each SDK joins an in-process fake router (its own libp2p
with the handshake and a relay), and refuses a relay without the router
role, a router that trusts another control plane, and a refused
reservation. Integration:
tests/integration/sdk_mesh_test.goruns one mesh with a control plane, asam-router, asam-nodeand one member per SDK, all real, then walks the connectivity matrix: every member on the router's lease; each SDK member verifies thesam-nodeand the other SDK member on both the direct and the relayed path, and is verified by them; thesam-nodereaches each member through the router; an admitted Go peer does the same and sends a forged frame; an address behind the router for a peer that is not on the mesh fails. About 7 seconds.
- Discovery:
session.discover("mcp://calc"), or(type, name), or a type alone, looks the mesh DHT up for the service keyinternal/node/service.goderives. JS:@libp2p/kad-dhtin client mode on/sam/kad/1.0.0; Python: the SDK's own bounded GET_PROVIDERS walk on the same protocol (see the facts above). - Naming a peer: every call takes a
Peer, which is a providerdiscoverreturned, a peer id, or a multiaddr. For the first two the SDK dials the advertised addresses and<router>/p2p-circuit/p2p/<peer>through every admitted router, aspreparePeerAddrsininternal/node/mcp.godoes; a multiaddr is dialed as given. /sam/mcp/1.0.0client:session.openMCP(peer, "mcp://<name>")sends theAuthFramenaming the service, verifies the provider's credential and the caller's required labels (checkPeerLabels: every pair must be attested, asapi.LabelCheckjoins them with,), then runs the official MCP client over the varint-framed stream. JS: aTransportfor@modelcontextprotocol/sdk; Python: a pair of memory streams pumped to and from the libp2p stream formcp.ClientSession.""as the target is the provider's own catalog (list_local_services,get_mesh_info).- Egress floor:
join({ egressRequireLabels })(join(egress_require_labels=)) issam-node'segress.require_labelsfor an SDK member: every provider the session calls must attest all of them, on top of a call's required labels, on every outbound call however the peer was named, MCP and HTTP alike. It is the same rule as the caller's requirement (api.LabelCheck), checked separately so no call can reach it. Stated once at join and held for the session; a call cannot waive or widen it. The three implementations agree on it, as they do on the caller's requirement. The HTTP path (request,fetch,MeshTransport) verifies the provider with or without a floor, through the mutual/sam/auth/1.0.0handshake, assam-node'sVerifyPeerLabelsdoes before its egress proxy sends anything; a positive verdict is kept per peer for five minutes (labelGateTTL); a refusal is not kept. An unmet floor is aLabelsNotSatisfiedErrornaming the floor. session.listTools(peer, service)andsession.callTool(peer, service, tool, args)on top of that.- Tests. Unit: each SDK calls a tool on an in-process provider that serves
/sam/mcp/1.0.0assam-nodedoes with the official MCP server behind it, reads its catalog, and is refused for missing labels, an untrusted provider, a forged caller credential and an unknown service. Integration: the mesh ofsdk_mesh_test.gohas an MCP backend behind thesam-node(calc, tooladd); each SDK member discovers it in the DHT, lists and callsaddthrough the router, reads the node's catalog and is refused a service the node does not have. The runners tookdiscover,toolsandcallcommands for it.
- Datalog as the contract, Go side: the baseline moved into a generated
artifact both SDKs embed, the control plane renders the mesh policy as
datalog_rules, andsam-nodeconsumes that text like any other member. See the authorization section above. - Provider authorizer in each SDK (
authorizer.ts,authorizer.py), built from the artifact anddatalog_rules, evaluated on every inbound/libp2p-httprequest in the orderinternal/node/middleware.gouses. /libp2p-httpserver for the agent: the path is/<type>/<name>/<upstream>, the biscuit isX-Sam-Biscuit; after authorization for<type>://<name>, a request for the agent's owna2a://<name>goes to an A2A server beside the process or a handler in it, with the biscuit and agent headers stripped andX-Peer-Idset to the verified caller; anything else is 404, after authorization, so an unauthorized caller learns nothing. JS also takes a Node request listener, which is what an Express app with the A2A SDK's handlers is, and runs it on the ingress's ownhttp.Server.session.accept_a2a(target, name=)/session.acceptA2A(spec)fetches the mesh policy, re-reads it onsam-node's sync interval and starts answering. It announces nothing. One agent per session./libp2p-httpclient for callers built on HTTP libraries:open_http_request/fetchOverStreamreturn once the headers are in and stream the body;MeshTransport(httpx) andsession.fetch()(fetch) carry a client's requests to the peer a mesh URL names. The A2A SDK's client takes either without changes. The card of an agent behind asam-nodenames the agent's own address; a GET of the well-known card path or of the service root is answered the way the node's egress proxy answers it: the SDK fetches the card itself, with identity encoding, and serves it rewritten (rewriteAgentCard/rewrite_agent_card): HTTP interfaces point at the mesh URL, gRPC ones are dropped, signatures go. Streaming stays as the agent declares it, since the transport streams.sam-node: a peer it knows no address for is dialed through every router it authenticated with, so its egress proxy reaches an agent by peer ID (preparePeerAddrs).- Examples with the official A2A SDKs (
a2a-agent.ts,a2a-call.ts;a2a_agent.py,a2a_call.py): the A2A SDK's server is the listeneracceptA2Aruns (JS) or a uvicorn processaccept_a2aforwards to (Python), its card names the agent by its mesh URL, and the A2A SDK's client runs onsession.fetch()orMeshTransport. The A2A SDKs are dependencies of the examples only: dev dependencies insdk/js, theexamplesextra insdk/python. - Tests. Unit: each SDK's ingress behind an in-process host, called with
its own clients: a forwarded A2A server that sees
X-Peer-Idand never the biscuit, an SSE body read event by event, an in-process handler (and a Node listener), an httpx client onMeshTransport, and refusals for a role that grants nothing, an ungranted type, another agent name (404), a missing biscuit and a dotted path; plus the authorizer alone against the decisionsinternal/node/middleware_test.gopins, andsam-node's router fallback. Integration: in the mesh ofsdk_mesh_test.goeach SDK member acceptsa2a://agent; thesam-nodereaches it by peer ID through its egress proxy, the other SDK member does the same with no lookup, a lookup fora2a://agentfinds nothing, the member refuses a/sam/mcp/1.0.0stream, and a Go peer whose role the policy grants nothing gets 403 and is answered once it presents a node-role token. About 9 seconds for the whole mesh.TestNativeSDKA2Aruns the A2A SDK agents and calls each from the other language with the A2A SDK's client; the answer names the caller's peer, and asam-nodefetches the card through its egress proxy.
- Control plane pull (
session.sync(),mesh.syncControlPlane()), asSyncControlPlaneininternal/node/controlplane_sync.go:/keysverified against the keys already trusted, so whoever answers the URL cannot become the trust root; a credential refresh when a key trusted now was unknown when the credential was issued (issuedUnderKeysin the credential file);/infofor router addresses and the ban set, reconciled with the rule that a ban recorded after the request went out survives an answer that omits it; the mesh policy while accepting callers. Runs once shortly after join, then onsam-node's interval with the same jitter, and whenever an event triggers it. - Gossip events on
/sam/mesh/events/v1: the topic validator rejects anything not signed by a trusted control plane key and ignores stale events;BANNEDevicts the peer at once,KEY_ROTATIONadopts the key and triggers a pull,POLICY_UPDATEtriggers a pull. Bans are not persisted; a restarted member reads them from/info. - Ban enforcement: the auth handshake and the A2A ingress refuse a banned
peer before looking at its token;
connect()refuses to dial one; its connections are dropped when the ban lands. - Publishing:
.github/workflows/release.ymlstamps the release tag's version on both packages (hack/sdk-version.sh) and publishes@sam-mesh/sdkto npm andsam-meshto PyPI through trusted publishing. A prerelease tag (v0.1.0-rc.4) publishes under the npm dist-tagnext, a stable tag underlatest; PyPI needs no tag,pipskips prereleases on its own. When a publish job (publish-sdk-jsorpublish-sdk-python) fails after the GitHub release exists, re-run the failed job, or run the workflow by hand from the Actions tab with the tag and target SDK as input; it checks out that tag and publishes the selected SDK. One-time setup by a package owner: on npmjs.com, create thesam-meshorganization, publish@sam-mesh/sdk0.1.0 once by hand (cd sdk/js && npm publish --access public; the trusted-publisher settings live on the package page, which exists only after that), then registergoogle/samwith workflowrelease.ymlunder the package's Settings, Trusted publishing; on pypi.org, add a pending publisher for projectsam-meshwith the same repository and workflow (environment left empty), which reserves the name and lets the first tag create the project. No publishing token is stored in the repository. - Docs:
site/content/docs/guides/native-sdks.md. - Tests. Unit: a fake control plane rotates its key and bans a peer; each
SDK learns both from a pull, refreshes under the new key and only under
it, drops and refuses the banned peer, and lifts the ban when the control
plane does;
BanSetandverifyMeshEventalone. Integration: the mesh ofsdk_mesh_test.gogets the control plane's real event publisher; an enrolled Go peer admitted by every member is banned withPOST /admin/revokeand each member learns it from the gossip event through the router without pulling, refuses the peer's next handshake, and a pull agrees; then the control plane rotates its key and announces it, and every member ends up trusting both keys, holding a credential that verifies only under the new one, and still authenticating with thesam-node.
Both testnets run the example programs, unchanged, as canaries beside the
sam-node ones (.github/k8s/sam-sdk-canary-template.yaml): two pairs, an
agent written with one SDK and the official A2A SDK (a2a-agent.ts,
a2a_agent.py) and a caller written with the other in one pod, enrolled
with the pod's projected service account token through SAM_JWT_PATH. The
agent accepts a2a://agent; the caller reads its peer ID from the line it
prints, through a volume the pod shares, sends it a message with the A2A
SDK's client every five minutes and is Ready while the last answer named
the caller, so the Deployment's availability says whether an agent is still
reachable after hours on the mesh. Two CronJobs cross every implementation
boundary every 15 minutes and once per rollout: the sam-node cold-path
probe (sam-probe-cronjob-template.yaml) runs an agent per SDK as sidecars
and fetches each one's agent card by peer ID through the egress proxy
(node → SDK), and the SDK cold-path probe
(sam-sdk-probe-cronjob-template.yaml) runs each SDK's callers against the
everything canary by name (SDK → node) and the other SDK's agent by peer ID,
the card with the mesh SDK's client and a message with the A2A SDK's
(SDK → SDK). The images (Dockerfile.sam-sdk-js, Dockerfile.sam-sdk-python)
are built per commit by deploy.yaml, so bananas runs the SDKs at the
same commit as the Go components they talk to.
- The connector interface for platforms (issue #480):
Attach,Detach,Refresh,Statusand the agent bundle, so a scheduler places SDK members the way it placessam-boxsandboxes. - Reading the control plane's events also for router address changes, and re-dialing a router that replaced another.
- Rewriting the control plane, router or sandbox components; they stay Go.
- Publishing services from an SDK: an MCP server, a named inference or A2A
service, a DHT record or a catalog entry. That is
sam-node's job; see Agents, not services. - Any SDK-only wire protocol. If an SDK needs something the Go node does not speak, the Go node learns it first.
# Regenerate protobuf bindings and the Datalog artifact after editing
# api/sam.proto or api/datalog.go
./hack/gen-sdk-proto.sh
# JavaScript
cd sdk/js && npm ci && npm test && npm run build && npm run examples
# Python
python3 -m venv sdk/python/.venv
sdk/python/.venv/bin/pip install -e 'sdk/python[test]'
sdk/python/.venv/bin/pytest sdk/python/tests
# Both against a real control plane, router and sam-node, and the example
# programs the docs embed against the same
go test ./tests/integration -run TestNativeSDK -v
# The JS SDK in a browser page against sam-one, in Chromium (Playwright);
# also run by `make ui-test`
cd sdk/js && node scripts/bundle-browser.mjs examples/browser/app.js build/browser-example
cd tests/ui && npm ci && npx playwright install chromium && npx playwright test browser-sdkmake sdk-test runs all of the above. The integration tests skip an SDK
whose toolchain is missing, and CI (.github/workflows/sdk.yml) installs
both so nothing skips there. The SDK members in the mesh test are the
runners sdk/js/src/conformance-join.ts and
sdk/python/src/agent_mesh/conformance_join.py: each joins, prints what it
holds, then takes JSON commands on stdin (auth, discover, tools,
call, accept, http, peers, sync, banned, quit) so the Go test
can drive both languages through the same script.
The programs the package READMEs and the Native SDKs guide show are
sdk/js/examples/*.ts and sdk/python/examples/*.py. The Markdown embeds
them between <!-- embed: <path> --> and <!-- /embed --> markers;
make sdk-docs (go run ./hack/gen-sdk-docs sdk site/content/docs) copies
the files in, hack/verify-sdk-generated.sh fails when a copy is stale, and
TestNativeSDKExamples runs the files themselves against a mesh. Edit the
example, regenerate, commit both.
sdk/js/.npmrc pins the public registry so package-lock.json never
resolves packages through a local mirror; the verify script checks the
lockfile too.
Interoperability facts that the tests pin: sdk/testdata/identity_vectors.json
holds key encodings, peer IDs and challenge signatures produced with
go-libp2p, sdk/testdata/biscuit_vectors.json holds tokens minted by the
control plane's code, and every SDK reproduces or agrees with them.
The package READMEs (sdk/js/README.md, sdk/python/README.md) are the
registry pages on npm and PyPI: they hold what a user of the package needs
and nothing about this repository's internals. Everything below is for
contributors.
Both SDKs mirror the same Go code, one module per concern; the Go file each one follows is named so a change on one side can be carried to the others.
JavaScript (sdk/js/src/) |
Python (sdk/python/src/agent_mesh/) |
Mirrors |
|---|---|---|
identity.ts |
identity.py |
ed25519 key pair, libp2p key encodings, peer ID |
controlplane.ts |
controlplane.py |
/info, /keys, /enroll, /enroll/status, /register, /refresh, /policies, with the challenges of api/network.go |
credential.ts |
credential.py |
what a member holds, AuthFrame encoding, issuedUnderKeys |
mesh.ts |
mesh.py |
AgentMesh: enroll (resumes an unexpired credential in the state directory before spending a token), load, refresh, syncControlPlane as SyncControlPlane in internal/node/controlplane_sync.go; state directory (identity.key, credential.json, the same layout in both languages) |
biscuit.ts |
biscuit.py |
verification of a peer's credential, as internal/identity.verifyBiscuit |
host.ts |
host.py |
the libp2p host as internal/node/node.go configures it, plus gossipsub and the connection gater |
auth.ts |
auth.py |
/sam/auth/1.0.0 on both sides, as HandleAuthHandshake |
| — | relay.py |
circuit relay v2 client (py-libp2p's cannot talk to go-libp2p) |
session.ts |
session.py |
MeshSession: join, relay reservation, refresh loop, control plane sync loop, gossip events, acceptA2A() / accept_a2a(), fetch() |
discovery.ts |
discovery.py |
service keys and DHT lookups as internal/node/service.go; lookups only, no records |
mcp.ts |
mcp_client.py |
MCP over /sam/mcp/1.0.0, the client side of internal/node/gate.go |
authorizer.ts |
authorizer.py |
the provider authorizer, as internal/node.(*SamNode).Authorize, over the generated baseline and datalog_rules |
libp2p-http.ts |
libp2p_http.py |
/libp2p-http client (streaming) and the A2A ingress for the agent, as go-libp2p-http and StartIngressServer; mesh URLs |
http1.ts |
— | HTTP/1.1 heads and bodies on a libp2p stream, for libp2p-http.ts (Python uses h11 in libp2p_http.py) |
libp2p-http-node.ts |
— | the ingress for a Node request listener (an Express app), Node's own HTTP server over the stream |
platform/*.ts, platform/*.browser.ts |
— | what differs between Node and a browser: transports and security (TCP+WebSocket with TLS then Noise; WebSocket with Noise), state (files; IndexedDB), the biscuit WASM loader, the ingress. package.json's browser field maps each to its twin; scripts/bundle-browser.mjs bundles for a page and fails if the browser graph reaches a node: module |
| — | httpx_transport.py |
MeshTransport, the mesh as an httpx.AsyncBaseTransport (JS has session.fetch() instead) |
sync.ts |
sync.py |
ban set and mesh event verification, as reconcileBannedPeers and verifyEvent |
conformance.ts, conformance-join.ts |
conformance.py, conformance_join.py |
the runners the integration tests drive |
../examples/ |
../../examples/ |
the programs the docs embed and TestNativeSDKExamples runs; ../examples/browser/ is the page tests/ui/browser-sdk.spec.js drives |
gen/ |
_proto/, _gen/ |
generated by hack/gen-sdk-proto.sh from api/sam.proto, sdk/python/proto/circuit.proto and api/datalog.go |
Unit tests sit beside the code (*.test.ts, tests/test_*.py) and build a
fake control plane and router in the process; the real ones are exercised
only by tests/integration.