diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index bbda0e60..661c8921 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -230,14 +230,14 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { handle := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, observeRoute(pattern, h)) } - handle("/info", s.HandleInfo) - handle("/register", noStore(s.HandleRegister)) - handle("/keys", s.HandleKeys) + handle("/info", meshSurface(s.HandleInfo)) + handle("/register", meshSurface(noStore(s.HandleRegister))) + handle("/keys", meshSurface(s.HandleKeys)) handle("/routers/lease", s.HandleRouterLease) - handle("/policies", s.HandlePolicies) - handle("/enroll", noStore(s.HandleEnroll)) - handle("/enroll/status", noStore(s.HandleEnrollStatus)) - handle("/refresh", noStore(s.HandleRefresh)) + handle("/policies", meshSurface(s.HandlePolicies)) + handle("/enroll", meshSurface(noStore(s.HandleEnroll))) + handle("/enroll/status", meshSurface(noStore(s.HandleEnrollStatus))) + handle("/refresh", meshSurface(noStore(s.HandleRefresh))) handle("/nodes/catalog", s.HandleNodeCatalog) handle("/admin/bootstrap-tokens", noStore(s.HandleAdminBootstrapTokens)) handle("/admin/bootstrap-tokens/", noStore(s.HandleAdminBootstrapTokenAction)) @@ -261,6 +261,26 @@ func noStore(h http.HandlerFunc) http.HandlerFunc { } } +// meshSurface lets a member running in a browser call an endpoint of the +// mesh protocol from any origin. These endpoints authenticate by what the +// request carries, a bootstrap token or an OIDC token in the body or a +// biscuit as a bearer, never by a cookie, so a page on another origin can +// send nothing a program could not send already. The operator plane +// (/admin, /user) is cookie-authenticated and gets no such header. +func meshSurface(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", strings.Join([]string{"Authorization", "Content-Type", api.HeaderChallengeTimestamp, api.HeaderChallengeSignature}, ", ")) + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return + } + h(w, r) + } +} + func (s *Server) discoverProviders() error { s.providersMu.Lock() defer s.providersMu.Unlock() diff --git a/internal/controlplane/server_test.go b/internal/controlplane/server_test.go index 8c4633ae..959ea277 100644 --- a/internal/controlplane/server_test.go +++ b/internal/controlplane/server_test.go @@ -3018,6 +3018,79 @@ func TestInitRegisterRoutesEmbedded(t *testing.T) { } } +// TestMeshSurfaceCORS pins what a member in a browser needs from the control +// plane and what it must not get: the mesh protocol's endpoints answer a +// preflight and mark every response for any origin, the operator plane +// does neither. +func TestMeshSurfaceCORS(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "cp-cors.db") + store, err := storage.NewSQLStore("sqlite", dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer func() { _ = store.Close() }() + srv, err := NewServer(Options{DriverName: "sqlite", DataSourceName: dbPath, AllowedAudiences: []string{"sam-mesh-audience"}, AdminToken: "admin-token"}, store) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + if err := srv.Init(); err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Close() }() + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + client := &http.Client{Timeout: 5 * time.Second} + + for _, path := range []string{"/info", "/keys", "/enroll", "/enroll/status", "/register", "/refresh", "/policies"} { + req, _ := http.NewRequest(http.MethodOptions, ts.URL+path, nil) + req.Header.Set("Origin", "https://agent.example") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "content-type, "+api.HeaderChallengeTimestamp) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("OPTIONS %s = %s, want 204", path, resp.Status) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("OPTIONS %s Access-Control-Allow-Origin = %q, want *", path, got) + } + for _, h := range []string{"Authorization", "Content-Type", api.HeaderChallengeTimestamp, api.HeaderChallengeSignature} { + if !strings.Contains(resp.Header.Get("Access-Control-Allow-Headers"), h) { + t.Errorf("OPTIONS %s does not allow header %s: %q", path, h, resp.Header.Get("Access-Control-Allow-Headers")) + } + } + } + resp, err := client.Get(ts.URL + "/info") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("GET /info Access-Control-Allow-Origin = %q, want *", got) + } + + for _, path := range []string{"/admin/status", "/user/status", "/routers/lease"} { + req, _ := http.NewRequest(http.MethodOptions, ts.URL+path, nil) + req.Header.Set("Origin", "https://agent.example") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("OPTIONS %s Access-Control-Allow-Origin = %q, want none", path, got) + } + if resp.StatusCode == http.StatusNoContent { + t.Errorf("OPTIONS %s answered a preflight", path) + } + } +} + // TestAdminBootstrapTokensList pins the admin listing surface used by // `sam-one token list`: created tokens show up, and the list is admin-gated. func TestAdminBootstrapTokensList(t *testing.T) { diff --git a/internal/node/node.go b/internal/node/node.go index 07b77312..7ef2d962 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -61,6 +61,7 @@ import ( "github.com/libp2p/go-libp2p/p2p/host/autorelay" "github.com/libp2p/go-libp2p/p2p/net/connmgr" "github.com/libp2p/go-libp2p/p2p/net/swarm" + "github.com/libp2p/go-libp2p/p2p/security/noise" libp2ptls "github.com/libp2p/go-libp2p/p2p/security/tls" "github.com/libp2p/go-msgio" "github.com/multiformats/go-multiaddr" @@ -430,11 +431,15 @@ func (n *SamNode) Start(ctx context.Context) error { return fmt.Errorf("failed to create connection manager: %w", err) } - // Layer 1: Establish FIPS-compliant Transports & NAT Services + // Layer 1: Transports & NAT Services. TLS first: Go peers and the + // Node/Python SDKs land on it. Noise is what a browser can speak, and a + // relayed connection from one is upgraded here, end to end; both bind + // the connection to the peer ID. opts := []libp2p.Option{ libp2p.Identity(n.config.PrivKey), libp2p.DefaultTransports, libp2p.Security(libp2ptls.ID, libp2ptls.New), + libp2p.Security(noise.ID, noise.New), libp2p.ConnectionGater(gater), libp2p.ListenAddrStrings(n.config.ListenAddrs...), libp2p.EnableNATService(), diff --git a/internal/router/router.go b/internal/router/router.go index 5f0e3f44..d2232849 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -51,6 +51,7 @@ import ( rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager" "github.com/libp2p/go-libp2p/p2p/net/connmgr" "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay" + "github.com/libp2p/go-libp2p/p2p/security/noise" libp2ptls "github.com/libp2p/go-libp2p/p2p/security/tls" libp2pquic "github.com/libp2p/go-libp2p/p2p/transport/quic" "github.com/libp2p/go-libp2p/p2p/transport/tcp" @@ -353,7 +354,10 @@ func (r *Router) Start() error { libp2p.Identity(r.privKey), r.transportOptions(), libp2p.ListenAddrStrings(r.config.ListenAddrs...), + // TLS first: Go peers and the Node/Python SDKs land on it. Noise is + // what a browser can speak; both bind the connection to the peer ID. libp2p.Security(libp2ptls.ID, libp2ptls.New), + libp2p.Security(noise.ID, noise.New), libp2p.ConnectionManager(cm), libp2p.EnableAutoNATv2(), libp2p.EnableNATService(), diff --git a/sdk/README.md b/sdk/README.md index 1475825d..b54c8dae 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -38,9 +38,9 @@ Milestones 1 to 5 are implemented and tested in both languages: | 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) | 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, yamux) | 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 | @@ -53,6 +53,7 @@ Milestones 1 to 5 are implemented and tested in both languages: | Control plane pull 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 @@ -173,6 +174,9 @@ 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 ` moves a member between the two (`internal/node/statedir.go`). `TestNativeSDKExamples` resumes each SDK's @@ -198,6 +202,11 @@ messages in `api/sam.proto`. Bodies are capped at 1 MiB on both sides. - `` is the request's `challenge_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/lease` do not. Challenges are defined in `api/network.go`. It is the one instant on the wire that is an `int64`: it is the number in the signed text. Every other instant (`expire_time`, `sign_time`, `event_time`, `announce_time`) is a @@ -226,9 +235,13 @@ messages in `api/sam.proto`. Bodies are capped at 1 MiB on both sides. ### libp2p host (`internal/node/node.go`) - Transports: `libp2p.DefaultTransports` (TCP, QUIC, WebSocket). -- Security: **TLS only** (`libp2p.Security(libp2ptls.ID, libp2ptls.New)`). - There is no Noise on `sam-node` or `sam-router`. js-libp2p has TLS; - py-libp2p gained it in +- Security: TLS first, Noise accepted (`libp2p.Security` twice, in that + order, on `sam-node` and `sam-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](https://github.com/libp2p/py-libp2p/pull/831) and has passed the libp2p transport interoperability suite against the other implementations since @@ -268,7 +281,10 @@ bytes), with a 64 KiB cap on the first frame. the same authorizer, then forwards `` to the service with those two headers stripped and `X-Peer-Id` set to the verified caller. The SDKs are clients of this for `inference://` and `a2a://` services, and servers - of it for their own agent, `a2a://`, only. + of it for their own agent, `a2a://`, only. Bodies are framed by + `Content-Length` or 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. ### Discovery (`internal/node/service.go`) @@ -340,7 +356,10 @@ service, no DHT record, no catalog entry. The reasons: 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_a2a` is. + `accept_a2a` is. The JS SDK runs in a page: it reaches the router over + WebSocket (`wss` when the router sits behind a TLS-terminating edge, as + `sam-one --tunnel` puts 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://`, @@ -353,7 +372,8 @@ 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 TLS is end to end, so the router carries ciphertext. +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. @@ -590,11 +610,6 @@ same commit as the Go components they talk to. - 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](#agents-not-services). -- A browser build. The JS SDK dials routers over WebSocket, which a browser - can do, but it still runs on Node.js only: the routers and nodes accept - libp2p TLS alone, which a browser cannot speak (it would need Noise on the - Go side), and the SDK reads its state and speaks HTTP/1.1 on streams with - Node's `fs` and `http`. - Any SDK-only wire protocol. If an SDK needs something the Go node does not speak, the Go node learns it first. @@ -616,6 +631,11 @@ 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-sdk ``` `make sdk-test` runs all of the above. The integration tests skip an SDK @@ -669,10 +689,13 @@ one follows is named so a change on one side can be carried to the others. | `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/` | `../../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 diff --git a/sdk/js/README.md b/sdk/js/README.md index 424ae3ce..1624f4d2 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -1,7 +1,8 @@ # @sam-mesh/sdk Native JavaScript SDK for joining a SAM agent mesh from inside the agent -process. It replaces the `sam-node` sidecar for agents written for Node.js: +process. It replaces the `sam-node` sidecar for agents written for Node.js +or running in a browser page: the agent enrolls with the control plane, joins the mesh through a router, finds services and calls them, answers A2A requests for the agent itself, and follows the control plane's keys, bans and policy while it runs. It @@ -18,7 +19,10 @@ Source: [github.com/google/sam/tree/main/sdk/js](https://github.com/google/sam/t npm install @sam-mesh/sdk @modelcontextprotocol/sdk zod ``` -Requires Node.js 22.18 or later. +Requires Node.js 22.18 or later. In a browser, bundle it with the page +(the package's `browser` field selects the browser files); the guide's +[In a browser](https://sam-mesh.dev/docs/guides/native-sdks/#in-a-browser) +section has the details and an example page. ## Use diff --git a/sdk/js/examples/browser/app.js b/sdk/js/examples/browser/app.js new file mode 100644 index 00000000..75276b73 --- /dev/null +++ b/sdk/js/examples/browser/app.js @@ -0,0 +1,176 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// A member of the mesh in a browser page: the same Echo agent as +// a2a-agent.ts, answering with a fetch handler instead of an Express app, +// and the same caller as a2a-call.ts. Bundle it for the page with +// +// npm run build && node scripts/bundle-browser.mjs examples/browser/app.js +// +// and serve together with index.html. The page's identity and +// credential are kept in IndexedDB, so a reload resumes the same peer +// without a token. + +import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, Message, Role } from "@a2a-js/sdk"; +import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory } from "@a2a-js/sdk/client"; +import { AgentEvent, DefaultRequestHandler, InMemoryTaskStore, JsonRpcTransportHandler, ServerCallContext } from "@a2a-js/sdk/server"; +import { AgentMesh, MeshSession } from "@sam-mesh/sdk"; + +const $ = (id) => document.getElementById(id); +const params = new URLSearchParams(location.search); +$("control-plane-url").value = params.get("controlPlaneUrl") ?? location.origin; +$("bootstrap-token").value = params.get("bootstrapToken") ?? ""; +$("target-peer").value = params.get("peerId") ?? ""; + +let session; + +function log(line) { + $("log").textContent += line + "\n"; +} + +/** The Echo agent: answers every message with what it said and who sent it. */ +class EchoExecutor { + async execute(context, eventBus) { + const said = context.userMessage.parts.map((p) => (p.content?.$case === "text" ? p.content.value : "")).join(""); + const caller = context.context.user?.userName ?? "someone"; + log(`message from ${caller}: ${said}`); + eventBus.publish( + AgentEvent.message({ + messageId: crypto.randomUUID(), + contextId: context.contextId, + taskId: "", + role: Role.ROLE_AGENT, + parts: [{ content: { $case: "text", value: `${caller} said: ${said}` }, filename: "", mediaType: "text/plain", metadata: undefined }], + metadata: undefined, + extensions: [], + referenceTaskIds: [], + }), + ); + eventBus.finished(); + } + + async cancelTask() {} +} + +function agentCard(url) { + return { + name: "Echo agent (browser)", + description: "Answers every message with what it said and who sent it.", + version: "1.0.0", + supportedInterfaces: [{ url, protocolBinding: "JSONRPC", tenant: "", protocolVersion: A2A_PROTOCOL_VERSION }], + provider: undefined, + documentationUrl: "", + capabilities: { streaming: true, pushNotifications: false, extendedAgentCard: false, extensions: [] }, + securitySchemes: {}, + securityRequirements: [], + defaultInputModes: ["text/plain"], + defaultOutputModes: ["text/plain"], + skills: [{ id: "echo", name: "Echo", description: "Repeats the message.", tags: ["echo"], examples: ["hello"], inputModes: [], outputModes: [], securityRequirements: [] }], + signatures: [], + iconUrl: "", + }; +} + +/** + * The handler behind a2a://agent. The SDK has already authorized the caller; + * `caller` is its verified biscuit, so the A2A user is the peer ID it is + * bound to, never anything the request said about itself. + */ +function a2aHandler(card) { + const transport = new JsonRpcTransportHandler(new DefaultRequestHandler(card, new InMemoryTaskStore(), new EchoExecutor())); + return async (request, caller) => { + const path = new URL(request.url).pathname; + if (request.method === "GET" && path === `/${AGENT_CARD_PATH}`) { + return Response.json(card); + } + if (request.method !== "POST") { + return new Response("not found\n", { status: 404 }); + } + const context = new ServerCallContext({ user: { isAuthenticated: true, userName: caller.peerId }, requestedVersion: request.headers.get("a2a-version") ?? undefined }); + const result = await transport.handle(await request.text(), context); + if (Symbol.asyncIterator in result) { + // message/stream: one SSE event per JSON-RPC response, as it is produced. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async pull(controller) { + const next = await result.next(); + if (next.done) { + controller.close(); + } else { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(next.value)}\n\n`)); + } + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json(result); + }; +} + +async function join(controlPlaneUrl, bootstrapToken) { + $("status").textContent = "enrolling"; + const mesh = await AgentMesh.enroll({ + controlPlaneUrl, + // Empty resumes the enrollment saved in IndexedDB under this name. + ...(bootstrapToken !== "" ? { bootstrapToken } : {}), + stateDir: "browser-agent", + // A plaintext http:// control plane is otherwise accepted only on loopback. + allowInsecure: params.get("allowInsecure") === "true", + }); + $("status").textContent = "joining"; + session = await mesh.join(); + const url = MeshSession.meshURL(session.peerId, "a2a://agent"); + await session.acceptA2A({ handler: a2aHandler(agentCard(url)) }); + $("peer-id").textContent = session.peerId; + $("status").textContent = `on the mesh, accepting a2a://agent`; + window.addEventListener("pagehide", () => void session.close()); + return session.peerId; +} + +async function call(peerId, text) { + const fetchImpl = session.fetch(); + const factory = new ClientFactory({ + transports: [new JsonRpcTransportFactory({ fetchImpl })], + cardResolver: new DefaultAgentCardResolver({ fetchImpl }), + }); + const client = await factory.createFromUrl(MeshSession.meshURL(peerId, "a2a://agent") + "/"); + const card = await client.getAgentCard(); + const answer = await client.sendMessage({ + tenant: "", + message: Message.fromJSON({ messageId: crypto.randomUUID(), role: Role[Role.ROLE_USER], parts: [{ text }] }), + configuration: undefined, + metadata: undefined, + }); + const parts = "parts" in answer ? answer.parts : (answer.status?.message?.parts ?? []); + const reply = parts.map((p) => (p.content?.$case === "text" ? p.content.value : "")).join(""); + $("answer").textContent = `${card.name}: ${reply}`; + return reply; +} + +$("join-form").addEventListener("submit", (event) => { + event.preventDefault(); + join($("control-plane-url").value, $("bootstrap-token").value).catch((err) => { + $("status").textContent = `failed: ${err.message}`; + }); +}); + +$("call-form").addEventListener("submit", (event) => { + event.preventDefault(); + call($("target-peer").value, $("message").value).catch((err) => { + $("answer").textContent = `failed: ${err.message}`; + }); +}); + +// For scripts and tests driving the page. +window.sam = { join, call, get session() { return session; } }; diff --git a/sdk/js/examples/browser/index.html b/sdk/js/examples/browser/index.html new file mode 100644 index 00000000..1d60ca41 --- /dev/null +++ b/sdk/js/examples/browser/index.html @@ -0,0 +1,53 @@ + + + + + + SAM browser agent + + + +

SAM browser agent

+

+ This page is a member of the mesh: it enrolls with the control plane, joins through a router over WebSocket and Noise, + answers A2A requests as a2a://agent and calls other agents by peer ID. +

+
+ + + +
+

Status: not joined

+

Peer ID:

+
+ + + +
+

Answer:

+

Requests received

+

+    
+  
+
diff --git a/sdk/js/package-lock.json b/sdk/js/package-lock.json
index 73101ea1..9376cbb5 100644
--- a/sdk/js/package-lock.json
+++ b/sdk/js/package-lock.json
@@ -11,6 +11,7 @@
       "dependencies": {
         "@biscuit-auth/biscuit-wasm": "^0.6.0",
         "@bufbuild/protobuf": "^2.15.0",
+        "@chainsafe/libp2p-noise": "^17.0.0",
         "@chainsafe/libp2p-yamux": "^8.0.1",
         "@libp2p/circuit-relay-v2": "^4.2.13",
         "@libp2p/crypto": "^5.1.23",
@@ -26,8 +27,10 @@
         "@libp2p/websockets": "^10.1.21",
         "@modelcontextprotocol/sdk": "^1.30.1",
         "@multiformats/multiaddr": "^13.0.3",
+        "@noble/curves": "^2.0.1",
         "libp2p": "^3.3.11",
         "multiformats": "^14.0.5",
+        "uint8arrays": "^6.1.1",
         "zod": "^4.6.5"
       },
       "devDependencies": {
@@ -35,6 +38,7 @@
         "@bufbuild/protoc-gen-es": "^2.15.0",
         "@types/express": "^5.0.6",
         "@types/node": "^26.6.1",
+        "esbuild": "^0.28.2",
         "express": "^5.2.1",
         "typescript": "^7.0.2"
       },
@@ -136,12 +140,74 @@
         "node": ">=14.17"
       }
     },
+    "node_modules/@chainsafe/as-chacha20poly1305": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/@chainsafe/as-chacha20poly1305/-/as-chacha20poly1305-0.1.0.tgz",
+      "integrity": "sha512-BpNcL8/lji/GM3+vZ/bgRWqJ1q5kwvTFmGPk7pxm/QQZDbaMI98waOHjEymTjq2JmdD/INdNBFOVSyJofXg7ew=="
+    },
+    "node_modules/@chainsafe/as-sha256": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/@chainsafe/as-sha256/-/as-sha256-1.2.5.tgz",
+      "integrity": "sha512-8LzmWxBC/2O5BgbP+aRom/FaDJMrM04VZMlVIivYTc6yNQKYrONmIv27m3q/1VBgxrfrNNqmibitb2hE2tQzIw=="
+    },
     "node_modules/@chainsafe/is-ip": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/@chainsafe/is-ip/-/is-ip-2.1.0.tgz",
       "integrity": "sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==",
       "license": "MIT"
     },
+    "node_modules/@chainsafe/libp2p-noise": {
+      "version": "17.0.0",
+      "resolved": "https://registry.npmjs.org/@chainsafe/libp2p-noise/-/libp2p-noise-17.0.0.tgz",
+      "integrity": "sha512-vwrmY2Y+L1xYhIDiEpl61KHxwrLCZoXzTpwhyk34u+3+6zCAZPL3GxH3i2cs+u5IYNoyLptORdH17RKFXy7upA==",
+      "dependencies": {
+        "@chainsafe/as-chacha20poly1305": "^0.1.0",
+        "@chainsafe/as-sha256": "^1.2.0",
+        "@libp2p/crypto": "^5.1.9",
+        "@libp2p/interface": "^3.0.0",
+        "@libp2p/peer-id": "^6.0.0",
+        "@libp2p/utils": "^7.0.0",
+        "@noble/ciphers": "^2.0.1",
+        "@noble/curves": "^2.0.1",
+        "@noble/hashes": "^2.0.1",
+        "protons-runtime": "^5.6.0",
+        "uint8arraylist": "^2.4.8",
+        "uint8arrays": "^5.1.0",
+        "wherearewe": "^2.0.1"
+      }
+    },
+    "node_modules/@chainsafe/libp2p-noise/node_modules/multiformats": {
+      "version": "13.4.2",
+      "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz",
+      "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ=="
+    },
+    "node_modules/@chainsafe/libp2p-noise/node_modules/protons-runtime": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/protons-runtime/-/protons-runtime-5.6.0.tgz",
+      "integrity": "sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==",
+      "dependencies": {
+        "uint8-varint": "^2.0.2",
+        "uint8arraylist": "^2.4.3",
+        "uint8arrays": "^5.0.1"
+      }
+    },
+    "node_modules/@chainsafe/libp2p-noise/node_modules/uint8-varint": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.5.tgz",
+      "integrity": "sha512-jeFLbL/x30wBRnWjKE1qVBXeumG46r7XmYkpis955lTQ+blccGKFrOsSMHlxePwYB1pI7L8YPHz1t4jLxEs3nA==",
+      "dependencies": {
+        "uint8arraylist": "^2.0.0",
+        "uint8arrays": "^5.0.0"
+      }
+    },
+    "node_modules/@chainsafe/libp2p-noise/node_modules/uint8arrays": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz",
+      "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==",
+      "dependencies": {
+        "multiformats": "^13.0.0"
+      }
+    },
     "node_modules/@chainsafe/libp2p-yamux": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/@chainsafe/libp2p-yamux/-/libp2p-yamux-8.0.1.tgz",
@@ -176,6 +242,422 @@
         "node": ">=6"
       }
     },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+      "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+      "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+      "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+      "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+      "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+      "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+      "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+      "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+      "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+      "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+      "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+      "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+      "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+      "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+      "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+      "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+      "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+      "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+      "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+      "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@hono/node-server": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
@@ -767,6 +1249,17 @@
         "@multiformats/multiaddr": "^13.0.0"
       }
     },
+    "node_modules/@noble/ciphers": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
+      "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/@noble/curves": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
@@ -1855,6 +2348,47 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/esbuild": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+      "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.2",
+        "@esbuild/android-arm": "0.28.2",
+        "@esbuild/android-arm64": "0.28.2",
+        "@esbuild/android-x64": "0.28.2",
+        "@esbuild/darwin-arm64": "0.28.2",
+        "@esbuild/darwin-x64": "0.28.2",
+        "@esbuild/freebsd-arm64": "0.28.2",
+        "@esbuild/freebsd-x64": "0.28.2",
+        "@esbuild/linux-arm": "0.28.2",
+        "@esbuild/linux-arm64": "0.28.2",
+        "@esbuild/linux-ia32": "0.28.2",
+        "@esbuild/linux-loong64": "0.28.2",
+        "@esbuild/linux-mips64el": "0.28.2",
+        "@esbuild/linux-ppc64": "0.28.2",
+        "@esbuild/linux-riscv64": "0.28.2",
+        "@esbuild/linux-s390x": "0.28.2",
+        "@esbuild/linux-x64": "0.28.2",
+        "@esbuild/netbsd-arm64": "0.28.2",
+        "@esbuild/netbsd-x64": "0.28.2",
+        "@esbuild/openbsd-arm64": "0.28.2",
+        "@esbuild/openbsd-x64": "0.28.2",
+        "@esbuild/openharmony-arm64": "0.28.2",
+        "@esbuild/sunos-x64": "0.28.2",
+        "@esbuild/win32-arm64": "0.28.2",
+        "@esbuild/win32-ia32": "0.28.2",
+        "@esbuild/win32-x64": "0.28.2"
+      }
+    },
     "node_modules/escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -2196,6 +2730,11 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/is-electron": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
+      "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="
+    },
     "node_modules/is-loopback-addr": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-loopback-addr/-/is-loopback-addr-2.1.0.tgz",
@@ -3311,6 +3850,18 @@
         "tslib": "^2.8.1"
       }
     },
+    "node_modules/wherearewe": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/wherearewe/-/wherearewe-2.0.1.tgz",
+      "integrity": "sha512-XUguZbDxCA2wBn2LoFtcEhXL6AXo+hVjGonwhSTTTU9SzbWG8Xu3onNIpzf9j/mYUcJQ0f+m37SzG77G851uFw==",
+      "dependencies": {
+        "is-electron": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=16.0.0",
+        "npm": ">=7.0.0"
+      }
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/sdk/js/package.json b/sdk/js/package.json
index 544d401e..ea85f34f 100644
--- a/sdk/js/package.json
+++ b/sdk/js/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@sam-mesh/sdk",
   "version": "0.1.0",
-  "description": "Native SDK for joining a SAM agent mesh from inside a Node.js agent process",
+  "description": "Native SDK for joining a SAM agent mesh from a Node.js agent process or a browser page",
   "license": "Apache-2.0",
   "repository": {
     "type": "git",
@@ -18,6 +18,12 @@
       "default": "./dist/index.js"
     }
   },
+  "browser": {
+    "./dist/platform/ingress.js": "./dist/platform/ingress.browser.js",
+    "./dist/platform/state.js": "./dist/platform/state.browser.js",
+    "./dist/platform/transports.js": "./dist/platform/transports.browser.js",
+    "./dist/platform/wasm.js": "./dist/platform/wasm.browser.js"
+  },
   "files": [
     "dist",
     "README.md"
@@ -31,11 +37,13 @@
     "examples": "tsc -p tsconfig.examples.json",
     "typecheck": "tsc -p tsconfig.json --noEmit",
     "pretest": "tsc -p tsconfig.test.json",
-    "test": "node --test 'build/**/*.test.js'"
+    "test": "node --test 'build/**/*.test.js'",
+    "bundle:browser": "node scripts/bundle-browser.mjs"
   },
   "dependencies": {
     "@biscuit-auth/biscuit-wasm": "^0.6.0",
     "@bufbuild/protobuf": "^2.15.0",
+    "@chainsafe/libp2p-noise": "^17.0.0",
     "@chainsafe/libp2p-yamux": "^8.0.1",
     "@libp2p/circuit-relay-v2": "^4.2.13",
     "@libp2p/crypto": "^5.1.23",
@@ -51,8 +59,10 @@
     "@libp2p/websockets": "^10.1.21",
     "@modelcontextprotocol/sdk": "^1.30.1",
     "@multiformats/multiaddr": "^13.0.3",
+    "@noble/curves": "^2.0.1",
     "libp2p": "^3.3.11",
     "multiformats": "^14.0.5",
+    "uint8arrays": "^6.1.1",
     "zod": "^4.6.5"
   },
   "devDependencies": {
@@ -60,6 +70,7 @@
     "@bufbuild/protoc-gen-es": "^2.15.0",
     "@types/express": "^5.0.6",
     "@types/node": "^26.6.1",
+    "esbuild": "^0.28.2",
     "express": "^5.2.1",
     "typescript": "^7.0.2"
   }
diff --git a/sdk/js/scripts/bundle-browser.mjs b/sdk/js/scripts/bundle-browser.mjs
new file mode 100644
index 00000000..1d95020c
--- /dev/null
+++ b/sdk/js/scripts/bundle-browser.mjs
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Bundles an ES module for a browser page with esbuild, the way a page's own
+// bundler would: package.json "browser" fields swap the Node platform files
+// for their browser twins, and a .wasm import (biscuit-wasm is built for
+// bundlers and imports its module that way) becomes a module that fetches
+// the file beside the bundle and instantiates it. Bundling dist/index.js
+// this way also proves that nothing in the browser graph reaches for Node.
+//
+//   node scripts/bundle-browser.mjs [entry] [outdir]
+//
+// Defaults: dist/index.js into build/browser.
+
+import * as esbuild from "esbuild";
+import { readFile } from "node:fs/promises";
+import { basename, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const [entry = join(here, "..", "dist", "index.js"), outdir = join(here, "..", "build", "browser")] = process.argv.slice(2);
+
+/** import * as wasm from "./x.wasm" as WebAssembly ESM integration would resolve it. */
+const wasmModules = {
+  name: "wasm-esm",
+  setup(build) {
+    build.onResolve({ filter: /\.wasm$/ }, (args) => ({ path: join(args.resolveDir, args.path), namespace: "wasm-esm" }));
+    build.onLoad({ filter: /.*/, namespace: "wasm-esm" }, async (args) => {
+      const bytes = await readFile(args.path);
+      const module = new WebAssembly.Module(bytes);
+      const importModules = [...new Set(WebAssembly.Module.imports(module).map((imp) => imp.module))];
+      const exports = WebAssembly.Module.exports(module).map((exp) => exp.name);
+      const lines = [];
+      const imports = [];
+      importModules.forEach((mod, i) => {
+        if (mod.startsWith("./") || mod.startsWith("../")) {
+          lines.push(`import * as m${i} from ${JSON.stringify(mod)};`);
+          imports.push(`${JSON.stringify(mod)}: m${i}`);
+        } else {
+          // wasm-bindgen's own placeholder module: intrinsics the bindings provide by name.
+          imports.push(`${JSON.stringify(mod)}: { performance_now: () => performance.now() }`);
+        }
+      });
+      lines.push(`const url = new URL(${JSON.stringify(basename(args.path))}, import.meta.url);`);
+      lines.push(`const { instance } = await WebAssembly.instantiateStreaming(fetch(url), { ${imports.join(", ")} });`);
+      for (const name of exports) {
+        lines.push(`export const ${name} = instance.exports[${JSON.stringify(name)}];`);
+      }
+      return { contents: lines.join("\n"), loader: "js", resolveDir: dirname(args.path), watchFiles: [args.path] };
+    });
+    // The .wasm file itself goes beside the bundle under its own name.
+    build.onResolve({ filter: /\.wasm$/, namespace: "wasm-esm" }, (args) => ({ path: args.path, namespace: "wasm-file" }));
+  },
+};
+
+const result = await esbuild.build({
+  entryPoints: [entry],
+  bundle: true,
+  format: "esm",
+  platform: "browser",
+  target: "es2022",
+  outdir,
+  entryNames: "[name]",
+  assetNames: "[name]",
+  sourcemap: true,
+  plugins: [wasmModules],
+  metafile: true,
+  logLevel: "warning",
+});
+
+// The bytes the stub fetches: copy every .wasm the graph touched beside the bundle.
+const { copyFile, mkdir, readdir } = await import("node:fs/promises");
+await mkdir(outdir, { recursive: true });
+for (const input of Object.keys(result.metafile.inputs)) {
+  if (input.startsWith("wasm-esm:")) {
+    const file = input.slice("wasm-esm:".length);
+    await copyFile(file, join(outdir, basename(file)));
+  }
+}
+// A page's static files beside the entry (index.html) go with it.
+for (const name of await readdir(dirname(entry))) {
+  if (name.endsWith(".html") || name.endsWith(".css")) {
+    await copyFile(join(dirname(entry), name), join(outdir, name));
+  }
+}
+const nodeBuiltins = Object.keys(result.metafile.inputs).filter((p) => p.startsWith("node:"));
+if (nodeBuiltins.length > 0) {
+  console.error(`browser bundle reaches Node: ${nodeBuiltins.join(", ")}`);
+  process.exit(1);
+}
+console.log(`bundled ${entry} into ${outdir}`);
diff --git a/sdk/js/src/authorizer.test.ts b/sdk/js/src/authorizer.test.ts
index 039b7891..b528e1c4 100644
--- a/sdk/js/src/authorizer.test.ts
+++ b/sdk/js/src/authorizer.test.ts
@@ -93,8 +93,11 @@ test("the empty target is the protocol in the system namespace", async () => {
   await assert.rejects(authorizeCaller(request(token, "mcp://calc"), options([])), AuthorizationError);
 });
 
-test("a service the role was not granted is denied", async () => {
-  await assert.rejects(authorizeCaller(request(nodeToken(CALLER), "mcp://other"), options(NODE_ROLE_GRANTS)), AuthorizationError);
+test("a service the role was not granted is denied, and the refusal names the check", async () => {
+  await assert.rejects(
+    authorizeCaller(request(nodeToken(CALLER), "mcp://other"), options(NODE_ROLE_GRANTS)),
+    (err: unknown) => err instanceof AuthorizationError && /FailedLogic/.test(err.message) && !/\[object Object\]/.test(err.message),
+  );
 });
 
 test("a role with no grants at all is denied", async () => {
diff --git a/sdk/js/src/authorizer.ts b/sdk/js/src/authorizer.ts
index d5bf4a36..efe843f3 100644
--- a/sdk/js/src/authorizer.ts
+++ b/sdk/js/src/authorizer.ts
@@ -52,8 +52,16 @@ export class AuthorizationError extends Error {
   }
 }
 
+// biscuit-wasm throws plain objects ({ FailedLogic: ... }, { RunLimit: ... }).
 function describe(err: unknown): string {
-  return err instanceof Error ? err.message : String(err);
+  if (err instanceof Error) {
+    return err.message;
+  }
+  try {
+    return JSON.stringify(err);
+  } catch {
+    return String(err);
+  }
 }
 
 function timeFact(now: Date): string {
diff --git a/sdk/js/src/biscuit.ts b/sdk/js/src/biscuit.ts
index 2ef5bd0f..9e2f6d32 100644
--- a/sdk/js/src/biscuit.ts
+++ b/sdk/js/src/biscuit.ts
@@ -16,46 +16,13 @@
 // signed by a trusted control plane key, authority block only, unexpired,
 // and bound to the peer at the other end of the connection.
 
-import { readFile } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
-
-// biscuit-wasm is built for bundlers and imports its .wasm as a module,
-// which Node only does behind a flag. Instantiating it by hand avoids the
-// flag; the module's import list says what it needs.
-type BiscuitWasm = typeof import("@biscuit-auth/biscuit-wasm");
+import { loadBiscuitWasm, type BiscuitWasm } from "./platform/wasm.ts";
 
 let loading: Promise | undefined;
 
+/** The biscuit-wasm module, loaded once, the way the runtime loads it. */
 export function loadBiscuit(): Promise {
-  loading ??= (async () => {
-    const dir = new URL("./", import.meta.resolve("@biscuit-auth/biscuit-wasm"));
-    const bindings = (await import(new URL("biscuit_bg.js", dir).href)) as BiscuitWasm & {
-      __wbg_set_wasm(exports: WebAssembly.Exports): void;
-    };
-    const module = await WebAssembly.compile(await readFile(fileURLToPath(new URL("biscuit_bg.wasm", dir))));
-    const imports: WebAssembly.Imports = {};
-    for (const imp of WebAssembly.Module.imports(module)) {
-      const ns = (imports[imp.module] ??= {}) as Record;
-      if (imp.module === "./biscuit_bg.js") {
-        ns[imp.name] = (bindings as unknown as Record)[imp.name] as WebAssembly.ImportValue;
-      } else if (imp.name === "performance_now") {
-        ns[imp.name] = () => performance.now();
-      } else {
-        throw new Error(`biscuit-wasm needs an unknown import ${imp.module}:${imp.name}`);
-      }
-    }
-    const instance = await WebAssembly.instantiate(module, imports);
-    bindings.__wbg_set_wasm(instance.exports);
-    // The module's start hook logs a greeting to the console.
-    const log = console.log;
-    console.log = () => {};
-    try {
-      (instance.exports as { __wbindgen_start(): void }).__wbindgen_start();
-    } finally {
-      console.log = log;
-    }
-    return bindings;
-  })();
+  loading ??= loadBiscuitWasm();
   return loading;
 }
 
diff --git a/sdk/js/src/bytes.ts b/sdk/js/src/bytes.ts
new file mode 100644
index 00000000..f33245f2
--- /dev/null
+++ b/sdk/js/src/bytes.ts
@@ -0,0 +1,57 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Text encodings of bytes as the Go side reads them, for any JavaScript
+// runtime. The names say which alphabet and whether the text is padded:
+// a biscuit travels as padded standard base64 (Go base64.StdEncoding), a
+// challenge signature as unpadded base64url (Go base64.RawURLEncoding).
+
+import { fromString } from "uint8arrays/from-string";
+import { toString } from "uint8arrays/to-string";
+
+export function toHex(bytes: Uint8Array): string {
+  return toString(bytes, "hex");
+}
+
+export function fromHex(text: string): Uint8Array {
+  return fromString(text, "hex");
+}
+
+/** Padded standard base64 (RFC 4648 section 4), what Go base64.StdEncoding decodes. */
+export function toBase64(bytes: Uint8Array): string {
+  return toString(bytes, "base64pad");
+}
+
+export function fromBase64(text: string): Uint8Array {
+  return fromString(text, "base64pad");
+}
+
+/** Unpadded base64url (RFC 4648 section 5), what Go base64.RawURLEncoding decodes. */
+export function toBase64Url(bytes: Uint8Array): string {
+  return toString(bytes, "base64url");
+}
+
+export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
+  return a.length === b.length && a.every((v, i) => v === b[i]);
+}
+
+export function concatBytes(...parts: Uint8Array[]): Uint8Array {
+  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
+  let offset = 0;
+  for (const p of parts) {
+    out.set(p, offset);
+    offset += p.length;
+  }
+  return out;
+}
diff --git a/sdk/js/src/controlplane.test.ts b/sdk/js/src/controlplane.test.ts
index a368ae55..06408150 100644
--- a/sdk/js/src/controlplane.test.ts
+++ b/sdk/js/src/controlplane.test.ts
@@ -257,3 +257,15 @@ test("keys() fetches and verifies against the enrollment key", async () => {
   assert.deepEqual(await client.keys([cpKey.publicKeyRaw]), [cpKey.publicKeyRaw]);
   await assert.rejects(client.keys([Identity.generate().publicKeyRaw]), /not signed by any trusted/);
 });
+
+test("an injected fetch is called unbound, as a browser's window.fetch requires", async () => {
+  const inner = fakeFetch({ "GET /keys": () => proto(toBinary(KeysResponseSchema, signedKeys([cpKey]))) });
+  let receiver: unknown = "unset";
+  const strict = function (this: unknown, input: Parameters[0], init?: RequestInit) {
+    receiver = this;
+    return inner(input, init);
+  } as typeof fetch;
+  const client = new ControlPlaneClient({ url: "http://127.0.0.1:1", fetch: strict });
+  await client.keys([cpKey.publicKeyRaw]);
+  assert.equal(receiver, undefined);
+});
diff --git a/sdk/js/src/controlplane.ts b/sdk/js/src/controlplane.ts
index ee77c80e..cb711a83 100644
--- a/sdk/js/src/controlplane.ts
+++ b/sdk/js/src/controlplane.ts
@@ -34,6 +34,7 @@ import {
   type KeysResponse,
 } from "./gen/sam_pb.ts";
 import type { Identity } from "./identity.ts";
+import { bytesEqual, toBase64, toBase64Url } from "./bytes.ts";
 import { verifyEd25519 } from "./identity.ts";
 
 export const PROTOBUF_CONTENT_TYPE = "application/x-protobuf";
@@ -154,10 +155,6 @@ export function validateControlPlaneURL(rawUrl: string, allowInsecure = false):
   throw new Error(`control plane URL ${JSON.stringify(rawUrl)} must use http:// or https://`);
 }
 
-function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
-  return a.length === b.length && a.every((v, i) => v === b[i]);
-}
-
 /**
  * Returns the key set if it is fresh and at least one listed key is already
  * trusted and its signature verifies. Mirrors api.VerifyKeysResponse.
@@ -229,7 +226,9 @@ export class ControlPlaneClient {
 
   constructor(options: ControlPlaneClientOptions) {
     this.url = validateControlPlaneURL(options.url, options.allowInsecure ?? false);
-    this.#fetch = options.fetch ?? globalThis.fetch;
+    // Unbound: a browser's fetch refuses to run as a method of anything else.
+    const f = options.fetch ?? globalThis.fetch;
+    this.#fetch = (input, init) => f(input, init);
     this.#timeoutMs = options.timeoutMs ?? 30_000;
   }
 
@@ -280,7 +279,7 @@ export class ControlPlaneClient {
     const sig = identity.sign(enrollStatusChallenge(identity.peerId, ts));
     const body = await this.#request("GET", `/enroll/status?peer_id=${encodeURIComponent(identity.peerId)}`, undefined, {
       [HEADER_CHALLENGE_TIMESTAMP]: String(ts),
-      [HEADER_CHALLENGE_SIGNATURE]: Buffer.from(sig).toString("base64url"),
+      [HEADER_CHALLENGE_SIGNATURE]: toBase64Url(sig),
     });
     return fromBinary(BootstrapEnrollResponseSchema, body);
   }
@@ -322,7 +321,7 @@ export class ControlPlaneClient {
       peerId: identity.peerId,
     });
     const body = await this.#request("POST", "/refresh", toBinary(TokenRefreshRequestSchema, req), {
-      Authorization: `Bearer ${Buffer.from(params.biscuit).toString("base64")}`,
+      Authorization: `Bearer ${toBase64(params.biscuit)}`,
     });
     const resp = fromBinary(TokenRefreshResponseSchema, body);
     if (resp.errorMessage) {
@@ -341,7 +340,7 @@ export class ControlPlaneClient {
    */
   async policyRules(biscuit: Uint8Array): Promise {
     const body = await this.#request("GET", "/policies", undefined, {
-      Authorization: `Bearer ${Buffer.from(biscuit).toString("base64")}`,
+      Authorization: `Bearer ${toBase64(biscuit)}`,
     });
     const resp = fromBinary(PolicyConfigGetResponseSchema, body);
     if (resp.$unknown !== undefined && resp.$unknown.length > 0) {
@@ -360,7 +359,7 @@ export class ControlPlaneClient {
         signal: controller.signal,
       };
       if (body !== undefined) {
-        init.body = Buffer.from(body);
+        init.body = new Uint8Array(body);
         init.headers = { ...init.headers, "Content-Type": PROTOBUF_CONTENT_TYPE };
       }
       const resp = await this.#fetch(new URL(path, this.url), init);
diff --git a/sdk/js/src/credential.ts b/sdk/js/src/credential.ts
index e4490170..8f979b60 100644
--- a/sdk/js/src/credential.ts
+++ b/sdk/js/src/credential.ts
@@ -14,6 +14,7 @@
 
 import { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protobuf";
 import { timestampDate, timestampFromDate, type Timestamp } from "@bufbuild/protobuf/wkt";
+import { toHex } from "./bytes.ts";
 import { AuthFrameSchema, AuthResponseSchema, MemberCredentialSchema, type AuthResponse, type OIDCSession } from "./gen/sam_pb.ts";
 
 /** What a member holds after enrolling: its biscuit and what it trusts. */
@@ -44,8 +45,8 @@ export interface MeshCredential {
 
 /** Whether a key trusted now was unknown when the credential was issued. */
 export function credentialPredatesRotation(c: MeshCredential): boolean {
-  const issued = new Set(c.issuedUnderKeys.map((k) => Buffer.from(k).toString("hex")));
-  return c.controlPlaneKeys.some((k) => !issued.has(Buffer.from(k).toString("hex")));
+  const issued = new Set(c.issuedUnderKeys.map((k) => toHex(k)));
+  return c.controlPlaneKeys.some((k) => !issued.has(toHex(k)));
 }
 
 /** Seconds of validity left on the biscuit; negative once expired. */
@@ -78,7 +79,7 @@ export function credentialToJSON(c: MeshCredential): string {
     biscuit: c.biscuit,
     expireTime: timestampFromDate(new Date(c.expiration * 1000)),
     trustedKeys: c.controlPlaneKeys.map((k) => {
-      const receiveTime = c.extra?.receiveTime.get(Buffer.from(k).toString("hex"));
+      const receiveTime = c.extra?.receiveTime.get(toHex(k));
       return receiveTime !== undefined ? { publicKey: k, receiveTime } : { publicKey: k };
     }),
     issuedUnderKeys: c.issuedUnderKeys,
@@ -97,7 +98,7 @@ export function credentialFromJSON(text: string): MeshCredential {
   const receiveTime = new Map();
   for (const k of message.trustedKeys) {
     if (k.receiveTime !== undefined) {
-      receiveTime.set(Buffer.from(k.publicKey).toString("hex"), k.receiveTime);
+      receiveTime.set(toHex(k.publicKey), k.receiveTime);
     }
   }
   return {
diff --git a/sdk/js/src/host.ts b/sdk/js/src/host.ts
index cc1ddc73..e3c21de5 100644
--- a/sdk/js/src/host.ts
+++ b/sdk/js/src/host.ts
@@ -13,10 +13,10 @@
 // limitations under the License.
 
 // The libp2p host a member joins the mesh with, configured the way
-// sam-node's is (internal/node/node.go): TLS is the only security
-// protocol, yamux the muxer, circuit relay v2 for reachability through
-// the routers. TCP and WebSocket are the transports: the testnets' routers
-// listen on TCP, sam-one's single port is a WebSocket listener.
+// sam-node's is (internal/node/node.go): yamux the muxer, circuit relay v2
+// for reachability through the routers. Transports and the security
+// protocol come from the runtime (platform/transports.ts): on Node TCP and
+// WebSocket with TLS, in a browser WebSocket with Noise.
 
 import { yamux } from "@chainsafe/libp2p-yamux";
 import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
@@ -26,13 +26,11 @@ import { identify } from "@libp2p/identify";
 import type { Libp2p, PeerId } from "@libp2p/interface";
 import { kadDHT, passthroughMapper } from "@libp2p/kad-dht";
 import { ping } from "@libp2p/ping";
-import { tcp } from "@libp2p/tcp";
-import { tls } from "@libp2p/tls";
-import { webSockets } from "@libp2p/websockets";
 import type { Multiaddr } from "@multiformats/multiaddr";
 import { createLibp2p, type Libp2pOptions } from "libp2p";
 import { DHT_PROTOCOL } from "./discovery.ts";
 import type { Identity } from "./identity.ts";
+import { connectionEncrypters, transports } from "./platform/transports.ts";
 
 export interface MeshHostOptions {
   /**
@@ -61,13 +59,20 @@ export async function createMeshHost(identity: Identity, options: MeshHostOption
     privateKey: privateKeyFromProtobuf(identity.toLibp2pPrivateKey()),
     addresses: { listen: options.listenAddrs ?? [] },
     ...(options.dns !== undefined ? { dns: options.dns } : {}),
-    transports: [tcp(), webSockets(), circuitRelayTransport()],
-    connectionEncrypters: [tls()],
+    transports: [...transports(), circuitRelayTransport()],
+    connectionEncrypters: connectionEncrypters(),
     streamMuxers: [yamux()],
     connectionGater: {
       denyDialPeer: denyBanned,
       denyInboundEncryptedConnection: denyBanned,
       denyInboundRelayedConnection: (_relay, remotePeer) => denyBanned(remotePeer),
+      // The addresses dialled are the routers' as the control plane
+      // published them, and every connection is authenticated by peer ID
+      // whatever the address, so no address is refused for its shape. In a
+      // browser js-libp2p would otherwise skip loopback and plain ws://
+      // addresses, which is what sam-one on the same machine advertises; a
+      // page on https cannot open ws:// anyway, the browser sees to that.
+      denyDialMultiaddr: () => false,
     },
     services: {
       identify: identify(),
diff --git a/sdk/js/src/http1.test.ts b/sdk/js/src/http1.test.ts
new file mode 100644
index 00000000..d0374eae
--- /dev/null
+++ b/sdk/js/src/http1.test.ts
@@ -0,0 +1,172 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The HTTP/1.1 codec on its own: what the ingress and the client agree on
+// with go-libp2p-http, fed byte by byte so every split point is exercised.
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+  ByteReader,
+  HTTPParseError,
+  LAST_CHUNK,
+  MAX_HEAD_BYTES,
+  bodyStream,
+  encodeChunk,
+  encodeRequestHead,
+  encodeResponseHead,
+  readBody,
+  readRequestHead,
+  readResponseHead,
+  requestBodyFraming,
+  responseBodyFraming,
+  type ByteSource,
+} from "./http1.ts";
+
+const enc = new TextEncoder();
+const dec = new TextDecoder();
+
+/** A source that hands out the text in pieces of the given size. */
+function source(text: string | Uint8Array, pieceSize = 1): ByteSource {
+  const bytes = typeof text === "string" ? enc.encode(text) : text;
+  let offset = 0;
+  return {
+    async read() {
+      if (offset >= bytes.length) {
+        return null;
+      }
+      const next = bytes.subarray(offset, offset + pieceSize);
+      offset += pieceSize;
+      return next;
+    },
+  };
+}
+
+test("a request head parses however the bytes are split", async () => {
+  const wire = "POST /a2a/agent/tasks?x=1 HTTP/1.1\r\nHost: 12D3KooW\r\nX-Sam-Biscuit: abc=\r\nContent-Length: 2\r\n\r\n{}";
+  for (const size of [1, 3, 7, 1000]) {
+    const reader = new ByteReader(source(wire, size));
+    const head = await readRequestHead(reader);
+    assert.ok(head);
+    assert.equal(head.method, "POST");
+    assert.equal(head.target, "/a2a/agent/tasks?x=1");
+    assert.equal(head.headers.get("host"), "12D3KooW");
+    assert.equal(head.headers.get("x-sam-biscuit"), "abc=");
+    assert.deepEqual(requestBodyFraming(head), { kind: "length", length: 2 });
+    assert.equal(dec.decode(await readBody(reader, requestBodyFraming(head), 1024)), "{}");
+  }
+  // Nothing at all is a closed stream, not an error.
+  assert.equal(await readRequestHead(new ByteReader(source(""))), null);
+});
+
+test("a request without a length has no body; malformed heads are refused", async () => {
+  const head = await readRequestHead(new ByteReader(source("GET /a2a/agent HTTP/1.1\r\nHost: x\r\n\r\n")));
+  assert.deepEqual(requestBodyFraming(head!), { kind: "none" });
+  for (const bad of ["GET /x\r\n\r\n", "GET /x HTTP/2\r\n\r\n", "GET /x HTTP/1.1\r\nbad header\r\n\r\n", "GET /x HTTP/1.1\r\n bad: fold\r\n\r\n", "GET /x HTTP/1.1\r\nHost: x"]) {
+    await assert.rejects(readRequestHead(new ByteReader(source(bad, 64))), HTTPParseError, bad);
+  }
+  const huge = "GET /x HTTP/1.1\r\n" + "X-Pad: " + "a".repeat(MAX_HEAD_BYTES) + "\r\n\r\n";
+  await assert.rejects(readRequestHead(new ByteReader(source(huge, 4096))), HTTPParseError);
+  await assert.rejects(readRequestHead(new ByteReader(source("GET /x HTTP/1.1\r\nContent-Length: 1, 2\r\n\r\n"))).then((h) => requestBodyFraming(h!)), HTTPParseError);
+  await assert.rejects(readRequestHead(new ByteReader(source("GET /x HTTP/1.1\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"))).then((h) => requestBodyFraming(h!)), HTTPParseError);
+});
+
+test("a chunked body is reassembled, extensions and trailers skipped, and streamed piece by piece", async () => {
+  const wire = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n" + "5;ext=1\r\nhello\r\n" + "6\r\n world\r\n" + "0\r\nX-Trailer: t\r\n\r\n";
+  for (const size of [1, 2, 5, 64]) {
+    const reader = new ByteReader(source(wire, size));
+    const head = await readResponseHead(reader);
+    assert.equal(head.status, 200);
+    assert.equal(head.statusText, "OK");
+    const framing = responseBodyFraming("GET", head);
+    assert.deepEqual(framing, { kind: "chunked" });
+    assert.equal(dec.decode(await readBody(reader, framing, 1024)), "hello world");
+  }
+  let finished = false;
+  const reader = new ByteReader(source(wire, 1000));
+  const head = await readResponseHead(reader);
+  const pieces: string[] = [];
+  const stream = bodyStream(reader, responseBodyFraming("GET", head), () => (finished = true));
+  for await (const piece of stream) {
+    pieces.push(dec.decode(piece));
+  }
+  assert.deepEqual(pieces, ["hello", " world"]);
+  assert.ok(finished);
+  // Cancelling the stream reports the reason once and closes the generator,
+  // so nothing is read after the caller has gone.
+  const slow = new ByteReader(source("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n", 3));
+  await readResponseHead(slow);
+  const reasons: (Error | undefined)[] = [];
+  const cancelled = bodyStream(slow, { kind: "chunked" }, (err) => reasons.push(err));
+  const r = cancelled.getReader();
+  assert.ok("hello".startsWith(dec.decode((await r.read()).value)));
+  await r.cancel(new Error("gone"));
+  assert.equal(reasons.length, 1);
+  assert.equal(reasons[0]?.message, "gone");
+  assert.deepEqual(await r.read(), { done: true, value: undefined });
+  // Each malformed size line is followed by a body that would otherwise
+  // parse, so only the size check can be what refuses it.
+  for (const bad of ["zz\r\nhello\r\n0\r\n\r\n", "5g\r\nhello\r\n0\r\n\r\n", "5 g\r\nhello\r\n0\r\n\r\n", "-5\r\nhello\r\n0\r\n\r\n", "\r\nhello\r\n0\r\n\r\n", "123456789\r\n0\r\n\r\n"]) {
+    await assert.rejects(readBody(new ByteReader(source(bad)), { kind: "chunked" }, 1024), HTTPParseError, bad);
+  }
+  for (const bad of ["5\r\nhello\r\n", "5\r\nhelloXX", "5\r\nhel"]) {
+    await assert.rejects(readBody(new ByteReader(source(bad)), { kind: "chunked" }, 1024), HTTPParseError, bad);
+  }
+  // Whitespace around the extension separator is allowed (RFC 9112 section 7.1.1).
+  assert.equal(dec.decode(await readBody(new ByteReader(source("5 ; ext=1\r\nhello\r\n0\r\n\r\n")), { kind: "chunked" }, 1024)), "hello");
+});
+
+test("a response without framing runs to the end of the stream; some have no body at all", async () => {
+  const reader = new ByteReader(source("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nuntil the end", 3));
+  const head = await readResponseHead(reader);
+  const framing = responseBodyFraming("GET", head);
+  assert.deepEqual(framing, { kind: "until-close" });
+  assert.equal(dec.decode(await readBody(reader, framing, 1024)), "until the end");
+  for (const [method, status] of [["HEAD", 200], ["GET", 204], ["GET", 304], ["GET", 101]] as const) {
+    const h = await readResponseHead(new ByteReader(source(`HTTP/1.1 ${status} X\r\nContent-Length: 10\r\n\r\n`)));
+    assert.deepEqual(responseBodyFraming(method, h), { kind: "none" }, `${method} ${status}`);
+  }
+  // A status line with no reason phrase is still a status line.
+  assert.equal((await readResponseHead(new ByteReader(source("HTTP/1.1 204\r\n\r\n")))).status, 204);
+  await assert.rejects(readResponseHead(new ByteReader(source("HTTP/1.1 twenty\r\n\r\n"))), HTTPParseError);
+  await assert.rejects(readResponseHead(new ByteReader(source(""))), HTTPParseError);
+});
+
+test("a line past the limit is refused however the bytes arrive", async () => {
+  assert.equal(await new ByteReader(source("abcde\r\nrest", 1000)).readLine(5), "abcde");
+  assert.equal(await new ByteReader(source("abcde\r\nrest", 1)).readLine(5), "abcde");
+  // In one piece, so the line is complete before the limit is compared.
+  await assert.rejects(new ByteReader(source("abcdef\r\nrest", 1000)).readLine(5), HTTPParseError);
+  await assert.rejects(new ByteReader(source("abcdef\r\nrest", 1)).readLine(5), HTTPParseError);
+  await assert.rejects(new ByteReader(source("a".repeat(100), 1)).readLine(5), HTTPParseError);
+});
+
+test("bodies past the limit are refused, up front when the length says so", async () => {
+  await assert.rejects(readBody(new ByteReader(source("x".repeat(20))), { kind: "length", length: 20 }, 10), HTTPParseError);
+  await assert.rejects(readBody(new ByteReader(source("x".repeat(20))), { kind: "until-close" }, 10), HTTPParseError);
+  await assert.rejects(readBody(new ByteReader(source("x".repeat(5))), { kind: "length", length: 20 }, 100), HTTPParseError);
+});
+
+test("heads and chunks are written as the other side reads them", async () => {
+  const headers = new Headers({ Host: "peer", "X-Sam-Biscuit": "abc=", "Content-Length": "0" });
+  assert.equal(dec.decode(encodeRequestHead("GET", "/a2a/agent/card", headers)), "GET /a2a/agent/card HTTP/1.1\r\ncontent-length: 0\r\nhost: peer\r\nx-sam-biscuit: abc=\r\n\r\n");
+  assert.equal(dec.decode(encodeResponseHead(404, "Not Found", new Headers({ "Content-Type": "text/plain" }))), "HTTP/1.1 404 Not Found\r\ncontent-type: text/plain\r\n\r\n");
+  assert.equal(dec.decode(encodeResponseHead(200, "", new Headers())), "HTTP/1.1 200 \r\n\r\n");
+  // Headers itself refuses a line break in a value; a stray one in the reason phrase is flattened.
+  assert.throws(() => new Headers({ "X-Bad": "a\r\nInjected: 1" }), TypeError);
+  assert.equal(dec.decode(encodeResponseHead(200, "OK\r\nInjected: 1", new Headers())), "HTTP/1.1 200 OK  Injected: 1\r\n\r\n");
+  const body = new Uint8Array([...encodeChunk(enc.encode("hello")), ...encodeChunk(enc.encode(" world")), ...LAST_CHUNK]);
+  assert.equal(dec.decode(body), "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n");
+  assert.equal(dec.decode(await readBody(new ByteReader(source(body, 4)), { kind: "chunked" }, 1024)), "hello world");
+});
diff --git a/sdk/js/src/http1.ts b/sdk/js/src/http1.ts
new file mode 100644
index 00000000..07811e50
--- /dev/null
+++ b/sdk/js/src/http1.ts
@@ -0,0 +1,425 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// HTTP/1.1 on a libp2p stream, as go-libp2p-http speaks it: one request and
+// one response per stream, the body framed by Content-Length or chunked
+// (RFC 9112 section 6), a response without either running to the end of the
+// stream. This is the whole of what a member needs to call a service or
+// answer as one, so it is written here rather than borrowed from Node and
+// runs wherever the SDK does, a browser included.
+
+import type { Stream } from "@libp2p/interface";
+import { concatBytes } from "./bytes.ts";
+
+/** Longest request or response head (start line and headers) accepted. */
+export const MAX_HEAD_BYTES = 64 * 1024;
+const MAX_CHUNK_LINE_BYTES = 1024;
+
+const CR = 0x0d;
+const LF = 0x0a;
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+/** Where bytes come from: a chunk, or null when no more will arrive. */
+export interface ByteSource {
+  read(): Promise;
+}
+
+/** Reads a libp2p stream, holding the remote back while nobody is reading. */
+export function streamSource(stream: Stream): ByteSource {
+  const queue: Uint8Array[] = [];
+  let ended = false;
+  let waiter: (() => void) | undefined;
+  const wake = () => {
+    waiter?.();
+    waiter = undefined;
+  };
+  stream.addEventListener("message", (evt) => {
+    queue.push(evt.data.subarray());
+    stream.pause();
+    wake();
+  });
+  const end = () => {
+    ended = true;
+    wake();
+  };
+  // 'end' fires once the read buffer has drained after the remote closed its
+  // writable end; 'remoteCloseWrite' can fire while data is still buffered.
+  stream.addEventListener("end", end);
+  stream.addEventListener("close", end);
+  if (stream.readableEnded) {
+    ended = true;
+  }
+  return {
+    async read() {
+      for (;;) {
+        const next = queue.shift();
+        if (next !== undefined) {
+          if (queue.length === 0 && !ended) {
+            stream.resume();
+          }
+          return next;
+        }
+        if (ended) {
+          return null;
+        }
+        stream.resume();
+        await new Promise((resolve) => {
+          waiter = resolve;
+        });
+      }
+    },
+  };
+}
+
+/** Sends to a libp2p stream, waiting for the remote when the send buffer is full. */
+export async function sendAll(stream: Stream, data: Uint8Array): Promise {
+  if (data.length === 0) {
+    return;
+  }
+  if (!stream.send(data)) {
+    await stream.onDrain();
+  }
+}
+
+export class HTTPParseError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "HTTPParseError";
+  }
+}
+
+/** Bytes from a source with lookahead: lines and exact counts. */
+export class ByteReader {
+  #source: ByteSource;
+  #buf: Uint8Array = new Uint8Array(0);
+  #eof = false;
+
+  constructor(source: ByteSource) {
+    this.#source = source;
+  }
+
+  async #fill(): Promise {
+    if (this.#eof) {
+      return false;
+    }
+    const next = await this.#source.read();
+    if (next === null) {
+      this.#eof = true;
+      return false;
+    }
+    this.#buf = this.#buf.length === 0 ? next : concatBytes(this.#buf, next);
+    return true;
+  }
+
+  /** One CRLF-terminated line without its terminator, at most limit bytes; null at end of input before any byte. */
+  async readLine(limit: number): Promise {
+    for (;;) {
+      const lf = this.#buf.indexOf(LF);
+      if (lf !== -1) {
+        const end = lf > 0 && this.#buf[lf - 1] === CR ? lf - 1 : lf;
+        if (end > limit) {
+          throw new HTTPParseError(`line exceeds ${limit} bytes`);
+        }
+        const line = decoder.decode(this.#buf.subarray(0, end));
+        this.#buf = this.#buf.subarray(lf + 1);
+        return line;
+      }
+      // Room for the CRLF of a line exactly at the limit.
+      if (this.#buf.length > limit + 2) {
+        throw new HTTPParseError(`line exceeds ${limit} bytes`);
+      }
+      if (!(await this.#fill())) {
+        if (this.#buf.length === 0) {
+          return null;
+        }
+        throw new HTTPParseError("unexpected end of input in a line");
+      }
+    }
+  }
+
+  /** Exactly n bytes, or throws at end of input. */
+  async readExactly(n: number): Promise {
+    while (this.#buf.length < n) {
+      if (!(await this.#fill())) {
+        throw new HTTPParseError(`unexpected end of input after ${this.#buf.length} of ${n} bytes`);
+      }
+    }
+    const out = this.#buf.slice(0, n);
+    this.#buf = this.#buf.subarray(n);
+    return out;
+  }
+
+  /** Whatever is available next, at most n bytes; null at end of input. */
+  async readSome(n: number): Promise {
+    if (this.#buf.length === 0 && !(await this.#fill())) {
+      return null;
+    }
+    const take = Math.min(n, this.#buf.length);
+    const out = this.#buf.slice(0, take);
+    this.#buf = this.#buf.subarray(take);
+    return out;
+  }
+}
+
+export interface RequestHead {
+  method: string;
+  target: string;
+  headers: Headers;
+}
+
+export interface ResponseHead {
+  status: number;
+  statusText: string;
+  headers: Headers;
+}
+
+const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
+
+async function readHeaders(reader: ByteReader, budget: number): Promise {
+  const headers = new Headers();
+  for (;;) {
+    const line = await reader.readLine(budget);
+    if (line === null) {
+      throw new HTTPParseError("unexpected end of input in headers");
+    }
+    if (line === "") {
+      return headers;
+    }
+    budget -= line.length + 2;
+    if (budget < 0) {
+      throw new HTTPParseError(`head exceeds ${MAX_HEAD_BYTES} bytes`);
+    }
+    const colon = line.indexOf(":");
+    if (colon <= 0 || line[0] === " " || line[0] === "\t") {
+      throw new HTTPParseError(`malformed header line ${JSON.stringify(line)}`);
+    }
+    const name = line.slice(0, colon);
+    if (!TOKEN.test(name)) {
+      throw new HTTPParseError(`malformed header name ${JSON.stringify(name)}`);
+    }
+    headers.append(name, line.slice(colon + 1).trim());
+  }
+}
+
+/** The request line and headers, as a server reads them. */
+export async function readRequestHead(reader: ByteReader): Promise {
+  const line = await reader.readLine(MAX_HEAD_BYTES);
+  if (line === null) {
+    return null;
+  }
+  const parts = line.split(" ");
+  if (parts.length !== 3 || !TOKEN.test(parts[0] as string) || parts[1] === "" || !/^HTTP\/1\.[01]$/.test(parts[2] as string)) {
+    throw new HTTPParseError(`malformed request line ${JSON.stringify(line)}`);
+  }
+  const headers = await readHeaders(reader, MAX_HEAD_BYTES - line.length - 2);
+  return { method: parts[0] as string, target: parts[1] as string, headers };
+}
+
+/** The status line and headers, as a client reads them. */
+export async function readResponseHead(reader: ByteReader): Promise {
+  const line = await reader.readLine(MAX_HEAD_BYTES);
+  if (line === null) {
+    throw new HTTPParseError("no response");
+  }
+  const m = /^HTTP\/1\.[01] (\d{3})(?: (.*))?$/.exec(line);
+  if (m === null) {
+    throw new HTTPParseError(`malformed status line ${JSON.stringify(line)}`);
+  }
+  const headers = await readHeaders(reader, MAX_HEAD_BYTES - line.length - 2);
+  return { status: Number(m[1]), statusText: m[2] ?? "", headers };
+}
+
+/** How the bytes after a head are delimited. */
+export type BodyFraming = { kind: "none" } | { kind: "length"; length: number } | { kind: "chunked" } | { kind: "until-close" };
+
+function framingOf(headers: Headers, whenUnframed: BodyFraming): BodyFraming {
+  const te = headers.get("transfer-encoding");
+  if (te !== null) {
+    const codings = te.split(",").map((c) => c.trim().toLowerCase());
+    if (codings.at(-1) !== "chunked" || codings.length !== 1) {
+      throw new HTTPParseError(`unsupported transfer-encoding ${JSON.stringify(te)}`);
+    }
+    return { kind: "chunked" };
+  }
+  const cl = headers.get("content-length");
+  if (cl !== null) {
+    const values = new Set(cl.split(",").map((v) => v.trim()));
+    if (values.size !== 1 || !/^\d{1,15}$/.test(cl.split(",")[0]?.trim() ?? "")) {
+      throw new HTTPParseError(`malformed content-length ${JSON.stringify(cl)}`);
+    }
+    const length = Number(values.values().next().value);
+    return length === 0 ? { kind: "none" } : { kind: "length", length };
+  }
+  return whenUnframed;
+}
+
+/** A request body without Content-Length or Transfer-Encoding is empty (RFC 9112 section 6.3). */
+export function requestBodyFraming(head: RequestHead): BodyFraming {
+  return framingOf(head.headers, { kind: "none" });
+}
+
+/** A response body ends where the sender says, or with the stream. */
+export function responseBodyFraming(method: string, head: ResponseHead): BodyFraming {
+  if (method === "HEAD" || head.status === 204 || head.status === 304 || (head.status >= 100 && head.status < 200)) {
+    return { kind: "none" };
+  }
+  return framingOf(head.headers, { kind: "until-close" });
+}
+
+async function* chunkedBody(reader: ByteReader): AsyncGenerator {
+  for (;;) {
+    const line = await reader.readLine(MAX_CHUNK_LINE_BYTES);
+    if (line === null) {
+      throw new HTTPParseError("unexpected end of input in chunked body");
+    }
+    // The whole size token must be hex, else "5g" would read as 5 and the
+    // two ends would disagree on where the chunk ends. Eight digits bound
+    // the size to 4 GiB, well within what parseInt represents exactly.
+    const sizeText = (line.split(";")[0] as string).trim();
+    if (!/^[0-9A-Fa-f]{1,8}$/.test(sizeText)) {
+      throw new HTTPParseError(`malformed chunk size ${JSON.stringify(line)}`);
+    }
+    const size = parseInt(sizeText, 16);
+    if (size === 0) {
+      // Trailer fields, then the empty line that ends the message.
+      for (let t = await reader.readLine(MAX_HEAD_BYTES); t !== ""; t = await reader.readLine(MAX_HEAD_BYTES)) {
+        if (t === null) {
+          throw new HTTPParseError("unexpected end of input in trailers");
+        }
+      }
+      return;
+    }
+    let remaining = size;
+    while (remaining > 0) {
+      const part = await reader.readSome(remaining);
+      if (part === null) {
+        throw new HTTPParseError("unexpected end of input in a chunk");
+      }
+      remaining -= part.length;
+      yield part;
+    }
+    if ((await reader.readLine(2)) !== "") {
+      throw new HTTPParseError("chunk data not followed by CRLF");
+    }
+  }
+}
+
+/** The body bytes, as the framing delimits them, in the pieces they arrive. */
+export async function* bodyChunks(reader: ByteReader, framing: BodyFraming): AsyncGenerator {
+  switch (framing.kind) {
+    case "none":
+      return;
+    case "length": {
+      let remaining = framing.length;
+      while (remaining > 0) {
+        const part = await reader.readSome(remaining);
+        if (part === null) {
+          throw new HTTPParseError(`body ended ${remaining} bytes short of content-length`);
+        }
+        remaining -= part.length;
+        yield part;
+      }
+      return;
+    }
+    case "chunked":
+      yield* chunkedBody(reader);
+      return;
+    case "until-close":
+      for (let part = await reader.readSome(64 * 1024); part !== null; part = await reader.readSome(64 * 1024)) {
+        yield part;
+      }
+      return;
+  }
+}
+
+/** A whole body, refused past the limit. */
+export async function readBody(reader: ByteReader, framing: BodyFraming, limit: number): Promise> {
+  if (framing.kind === "length" && framing.length > limit) {
+    throw new HTTPParseError(`body of ${framing.length} bytes exceeds ${limit}`);
+  }
+  const parts: Uint8Array[] = [];
+  let size = 0;
+  for await (const part of bodyChunks(reader, framing)) {
+    size += part.length;
+    if (size > limit) {
+      throw new HTTPParseError(`body exceeds ${limit} bytes`);
+    }
+    parts.push(part);
+  }
+  return concatBytes(...parts);
+}
+
+/** The body as a web stream; onDone runs once it is drained, failed or cancelled. */
+export function bodyStream(reader: ByteReader, framing: BodyFraming, onDone: (err?: Error) => void): ReadableStream {
+  const chunks = bodyChunks(reader, framing);
+  let done = false;
+  const finish = (err?: Error) => {
+    if (!done) {
+      done = true;
+      onDone(err);
+    }
+  };
+  return new ReadableStream({
+    async pull(controller) {
+      try {
+        const next = await chunks.next();
+        if (next.done) {
+          controller.close();
+          finish();
+        } else {
+          controller.enqueue(next.value);
+        }
+      } catch (err) {
+        const e = err instanceof Error ? err : new Error(String(err));
+        controller.error(e);
+        finish(e);
+      }
+    },
+    async cancel(reason) {
+      // The stream is torn down first, so a read the generator is blocked on ends.
+      finish(reason instanceof Error ? reason : new Error(String(reason)));
+      await chunks.return(undefined).catch(() => {});
+    },
+  });
+}
+
+function headerLines(headers: Headers): string {
+  let out = "";
+  headers.forEach((value, name) => {
+    if (/[\r\n]/.test(value)) {
+      throw new HTTPParseError(`header ${name} contains a line break`);
+    }
+    out += `${name}: ${value}\r\n`;
+  });
+  return out;
+}
+
+/** A request head on the wire. */
+export function encodeRequestHead(method: string, target: string, headers: Headers): Uint8Array {
+  return encoder.encode(`${method} ${target} HTTP/1.1\r\n${headerLines(headers)}\r\n`);
+}
+
+/** A response head on the wire. */
+export function encodeResponseHead(status: number, statusText: string, headers: Headers): Uint8Array {
+  return encoder.encode(`HTTP/1.1 ${status} ${statusText.replace(/[\r\n]/g, " ")}\r\n${headerLines(headers)}\r\n`);
+}
+
+/** One chunk of a chunked body. */
+export function encodeChunk(data: Uint8Array): Uint8Array {
+  return concatBytes(encoder.encode(`${data.length.toString(16)}\r\n`), data, encoder.encode("\r\n"));
+}
+
+/** The end of a chunked body. */
+export const LAST_CHUNK = encoder.encode("0\r\n\r\n");
diff --git a/sdk/js/src/identity.ts b/sdk/js/src/identity.ts
index 1744d8a2..c5439a00 100644
--- a/sdk/js/src/identity.ts
+++ b/sdk/js/src/identity.ts
@@ -14,13 +14,17 @@
 
 // A mesh identity is an ed25519 key pair. Its peer ID is the one libp2p
 // derives, so the same key works in the SDK, in sam-node and on the wire.
+// The arithmetic is @noble/curves, the implementation libp2p itself uses,
+// so an identity is built and used the same way in Node and in a browser.
 
-import { createPrivateKey, createPublicKey, sign, verify, type KeyObject } from "node:crypto";
+import { ed25519 } from "@noble/curves/ed25519.js";
 import { peerIdFromString } from "@libp2p/peer-id";
 import { encodeBase58 } from "./base58.ts";
+import { bytesEqual, concatBytes as concat } from "./bytes.ts";
 
 const PUBLIC_KEY_SIZE = 32;
 const SEED_SIZE = 32;
+const SIGNATURE_SIZE = 64;
 
 // libp2p crypto.proto PublicKey{Type: Ed25519 (1), Data: <32 bytes>}.
 const LIBP2P_PUBLIC_KEY_PREFIX = Uint8Array.of(0x08, 0x01, 0x12, 0x20);
@@ -29,29 +33,21 @@ const LIBP2P_PRIVATE_KEY_PREFIX = Uint8Array.of(0x08, 0x01, 0x12, 0x40);
 // multihash: identity function (0x00), digest length 36.
 const IDENTITY_MULTIHASH_PREFIX = Uint8Array.of(0x00, 0x24);
 
-// PKCS#8 wrapper for a raw ed25519 seed (RFC 8410): what node:crypto imports.
-const PKCS8_ED25519_PREFIX = Uint8Array.of(
-  0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
-);
-// SubjectPublicKeyInfo wrapper for a raw ed25519 public key (RFC 8410).
-const SPKI_ED25519_PREFIX = Uint8Array.of(0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00);
-
-function concat(...parts: Uint8Array[]): Uint8Array {
-  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
-  let offset = 0;
-  for (const p of parts) {
-    out.set(p, offset);
-    offset += p.length;
-  }
-  return out;
-}
-
 function startsWith(bytes: Uint8Array, prefix: Uint8Array): boolean {
   return prefix.every((b, i) => bytes[i] === b);
 }
 
-function publicKeyObject(publicKeyRaw: Uint8Array): KeyObject {
-  return createPublicKey({ key: Buffer.from(concat(SPKI_ED25519_PREFIX, publicKeyRaw)), format: "der", type: "spki" });
+// RFC 8032 verification, as Go crypto/ed25519 and node:crypto do it; the
+// looser ZIP 215 rules noble defaults to accept signatures those reject.
+function verifyRaw(publicKeyRaw: Uint8Array, data: Uint8Array, signature: Uint8Array): boolean {
+  if (publicKeyRaw.length !== PUBLIC_KEY_SIZE || signature.length !== SIGNATURE_SIZE) {
+    return false;
+  }
+  try {
+    return ed25519.verify(signature, data, publicKeyRaw, { zip215: false });
+  } catch {
+    return false;
+  }
 }
 
 /** The libp2p protobuf encoding of an ed25519 public key. */
@@ -83,8 +79,6 @@ export function canonicalPeerId(text: string): string {
 }
 
 export class Identity {
-  readonly #privateKey: KeyObject;
-  readonly #publicKey: KeyObject;
   readonly #seed: Uint8Array;
   /** Raw 32-byte ed25519 public key. */
   readonly publicKeyRaw: Uint8Array;
@@ -95,17 +89,7 @@ export class Identity {
       throw new Error(`ed25519 seed must be ${SEED_SIZE} bytes, got ${seed.length}`);
     }
     this.#seed = new Uint8Array(seed);
-    this.#privateKey = createPrivateKey({
-      key: Buffer.from(concat(PKCS8_ED25519_PREFIX, this.#seed)),
-      format: "der",
-      type: "pkcs8",
-    });
-    this.#publicKey = createPublicKey(this.#privateKey);
-    const spki = new Uint8Array(this.#publicKey.export({ format: "der", type: "spki" }));
-    if (spki.length !== SPKI_ED25519_PREFIX.length + PUBLIC_KEY_SIZE || !startsWith(spki, SPKI_ED25519_PREFIX)) {
-      throw new Error("unexpected ed25519 public key encoding");
-    }
-    this.publicKeyRaw = spki.slice(SPKI_ED25519_PREFIX.length);
+    this.publicKeyRaw = ed25519.getPublicKey(this.#seed);
     this.peerId = peerIdFromPublicKey(this.publicKeyRaw);
   }
 
@@ -127,7 +111,7 @@ export class Identity {
     const seed = bytes.subarray(LIBP2P_PRIVATE_KEY_PREFIX.length, LIBP2P_PRIVATE_KEY_PREFIX.length + SEED_SIZE);
     const pub = bytes.subarray(LIBP2P_PRIVATE_KEY_PREFIX.length + SEED_SIZE);
     const id = new Identity(seed);
-    if (!pub.every((b, i) => id.publicKeyRaw[i] === b)) {
+    if (!bytesEqual(pub, id.publicKeyRaw)) {
       throw new Error("libp2p private key: public half does not match the seed");
     }
     return id;
@@ -144,18 +128,15 @@ export class Identity {
   }
 
   sign(data: Uint8Array): Uint8Array {
-    return new Uint8Array(sign(null, data, this.#privateKey));
+    return ed25519.sign(data, this.#seed);
   }
 
   verify(data: Uint8Array, signature: Uint8Array): boolean {
-    return verify(null, data, this.#publicKey, signature);
+    return verifyRaw(this.publicKeyRaw, data, signature);
   }
 }
 
 /** Verifies an ed25519 signature with a raw 32-byte public key. */
 export function verifyEd25519(publicKeyRaw: Uint8Array, data: Uint8Array, signature: Uint8Array): boolean {
-  if (publicKeyRaw.length !== PUBLIC_KEY_SIZE) {
-    return false;
-  }
-  return verify(null, data, publicKeyObject(publicKeyRaw), signature);
+  return verifyRaw(publicKeyRaw, data, signature);
 }
diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts
index 91bbaa8a..47d2b496 100644
--- a/sdk/js/src/index.ts
+++ b/sdk/js/src/index.ts
@@ -51,13 +51,13 @@ export {
   HTTP_PROTOCOL,
   MESH_PATH_PREFIX,
   a2aEndpoint,
+  admitIngress,
   fetchOverStream,
   httpIngressHandler,
   httpRequestOverStream,
   meshHTTPTarget,
   meshURL,
   splitMeshURL,
-  streamToNodeDuplex,
   type A2AEndpoint,
   type A2AEndpointSpec,
   type HTTPHandler,
@@ -67,5 +67,7 @@ export {
   type NodeRequestListener,
   type ProviderOptions,
 } from "./libp2p-http.ts";
+export { ingressHandler } from "./platform/ingress.ts";
+export type { StateStore } from "./platform/types.ts";
 export { BASELINE_DATALOG } from "./gen/datalog.ts";
 export { BanSet, EVENT_FRESHNESS_MS, GOSSIP_EVENTS_TOPIC, verifyMeshEvent, type VerifiedMeshEvent } from "./sync.ts";
diff --git a/sdk/js/src/libp2p-http-node.ts b/sdk/js/src/libp2p-http-node.ts
new file mode 100644
index 00000000..3b4bbebc
--- /dev/null
+++ b/sdk/js/src/libp2p-http-node.ts
@@ -0,0 +1,143 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The Node side of the /libp2p-http ingress: an A2A endpoint answered by a
+// Node request listener (an Express app with the A2A SDK's handlers mounted)
+// is served by Node's own HTTP server over the libp2p stream, so the
+// listener sees a real IncomingMessage and ServerResponse. Admission is the
+// same as for a handler or url target; only who parses the request differs.
+
+import type { Connection, Stream, StreamHandler } from "@libp2p/interface";
+import http from "node:http";
+import { Duplex } from "node:stream";
+import { AUTH_HANDSHAKE_TIMEOUT_MS } from "./auth.ts";
+import {
+  HEADER_PEER_ID,
+  HEADER_SAM_AGENT,
+  HEADER_SAM_BISCUIT,
+  HEADER_SAM_NO_TRAILING_SLASH,
+  admitIngress,
+  httpIngressHandler,
+  type A2AEndpoint,
+  type NodeRequestListener,
+  type ProviderOptions,
+} from "./libp2p-http.ts";
+
+interface StreamSocket extends Duplex {
+  remotePeer: string;
+}
+
+/**
+ * Bridges a libp2p stream to a Node Duplex so Node's own HTTP parser and
+ * client can run over it.
+ */
+export function streamToNodeDuplex(stream: Stream, remotePeer: string): StreamSocket {
+  const duplex = new Duplex({
+    read() {
+      stream.resume();
+    },
+    write(chunk: Uint8Array, _encoding, callback) {
+      if (stream.send(chunk)) {
+        callback();
+      } else {
+        stream.addEventListener("drain", () => callback(), { once: true });
+      }
+    },
+    final(callback) {
+      stream.close().then(
+        () => callback(),
+        (err: Error) => callback(err),
+      );
+    },
+    destroy(err, callback) {
+      if (err !== null) {
+        stream.abort(err);
+      } else {
+        void stream.close().catch(() => {});
+      }
+      callback(err);
+    },
+  }) as StreamSocket;
+  duplex.remotePeer = remotePeer;
+  stream.addEventListener("message", (evt) => {
+    if (!duplex.push(Buffer.from(evt.data.subarray()))) {
+      stream.pause();
+    }
+  });
+  const end = () => {
+    if (!duplex.readableEnded) {
+      duplex.push(null);
+    }
+  };
+  stream.addEventListener("remoteCloseWrite", end);
+  stream.addEventListener("close", end);
+  return duplex;
+}
+
+function nodeHeaders(req: http.IncomingMessage): Headers {
+  const headers = new Headers();
+  for (const [name, value] of Object.entries(req.headers)) {
+    for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
+      headers.append(name, v);
+    }
+  }
+  return headers;
+}
+
+async function serveListener(req: http.IncomingMessage, res: http.ServerResponse, endpoint: A2AEndpoint, listener: NodeRequestListener, options: ProviderOptions): Promise {
+  const remotePeer = (req.socket as unknown as StreamSocket).remotePeer;
+  const admission = await admitIngress({ target: req.url ?? "/", headers: nodeHeaders(req), remotePeer }, endpoint, options);
+  if ("status" in admission) {
+    res.writeHead(admission.status, { "content-type": "text/plain; charset=utf-8" });
+    res.end(admission.text + "\n");
+    return;
+  }
+  // The listener sees the request as a backend behind sam-node would: the
+  // path relative to the service, the verified caller, never the biscuit.
+  // X-Peer-Id is set, not added, so an inbound value cannot pose as the
+  // verified peer.
+  req.url = admission.path;
+  delete req.headers[HEADER_SAM_BISCUIT];
+  delete req.headers[HEADER_SAM_AGENT];
+  req.headers[HEADER_PEER_ID] = remotePeer;
+  if (admission.noTrailingSlash) {
+    req.headers[HEADER_SAM_NO_TRAILING_SLASH] = "true";
+  } else {
+    delete req.headers[HEADER_SAM_NO_TRAILING_SLASH];
+  }
+  listener(req, res);
+}
+
+/**
+ * Server side of /libp2p-http on Node: a listener target is served by Node's
+ * HTTP server on the stream, any other by the runtime-neutral ingress.
+ */
+export function nodeIngressHandler(endpoint: A2AEndpoint, options: ProviderOptions): StreamHandler {
+  if (!("listener" in endpoint.target)) {
+    return httpIngressHandler(endpoint, options);
+  }
+  const listener = endpoint.target.listener;
+  const server = http.createServer({ keepAlive: false }, (req, res) => {
+    void serveListener(req, res, endpoint, listener, options).catch((err: unknown) => {
+      if (!res.headersSent) {
+        res.writeHead(500, { "content-type": "text/plain" });
+      }
+      res.end(`ingress error: ${err instanceof Error ? err.message : String(err)}\n`);
+    });
+  });
+  server.headersTimeout = AUTH_HANDSHAKE_TIMEOUT_MS;
+  return (stream: Stream, connection: Connection) => {
+    server.emit("connection", streamToNodeDuplex(stream, connection.remotePeer.toString()));
+  };
+}
diff --git a/sdk/js/src/libp2p-http.test.ts b/sdk/js/src/libp2p-http.test.ts
index 3e11af9f..0bc05183 100644
--- a/sdk/js/src/libp2p-http.test.ts
+++ b/sdk/js/src/libp2p-http.test.ts
@@ -31,15 +31,16 @@ import { ROLE_NODE } from "./controlplane.ts";
 import {
   HTTP_PROTOCOL,
   a2aEndpoint,
+  admitIngress,
   fetchOverStream,
   httpIngressHandler,
   httpRequestOverStream,
   meshHTTPTarget,
   meshURL,
   splitMeshURL,
-  streamToNodeDuplex,
   type ProviderOptions,
 } from "./libp2p-http.ts";
+import { nodeIngressHandler, streamToNodeDuplex } from "./libp2p-http-node.ts";
 
 type Wasm = Awaited>;
 
@@ -48,6 +49,7 @@ let cpKeyPair: InstanceType;
 let cpKey: Uint8Array;
 let agent: Libp2p;
 let listenerAgent: Libp2p;
+let handlerAgent: Libp2p;
 let caller: Libp2p;
 let callerBiscuit: Uint8Array;
 let guestBiscuit: Uint8Array;
@@ -148,10 +150,40 @@ before(async () => {
   };
   await listenerAgent.handle(
     HTTP_PROTOCOL,
-    httpIngressHandler(a2aEndpoint({ name: "worker", listener }), providerOptions(mint(listenerAgent.peerId.toString(), ROLE_NODE))),
+    nodeIngressHandler(a2aEndpoint({ name: "worker", listener }), providerOptions(mint(listenerAgent.peerId.toString(), ROLE_NODE))),
     { runOnLimitedConnection: true },
   );
 
+  // An agent answering with a fetch handler, the shape a browser member uses:
+  // echoes what it was given and, on /stream, writes three SSE events one at
+  // a time into a streaming body.
+  handlerAgent = await newHost();
+  const handler = async (request: Request): Promise => {
+    const url = new URL(request.url);
+    if (url.pathname === "/stream") {
+      const encoder = new TextEncoder();
+      let i = 0;
+      const body = new ReadableStream({
+        async pull(controller) {
+          await new Promise((r) => setTimeout(r, 10));
+          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ event: i })}\n\n`));
+          if (++i === 3) {
+            controller.close();
+          }
+        },
+      });
+      return new Response(body, { headers: { "content-type": "text/event-stream" } });
+    }
+    if (url.pathname === "/sized") {
+      // An agent that declares its length, as a static file server would.
+      return new Response(request.method === "HEAD" ? null : "x".repeat(42), { headers: { "content-type": "text/plain", "content-length": "42" } });
+    }
+    return Response.json({ path: url.pathname + url.search, method: request.method, peer: request.headers.get("x-peer-id"), biscuit: request.headers.get("x-sam-biscuit"), echo: await request.text() });
+  };
+  await handlerAgent.handle(HTTP_PROTOCOL, httpIngressHandler(a2aEndpoint({ handler }), providerOptions(mint(handlerAgent.peerId.toString(), ROLE_NODE))), {
+    runOnLimitedConnection: true,
+  });
+
   caller = await newHost();
   callerBiscuit = mint(caller.peerId.toString(), ROLE_NODE);
   guestBiscuit = mint(caller.peerId.toString(), "sam:role:guest");
@@ -161,6 +193,7 @@ after(async () => {
   await caller.stop();
   await agent.stop();
   await listenerAgent.stop();
+  await handlerAgent.stop();
   await new Promise((resolve) => backend.close(() => resolve()));
 });
 
@@ -209,6 +242,57 @@ test("a fetch over the stream delivers an SSE body event by event", async () =>
   );
 });
 
+test("a fetch handler sees the caller and the path, and its streaming body goes out as it is written", async () => {
+  const conn = await dial(handlerAgent);
+  const res = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/tasks?x=1", { method: "POST", headers: { "x-sam-biscuit": "spoof", "content-type": "text/plain" }, body: "hello" });
+  assert.equal(res.status, 200);
+  assert.deepEqual(JSON.parse(res.text()), { path: "/tasks?x=1", method: "POST", peer: caller.peerId.toString(), biscuit: null, echo: "hello" });
+
+  // HEAD: the head a GET would get, the declared length kept, no body sent.
+  const sized = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/sized");
+  assert.equal(sized.text(), "x".repeat(42));
+  const head = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/sized", { method: "HEAD" });
+  assert.equal(head.status, 200);
+  assert.equal(head.headers["content-length"], "42");
+  assert.equal(head.headers["content-type"], "text/plain");
+  assert.equal(head.body.length, 0);
+  // A handler that built a body for HEAD anyway: no length is invented.
+  const headJson = await httpRequestOverStream(conn, callerBiscuit, "a2a://agent", "/card", { method: "HEAD" });
+  assert.equal(headJson.status, 200);
+  assert.equal(headJson.headers["content-length"], undefined);
+  assert.equal(headJson.body.length, 0);
+
+  const response = await fetchOverStream(conn, callerBiscuit, new Request(meshURL(handlerAgent.peerId.toString(), "a2a://agent", "/stream")));
+  assert.equal(response.status, 200);
+  assert.equal(response.headers.get("content-type"), "text/event-stream");
+  const chunks: string[] = [];
+  const reader = (response.body as ReadableStream).getReader();
+  for (let next = await reader.read(); !next.done; next = await reader.read()) {
+    chunks.push(new TextDecoder().decode(next.value));
+  }
+  assert.ok(chunks.length >= 3, `want at least three chunks, got ${JSON.stringify(chunks)}`);
+  assert.deepEqual(chunks.join("").split("\n").filter((l) => l.startsWith("data:")), ['data: {"event":0}', 'data: {"event":1}', 'data: {"event":2}']);
+
+  // Node's own client reads the chunked response the ingress writes.
+  const stream = await conn.newStream(HTTP_PROTOCOL, { runOnLimitedConnection: true });
+  const socket = streamToNodeDuplex(stream, handlerAgent.peerId.toString());
+  const viaNode = await new Promise<{ status: number; body: string }>((resolve, reject) => {
+    const req = http.request(
+      { method: "GET", path: "/a2a/agent/card", headers: { host: handlerAgent.peerId.toString(), "x-sam-biscuit": Buffer.from(callerBiscuit).toString("base64") }, createConnection: () => socket },
+      (r) => {
+        let body = "";
+        r.on("data", (c: Buffer) => (body += c.toString()));
+        r.on("end", () => resolve({ status: r.statusCode ?? 0, body }));
+      },
+    );
+    req.on("error", reject);
+    req.end();
+  });
+  socket.destroy();
+  assert.equal(viaNode.status, 200);
+  assert.equal(JSON.parse(viaNode.body).path, "/card");
+});
+
 test("a Node request listener sees the path relative to the agent and the verified caller", async () => {
   const conn = await dial(listenerAgent);
   const res = await httpRequestOverStream(conn, callerBiscuit, "a2a://worker", "/tasks/1?q=1", { method: "POST", headers: { "x-sam-biscuit": "spoof" }, body: "{}" });
@@ -253,6 +337,19 @@ test("the ingress rejects a request without a biscuit and a dotted path", async
   assert.equal(status, 400);
 });
 
+test("a dot segment is refused however it is spelled", async () => {
+  const endpoint = a2aEndpoint({ handler: () => new Response() });
+  // Nothing in options is consulted before the path check.
+  const options = providerOptions(new Uint8Array());
+  for (const target of ["/a2a/agent/../x", "/a2a/agent/./x", "/a2a/agent/%2e%2e/x", "/a2a/agent/%2E%2E/x", "/a2a/agent/.%2e/x", "/a2a/agent/%2e/x", "/a2a/%2e%2e/other/x?q=1"]) {
+    assert.deepEqual(await admitIngress({ target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 400, text: "Invalid path" }, target);
+  }
+  // Not dot segments: the request reaches the next check, the missing biscuit.
+  for (const target of ["/a2a/agent/%2e%2ex/x", "/a2a/agent/..x/x", "/a2a/agent/x?p=../y"]) {
+    assert.deepEqual(await admitIngress({ target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 401, text: "Missing X-Sam-Biscuit header" }, target);
+  }
+});
+
 test("mesh URLs name a peer and a service", () => {
   const peer = "12D3KooWJ2Yhy3CKwVPDw7AnkxN5HbZbb65xDoRif54hdXXUzh1U";
   assert.deepEqual(splitMeshURL(new URL(`http://mesh/sam/${peer}/a2a/agent`)), { peerId: peer, target: "/a2a/agent" });
diff --git a/sdk/js/src/libp2p-http.ts b/sdk/js/src/libp2p-http.ts
index 0b4871f9..8b544641 100644
--- a/sdk/js/src/libp2p-http.ts
+++ b/sdk/js/src/libp2p-http.ts
@@ -15,16 +15,33 @@
 // The /libp2p-http protocol (go-libp2p-http): the client that calls inference
 // and A2A services on the mesh, and the ingress that accepts A2A requests for
 // this member's own agent, gated by the authorizer the way sam-node gates its
-// ingress (StartIngressServer in internal/node). Node's own HTTP server and
-// client run over the libp2p stream, so bodies stream in both directions and
-// an A2A message/stream (SSE) works.
+// ingress (StartIngressServer in internal/node). Requests and responses are
+// framed by the codec in http1.ts on the libp2p stream itself, so bodies
+// stream in both directions, an A2A message/stream (SSE) works, and none of
+// it needs Node: a member in a browser calls and answers the same way. A
+// Node request listener (an Express app) is served by libp2p-http-node.ts.
 
 import type { Connection, Stream, StreamHandler } from "@libp2p/interface";
-import http from "node:http";
-import { Duplex, Readable } from "node:stream";
 import { AUTH_HANDSHAKE_TIMEOUT_MS } from "./auth.ts";
 import { AuthorizationError, authorizeCaller, type ProviderAuthorizerOptions } from "./authorizer.ts";
 import type { VerifiedBiscuit } from "./biscuit.ts";
+import { fromBase64, toBase64 } from "./bytes.ts";
+import {
+  ByteReader,
+  HTTPParseError,
+  LAST_CHUNK,
+  bodyStream,
+  encodeChunk,
+  encodeRequestHead,
+  encodeResponseHead,
+  readBody,
+  readRequestHead,
+  readResponseHead,
+  requestBodyFraming,
+  responseBodyFraming,
+  sendAll,
+  streamSource,
+} from "./http1.ts";
 import { canonicalPeerId } from "./identity.ts";
 
 /** go-libp2p-http's protocol: plain HTTP/1.1 on a stream, one request per stream. */
@@ -50,14 +67,21 @@ export const DEFAULT_A2A_NAME = "agent";
  */
 export const MESH_PATH_PREFIX = "/sam/";
 
-const MAX_INGRESS_BODY_BYTES = 8 * 1024 * 1024;
+/** Largest request body the ingress reads whole for a handler or url target. */
+export const MAX_INGRESS_BODY_BYTES = 8 * 1024 * 1024;
 const REQUEST_TIMEOUT_MS = 60_000;
 
 /** A fetch-style handler in this process. */
 export type HTTPHandler = (request: Request, caller: VerifiedBiscuit) => Promise | Response;
 
-/** A Node request listener in this process, such as an Express app with the A2A SDK's handlers mounted. */
-export type NodeRequestListener = (req: http.IncomingMessage, res: http.ServerResponse) => void;
+/**
+ * A request listener of the runtime's own HTTP server, such as an Express
+ * app with the A2A SDK's handlers mounted. On Node it is
+ * (req: http.IncomingMessage, res: http.ServerResponse) => void, served by
+ * libp2p-http-node.ts; a browser has no such server and answers 501.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type NodeRequestListener = (req: any, res: any) => void;
 
 /**
  * This member's agent as other members reach it: `a2a://`, answered by
@@ -97,239 +121,226 @@ export interface ProviderOptions extends ProviderAuthorizerOptions {
   onAuthorized?(peerId: string, verified: VerifiedBiscuit, targetService: string): void;
 }
 
-interface StreamSocket extends Duplex {
-  remotePeer: string;
+// A URL parser reads %2e as a dot too (WHATWG URL, path state), so the check
+// sees what the parser and the backend will see.
+function hasDotSegment(path: string): boolean {
+  return path.split("/").some((seg) => {
+    const s = seg.replace(/%2e/gi, ".");
+    return s === "." || s === "..";
+  });
 }
 
-/**
- * Bridges a libp2p stream to a Node Duplex so Node's own HTTP parser and
- * client can run over it.
- */
-export function streamToNodeDuplex(stream: Stream, remotePeer: string): StreamSocket {
-  const duplex = new Duplex({
-    read() {
-      stream.resume();
-    },
-    write(chunk: Uint8Array, _encoding, callback) {
-      if (stream.send(chunk)) {
-        callback();
-      } else {
-        stream.addEventListener("drain", () => callback(), { once: true });
-      }
-    },
-    final(callback) {
-      stream.close().then(
-        () => callback(),
-        (err: Error) => callback(err),
-      );
-    },
-    destroy(err, callback) {
-      if (err !== null) {
-        stream.abort(err);
-      } else {
-        void stream.close().catch(() => {});
-      }
-      callback(err);
-    },
-  }) as StreamSocket;
-  duplex.remotePeer = remotePeer;
-  stream.addEventListener("message", (evt) => {
-    if (!duplex.push(Buffer.from(evt.data.subarray()))) {
-      stream.pause();
-    }
-  });
-  const end = () => {
-    if (!duplex.readableEnded) {
-      duplex.push(null);
-    }
-  };
-  stream.addEventListener("remoteCloseWrite", end);
-  stream.addEventListener("close", end);
-  return duplex;
+/** What the ingress looks at before anything reaches the agent. */
+export interface IngressRequest {
+  /** The request target as it came off the wire, path and query. */
+  target: string;
+  headers: Headers;
+  remotePeer: string;
 }
 
-/** Reads a whole body, bounded. */
-async function readBody(readable: Readable, limit: number): Promise {
-  const chunks: Buffer[] = [];
-  let size = 0;
-  for await (const chunk of readable) {
-    const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
-    size += buf.length;
-    if (size > limit) {
-      throw new Error(`body exceeds ${limit} bytes`);
-    }
-    chunks.push(buf);
-  }
-  return Buffer.concat(chunks);
+/** A refusal, with the text the caller gets. */
+export interface IngressRefusal {
+  status: number;
+  text: string;
 }
 
-function hasDotSegment(path: string): boolean {
-  return path.split("/").some((seg) => seg === "." || seg === "..");
+/** An authorized request for the endpoint, with what the agent may see. */
+export interface IngressAdmission {
+  verified: VerifiedBiscuit;
+  /** The path relative to the service, query included. */
+  path: string;
+  noTrailingSlash: boolean;
 }
 
 /**
- * Server side of /libp2p-http, as sam-node's StartIngressServer: the path is
- * //[/], the caller's biscuit is X-Sam-Biscuit, and the
- * request is authorized for :// before anything is forwarded. Only
- * the agent's own endpoint is answered; anything else is 404 after
- * authorization, so an unauthorized caller learns nothing about it.
+ * Server-side admission of /libp2p-http, as sam-node's StartIngressServer:
+ * the path is //[/], the caller's biscuit is
+ * X-Sam-Biscuit, and the request is authorized for :// before
+ * anything is forwarded. Only the agent's own endpoint is answered; anything
+ * else is 404 after authorization, so an unauthorized caller learns nothing
+ * about it.
  */
-export function httpIngressHandler(endpoint: A2AEndpoint, options: ProviderOptions): StreamHandler {
-  const server = http.createServer({ keepAlive: false }, (req, res) => {
-    void handleIngress(req, res, endpoint, options).catch((err: unknown) => {
-      if (!res.headersSent) {
-        res.writeHead(500, { "content-type": "text/plain" });
-      }
-      res.end(`ingress error: ${err instanceof Error ? err.message : String(err)}\n`);
-    });
-  });
-  server.headersTimeout = AUTH_HANDSHAKE_TIMEOUT_MS;
-  return (stream: Stream, connection: Connection) => {
-    server.emit("connection", streamToNodeDuplex(stream, connection.remotePeer.toString()));
-  };
-}
-
-async function handleIngress(req: http.IncomingMessage, res: http.ServerResponse, endpoint: A2AEndpoint, options: ProviderOptions): Promise {
-  const remotePeer = (req.socket as unknown as StreamSocket).remotePeer;
-  const reply = (status: number, text: string) => {
-    res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
-    res.end(text + "\n");
-  };
-
-  const rawURL = req.url ?? "/";
+export async function admitIngress(req: IngressRequest, endpoint: A2AEndpoint, options: ProviderOptions): Promise {
   // Policy is decided on the // prefix; a dot segment in what
   // follows could resolve to a sibling path on the backend. Checked on the
   // raw path, before URL parsing normalizes it away.
-  if (hasDotSegment(rawURL.split("?")[0] as string)) {
-    reply(400, "Invalid path");
-    return;
+  if (hasDotSegment(req.target.split("?")[0] as string)) {
+    return { status: 400, text: "Invalid path" };
   }
-  const url = new URL(rawURL, "http://mesh.invalid");
+  const url = new URL(req.target, "http://mesh.invalid");
   const parts = url.pathname.replace(/^\//, "").split("/");
   if (parts.length < 2 || parts[0] === "" || parts[1] === "") {
-    reply(400, "Invalid path");
-    return;
+    return { status: 400, text: "Invalid path" };
   }
   const [serviceType, serviceName, ...rest] = parts as [string, string, ...string[]];
   if (serviceType !== "inference" && serviceType !== "a2a" && serviceType !== "mcp") {
-    reply(400, "Invalid service type");
-    return;
+    return { status: 400, text: "Invalid service type" };
   }
   const upstreamPath = rest.join("/");
 
-  const biscuitB64 = req.headers[HEADER_SAM_BISCUIT];
-  if (typeof biscuitB64 !== "string" || biscuitB64 === "") {
-    reply(401, "Missing X-Sam-Biscuit header");
-    return;
+  const biscuitB64 = req.headers.get(HEADER_SAM_BISCUIT);
+  if (biscuitB64 === null || biscuitB64 === "") {
+    return { status: 401, text: "Missing X-Sam-Biscuit header" };
   }
   let biscuit: Uint8Array;
   try {
-    biscuit = new Uint8Array(Buffer.from(biscuitB64, "base64"));
+    biscuit = fromBase64(biscuitB64);
     if (biscuit.length === 0) {
       throw new Error("empty");
     }
   } catch {
-    reply(400, "Invalid X-Sam-Biscuit encoding");
-    return;
+    return { status: 400, text: "Invalid X-Sam-Biscuit encoding" };
   }
 
   const targetService = `${serviceType}://${serviceName}`;
-  if (options.isBanned?.(remotePeer) === true) {
-    reply(403, "Authorization failed");
-    return;
+  if (options.isBanned?.(req.remotePeer) === true) {
+    return { status: 403, text: "Authorization failed" };
   }
-  const agentHeader = req.headers[HEADER_SAM_AGENT];
   let verified: VerifiedBiscuit;
   try {
     verified = await authorizeCaller(
-      { biscuit, peerId: remotePeer, targetService, protocol: HTTP_PROTOCOL, agent: typeof agentHeader === "string" ? agentHeader : "" },
+      { biscuit, peerId: req.remotePeer, targetService, protocol: HTTP_PROTOCOL, agent: req.headers.get(HEADER_SAM_AGENT) ?? "" },
       options,
     );
   } catch (err) {
     if (err instanceof AuthorizationError) {
-      reply(403, "Authorization failed");
-      return;
+      return { status: 403, text: "Authorization failed" };
     }
     throw err;
   }
-  options.onAuthorized?.(remotePeer, verified, targetService);
+  options.onAuthorized?.(req.remotePeer, verified, targetService);
 
   // Under the type the policy was evaluated on.
   if (targetService !== endpoint.service) {
-    reply(404, "Service not found");
-    return;
+    return { status: 404, text: "Service not found" };
   }
+  return { verified, path: "/" + upstreamPath + url.search, noTrailingSlash: upstreamPath === "" && rest.length === 0 };
+}
 
-  const path = "/" + upstreamPath + url.search;
-  const noTrailingSlash = upstreamPath === "" && rest.length === 0;
-
-  if ("listener" in endpoint.target) {
-    // The listener sees the request as a backend behind sam-node would: the
-    // path relative to the service, the verified caller, never the biscuit.
-    // X-Peer-Id is set, not added, so an inbound value cannot pose as the
-    // verified peer.
-    req.url = path;
-    delete req.headers[HEADER_SAM_BISCUIT];
-    delete req.headers[HEADER_SAM_AGENT];
-    req.headers[HEADER_PEER_ID] = remotePeer;
-    if (noTrailingSlash) {
-      req.headers[HEADER_SAM_NO_TRAILING_SLASH] = "true";
-    } else {
-      delete req.headers[HEADER_SAM_NO_TRAILING_SLASH];
-    }
-    endpoint.target.listener(req, res);
-    return;
-  }
+/** Headers of the mesh datapath and of the hop itself, not passed on to the agent. */
+const HOP_HEADERS = new Set([HEADER_SAM_BISCUIT, HEADER_SAM_AGENT, HEADER_SAM_NO_TRAILING_SLASH, HEADER_PEER_ID, "host", "connection", "transfer-encoding", "content-length", "keep-alive"]);
 
+/**
+ * The headers the agent sees: the request's own, less the datapath's, with
+ * X-Peer-Id set (not added) to the verified caller so an inbound value
+ * cannot pose as the peer.
+ */
+export function agentHeaders(inbound: Headers, remotePeer: string, noTrailingSlash: boolean): Headers {
   const headers = new Headers();
-  for (const [k, v] of Object.entries(req.headers)) {
-    if (v === undefined || k === HEADER_SAM_BISCUIT || k === HEADER_SAM_AGENT || k === HEADER_SAM_NO_TRAILING_SLASH || k === HEADER_PEER_ID || k === "host" || k === "connection" || k === "transfer-encoding" || k === "content-length") {
-      continue;
+  inbound.forEach((value, name) => {
+    if (!HOP_HEADERS.has(name)) {
+      headers.append(name, value);
     }
-    for (const value of Array.isArray(v) ? v : [v]) {
-      headers.append(k, value);
-    }
-  }
+  });
   headers.set(HEADER_PEER_ID, remotePeer);
   if (noTrailingSlash) {
     headers.set(HEADER_SAM_NO_TRAILING_SLASH, "true");
   }
+  return headers;
+}
 
-  const method = req.method ?? "GET";
-  const body: Uint8Array | undefined =
-    method === "GET" || method === "HEAD" ? undefined : new Uint8Array(await readBody(req, MAX_INGRESS_BODY_BYTES));
-  const init: RequestInit = { method, headers };
-  if (body !== undefined) {
-    init.body = body;
-  }
+/** A plain-text refusal, as sam-node's ingress writes one. */
+export function refusalResponse(refusal: IngressRefusal): Response {
+  return new Response(refusal.text + "\n", { status: refusal.status, headers: { "content-type": "text/plain; charset=utf-8" } });
+}
 
-  let response: Response;
-  if ("url" in endpoint.target) {
-    const base = endpoint.target.url.replace(/\/$/, "");
-    response = await fetch(base + path, { ...init, redirect: "manual" });
-  } else {
-    response = await endpoint.target.handler(new Request("http://" + endpoint.name + path, init), verified);
+async function writeResponse(stream: Stream, response: Response, headOnly: boolean): Promise {
+  const headers = new Headers();
+  response.headers.forEach((value, name) => {
+    if (name !== "content-length" && name !== "transfer-encoding" && name !== "connection") {
+      headers.set(name, value);
+    }
+  });
+  headers.set("connection", "close");
+  if (headOnly) {
+    // The same head a GET would get (RFC 9110 section 9.3.2): the length the
+    // agent declared, if any; the body it may have built is not sent.
+    const declared = response.headers.get("content-length");
+    if (declared !== null) {
+      headers.set("content-length", declared);
+    }
+    void response.body?.cancel().catch(() => {});
+    await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
+    return;
+  }
+  const body = response.body;
+  if (body === null) {
+    headers.set("content-length", "0");
+    await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
+    return;
   }
+  // The length is not known up front, so each piece goes as a chunk as soon
+  // as the agent writes it; an SSE event reaches the caller before the next.
+  headers.set("transfer-encoding", "chunked");
+  await sendAll(stream, encodeResponseHead(response.status, response.statusText, headers));
+  const reader = body.getReader();
+  try {
+    for (let next = await reader.read(); !next.done; next = await reader.read()) {
+      if (next.value.length > 0) {
+        await sendAll(stream, encodeChunk(next.value));
+      }
+    }
+  } finally {
+    reader.releaseLock();
+  }
+  await sendAll(stream, LAST_CHUNK);
+}
 
-  const outHeaders: Record = {};
-  response.headers.forEach((value, key) => {
-    if (key === "content-length" || key === "transfer-encoding" || key === "connection") {
+async function serveIngress(stream: Stream, remotePeer: string, endpoint: A2AEndpoint, options: ProviderOptions): Promise {
+  const reader = new ByteReader(streamSource(stream));
+  const headTimer = setTimeout(() => stream.abort(new Error(`no request head from ${remotePeer} within ${AUTH_HANDSHAKE_TIMEOUT_MS}ms`)), AUTH_HANDSHAKE_TIMEOUT_MS);
+  let response: Response;
+  let headOnly = false;
+  try {
+    let head;
+    try {
+      head = await readRequestHead(reader);
+    } finally {
+      clearTimeout(headTimer);
+    }
+    if (head === null) {
       return;
     }
-    outHeaders[key] = value;
-  });
-  res.writeHead(response.status, outHeaders);
-  if (response.body === null) {
-    res.end();
-    return;
+    headOnly = head.method === "HEAD";
+    const admission = await admitIngress({ target: head.target, headers: head.headers, remotePeer }, endpoint, options);
+    if ("status" in admission) {
+      response = refusalResponse(admission);
+    } else if ("listener" in endpoint.target) {
+      response = refusalResponse({ status: 501, text: "A request listener is not served in this runtime" });
+    } else {
+      const headers = agentHeaders(head.headers, remotePeer, admission.noTrailingSlash);
+      const init: RequestInit = { method: head.method, headers };
+      if (head.method !== "GET" && head.method !== "HEAD") {
+        init.body = await readBody(reader, requestBodyFraming(head), MAX_INGRESS_BODY_BYTES);
+      }
+      if ("url" in endpoint.target) {
+        const base = endpoint.target.url.replace(/\/$/, "");
+        response = await fetch(base + admission.path, { ...init, redirect: "manual" });
+      } else {
+        response = await endpoint.target.handler(new Request("http://" + endpoint.name + admission.path, init), admission.verified);
+      }
+    }
+  } catch (err) {
+    if (err instanceof HTTPParseError) {
+      response = refusalResponse({ status: 400, text: `Bad request: ${err.message}` });
+    } else {
+      response = refusalResponse({ status: 500, text: `ingress error: ${err instanceof Error ? err.message : String(err)}` });
+    }
   }
-  await new Promise((resolve, reject) => {
-    Readable.fromWeb(response.body as import("node:stream/web").ReadableStream)
-      .on("error", reject)
-      .pipe(res)
-      .on("finish", resolve)
-      .on("error", reject);
-  });
+  await writeResponse(stream, response, headOnly);
+}
+
+/**
+ * Server side of /libp2p-http for an endpoint answered by a handler in this
+ * process or an A2A server at a URL. One request per stream; the stream is
+ * closed once the response has been written.
+ */
+export function httpIngressHandler(endpoint: A2AEndpoint, options: ProviderOptions): StreamHandler {
+  return (stream: Stream, connection: Connection) => {
+    void serveIngress(stream, connection.remotePeer.toString(), endpoint, options)
+      .then(() => stream.close())
+      .catch((err: unknown) => stream.abort(err instanceof Error ? err : new Error(String(err))));
+  };
 }
 
 /** The request target for a service on a peer: ///. */
@@ -393,50 +404,55 @@ export async function fetchOverStream(conn: Connection, biscuit: Uint8Array, req
         ctl.abort(new DOMException(`no response headers from ${peerId} within ${REQUEST_TIMEOUT_MS}ms`, "TimeoutError"));
       }
     }, REQUEST_TIMEOUT_MS);
-    timer.unref?.();
+    (timer as { unref?: () => void }).unref?.();
   }
   const signal = ctl.signal;
   const stream = await conn.newStream(HTTP_PROTOCOL, { signal, runOnLimitedConnection: true });
-  const socket = streamToNodeDuplex(stream, peerId);
-  const headers: Record = {};
-  request.headers.forEach((value, key) => {
-    if (key !== "host" && key !== "content-length" && key !== HEADER_SAM_BISCUIT && key !== HEADER_PEER_ID) {
-      headers[key] = value;
+  const asError = (reason: unknown) => (reason instanceof Error ? reason : new Error(String(reason)));
+  const abortStream = () => stream.abort(asError(signal.reason));
+  signal.addEventListener("abort", abortStream, { once: true });
+
+  const headers = new Headers();
+  request.headers.forEach((value, name) => {
+    if (name !== "host" && name !== "content-length" && name !== "transfer-encoding" && name !== HEADER_SAM_BISCUIT && name !== HEADER_PEER_ID) {
+      headers.set(name, value);
     }
   });
-  headers.host = peerId;
-  headers[HEADER_SAM_BISCUIT] = Buffer.from(biscuit).toString("base64");
+  headers.set("host", peerId);
+  headers.set(HEADER_SAM_BISCUIT, toBase64(biscuit));
   if (options.agent) {
-    headers[HEADER_SAM_AGENT] = options.agent;
+    headers.set(HEADER_SAM_AGENT, options.agent);
   }
-  const body = request.body === null ? undefined : Buffer.from(await request.arrayBuffer());
-  headers["content-length"] = String(body?.length ?? 0);
-
-  return new Promise((resolve, reject) => {
-    const req = http.request({ method: request.method, path: target, headers, createConnection: () => socket, signal }, (res) => {
-      headersIn = true;
-      const responseHeaders = new Headers();
-      for (const [k, v] of Object.entries(res.headers)) {
-        if (typeof v === "string") {
-          responseHeaders.set(k, v);
-        } else if (Array.isArray(v)) {
-          responseHeaders.set(k, v.join(", "));
-        }
+  const body = request.body === null ? new Uint8Array(0) : new Uint8Array(await request.arrayBuffer());
+  headers.set("content-length", String(body.length));
+
+  try {
+    await sendAll(stream, encodeRequestHead(request.method, target, headers));
+    await sendAll(stream, body);
+    const reader = new ByteReader(streamSource(stream));
+    const head = await readResponseHead(reader);
+    headersIn = true;
+    signal.throwIfAborted();
+    const framing = responseBodyFraming(request.method, head);
+    const done = (err?: Error) => {
+      signal.removeEventListener("abort", abortStream);
+      if (err !== undefined) {
+        stream.abort(err);
+      } else {
+        void stream.close().catch(() => {});
       }
-      res.on("close", () => socket.destroy());
-      const status = res.statusCode ?? 0;
-      const bodyStream = Readable.toWeb(res) as ReadableStream;
-      resolve(new Response(status === 204 || status === 304 || request.method === "HEAD" ? null : bodyStream, { status, statusText: res.statusMessage ?? "", headers: responseHeaders }));
-    });
-    req.on("error", (err) => {
-      socket.destroy();
-      reject(err);
-    });
-    if (body !== undefined) {
-      req.write(body);
+    };
+    let responseBody: ReadableStream | null = null;
+    if (framing.kind === "none") {
+      done();
+    } else {
+      responseBody = bodyStream(reader, framing, done);
     }
-    req.end();
-  });
+    return new Response(responseBody, { status: head.status, statusText: head.statusText, headers: head.headers });
+  } catch (err) {
+    stream.abort(asError(err));
+    throw signal.aborted ? (signal.reason ?? err) : err;
+  }
 }
 
 export interface HTTPRequestOptions {
diff --git a/sdk/js/src/mesh.ts b/sdk/js/src/mesh.ts
index d5192207..5634f574 100644
--- a/sdk/js/src/mesh.ts
+++ b/sdk/js/src/mesh.ts
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
-import { join } from "node:path";
+import { toHex } from "./bytes.ts";
 import { ControlPlaneClient, ROLE_NODE, type Enrollment } from "./controlplane.ts";
 import { credentialFromJSON, credentialPredatesRotation, credentialTimeToLiveSeconds, credentialToJSON, encodeAuthFrame, type MeshCredential } from "./credential.ts";
 import { Identity } from "./identity.ts";
+import { openState, readTextFile } from "./platform/state.ts";
+import type { StateStore } from "./platform/types.ts";
 import { joinMesh, type JoinOptions, type MeshSession } from "./session.ts";
 
 const IDENTITY_FILE = "identity.key";
@@ -30,8 +31,9 @@ export interface AgentMeshOptions {
   /** Accept plaintext http:// to a non-loopback control plane. Off by default. */
   allowInsecure?: boolean;
   /**
-   * Directory that keeps the identity key and the credential across
-   * restarts. Without it the identity lives only in this process.
+   * Where the identity key and the credential are kept across restarts: a
+   * directory on Node, an IndexedDB database of that name in a browser.
+   * Without it the identity lives only in this process.
    */
   stateDir?: string | undefined;
   /** Use this identity instead of the persisted or a freshly generated one. */
@@ -47,7 +49,7 @@ export interface AgentMeshOptions {
 export interface EnrollOptions extends AgentMeshOptions {
   /** A bootstrap token value, when the caller already holds it in memory. */
   bootstrapToken?: string | undefined;
-  /** Path of a file holding the bootstrap token. Preferred over a value. */
+  /** Path of a file holding the bootstrap token. Preferred over a value on Node; a browser has no files. */
   bootstrapTokenPath?: string | undefined;
   /** An OIDC ID token, for meshes that enroll identities interactively. */
   jwt?: string | undefined;
@@ -86,13 +88,13 @@ export class AgentMesh {
   readonly identity: Identity;
   readonly controlPlane: ControlPlaneClient;
   #credential: MeshCredential;
-  readonly #stateDir: string | undefined;
+  readonly #state: StateStore | undefined;
 
-  private constructor(identity: Identity, controlPlane: ControlPlaneClient, credential: MeshCredential, stateDir: string | undefined) {
+  private constructor(identity: Identity, controlPlane: ControlPlaneClient, credential: MeshCredential, state: StateStore | undefined) {
     this.identity = identity;
     this.controlPlane = controlPlane;
     this.#credential = credential;
-    this.#stateDir = stateDir;
+    this.#state = state;
   }
 
   get peerId(): string {
@@ -114,13 +116,14 @@ export class AgentMesh {
    * the state directory to enroll afresh, for instance with other labels.
    */
   static async enroll(options: EnrollOptions): Promise {
-    const saved = await loadIdentity(options.stateDir);
+    const state = options.stateDir !== undefined ? openState(options.stateDir) : undefined;
+    const saved = await loadIdentity(state);
     const identity = options.identity ?? saved ?? Identity.generate();
     const controlPlane = newClient(options);
-    if (options.stateDir !== undefined && saved !== undefined && saved.peerId === identity.peerId) {
-      const credential = await loadCredential(options.stateDir);
+    if (state !== undefined && saved !== undefined && saved.peerId === identity.peerId) {
+      const credential = await loadCredential(state);
       if (credential !== undefined && sameBaseUrl(credential.controlPlaneUrl, controlPlane.url) && credentialTimeToLiveSeconds(credential) > REUSE_MIN_TTL_SECONDS) {
-        return new AgentMesh(identity, controlPlane, credential, options.stateDir);
+        return new AgentMesh(identity, controlPlane, credential, state);
       }
     }
     const given = [options.bootstrapToken, options.bootstrapTokenPath, options.jwt, options.jwtPath].filter((v) => v !== undefined).length;
@@ -132,10 +135,10 @@ export class AgentMesh {
 
     let enrollment: Enrollment;
     if (options.jwt !== undefined || options.jwtPath !== undefined) {
-      const jwt = options.jwtPath !== undefined ? (await readFile(options.jwtPath, "utf8")).trim() : (options.jwt as string);
+      const jwt = options.jwtPath !== undefined ? (await readTextFile(options.jwtPath)).trim() : (options.jwt as string);
       enrollment = await controlPlane.register({ identity, jwt, role, ...labelsOf(options) });
     } else {
-      const bootstrapToken = options.bootstrapTokenPath !== undefined ? (await readFile(options.bootstrapTokenPath, "utf8")).trim() : (options.bootstrapToken as string);
+      const bootstrapToken = options.bootstrapTokenPath !== undefined ? (await readTextFile(options.bootstrapTokenPath)).trim() : (options.bootstrapToken as string);
       enrollment = await controlPlane.enrollBootstrap({
         identity,
         bootstrapToken,
@@ -167,7 +170,7 @@ export class AgentMesh {
         issuedUnderKeys: controlPlaneKeys,
         routerAddresses: enrollment.routerAddresses,
       },
-      options.stateDir,
+      state,
     );
     await mesh.save();
     return mesh;
@@ -179,15 +182,16 @@ export class AgentMesh {
    * plane URL comes from the saved credential.
    */
   static async load(options: Omit & { stateDir: string }): Promise {
-    const identity = options.identity ?? (await loadIdentity(options.stateDir));
+    const state = openState(options.stateDir);
+    const identity = options.identity ?? (await loadIdentity(state));
     if (!identity) {
       throw new Error(`no identity in ${options.stateDir}; enroll first`);
     }
-    const credential = await loadCredential(options.stateDir);
+    const credential = await loadCredential(state);
     if (credential === undefined) {
       throw new Error(`no credential in ${options.stateDir}; enroll first`);
     }
-    return new AgentMesh(identity, newClient({ ...options, controlPlaneUrl: credential.controlPlaneUrl }), credential, options.stateDir);
+    return new AgentMesh(identity, newClient({ ...options, controlPlaneUrl: credential.controlPlaneUrl }), credential, state);
   }
 
   /**
@@ -213,8 +217,8 @@ export class AgentMesh {
    * credentials the new key signs verify before the next pull confirms it.
    */
   addTrustedKey(key: Uint8Array): boolean {
-    const hex = Buffer.from(key).toString("hex");
-    if (this.#credential.controlPlaneKeys.some((k) => Buffer.from(k).toString("hex") === hex)) {
+    const hex = toHex(key);
+    if (this.#credential.controlPlaneKeys.some((k) => toHex(k) === hex)) {
       return false;
     }
     this.#credential = { ...this.#credential, controlPlaneKeys: [...this.#credential.controlPlaneKeys, key] };
@@ -285,14 +289,13 @@ export class AgentMesh {
     return joinMesh(this, options);
   }
 
-  /** Writes identity and credential to the state directory, if one is configured. */
+  /** Writes identity and credential to the state store, if one is configured. */
   async save(): Promise {
-    if (this.#stateDir === undefined) {
+    if (this.#state === undefined) {
       return;
     }
-    await mkdir(this.#stateDir, { recursive: true, mode: 0o700 });
-    await writeAtomic(join(this.#stateDir, IDENTITY_FILE), this.identity.toLibp2pPrivateKey(), 0o600);
-    await writeAtomic(join(this.#stateDir, CREDENTIAL_FILE), credentialToJSON(this.#credential), 0o600);
+    await this.#state.write(IDENTITY_FILE, this.identity.toLibp2pPrivateKey());
+    await this.#state.write(CREDENTIAL_FILE, new TextEncoder().encode(credentialToJSON(this.#credential)));
   }
 }
 
@@ -307,7 +310,7 @@ function newClient(options: AgentMeshOptions): ControlPlaneClient {
 function sameKeySet(a: Uint8Array[], b: Uint8Array[]): boolean {
   const hex = (keys: Uint8Array[]) =>
     keys
-      .map((k) => Buffer.from(k).toString("hex"))
+      .map((k) => toHex(k))
       .sort()
       .join(",");
   return hex(a) === hex(b);
@@ -317,29 +320,14 @@ function labelsOf(options: AgentMeshOptions): { labels?: Record
   return options.labels !== undefined ? { labels: options.labels } : {};
 }
 
-async function loadIdentity(stateDir: string | undefined): Promise {
-  if (stateDir === undefined) {
-    return undefined;
-  }
-  try {
-    return Identity.fromLibp2pPrivateKey(new Uint8Array(await readFile(join(stateDir, IDENTITY_FILE))));
-  } catch (err) {
-    if ((err as NodeJS.ErrnoException).code === "ENOENT") {
-      return undefined;
-    }
-    throw err;
-  }
+async function loadIdentity(state: StateStore | undefined): Promise {
+  const bytes = await state?.read(IDENTITY_FILE);
+  return bytes === undefined ? undefined : Identity.fromLibp2pPrivateKey(bytes);
 }
 
-async function loadCredential(stateDir: string): Promise {
-  try {
-    return credentialFromJSON(await readFile(join(stateDir, CREDENTIAL_FILE), "utf8"));
-  } catch (err) {
-    if ((err as NodeJS.ErrnoException).code === "ENOENT") {
-      return undefined;
-    }
-    throw err;
-  }
+async function loadCredential(state: StateStore): Promise {
+  const bytes = await state.read(CREDENTIAL_FILE);
+  return bytes === undefined ? undefined : credentialFromJSON(new TextDecoder().decode(bytes));
 }
 
 /** The form every implementation persists: scheme, host, port, path, no trailing slash. */
@@ -350,9 +338,3 @@ function baseUrl(url: URL | string): string {
 function sameBaseUrl(a: URL | string, b: URL | string): boolean {
   return baseUrl(a) === baseUrl(b);
 }
-
-async function writeAtomic(path: string, data: Uint8Array | string, mode: number): Promise {
-  const tmp = `${path}.tmp`;
-  await writeFile(tmp, data, { mode });
-  await rename(tmp, path);
-}
diff --git a/sdk/js/src/platform/ingress.browser.ts b/sdk/js/src/platform/ingress.browser.ts
new file mode 100644
index 00000000..992c3125
--- /dev/null
+++ b/sdk/js/src/platform/ingress.browser.ts
@@ -0,0 +1,18 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The /libp2p-http ingress in a browser: a fetch handler or a URL; a Node
+// request listener is answered 501, there is no Node HTTP server to run it.
+
+export { httpIngressHandler as ingressHandler } from "../libp2p-http.ts";
diff --git a/sdk/js/src/platform/ingress.ts b/sdk/js/src/platform/ingress.ts
new file mode 100644
index 00000000..9fbd806f
--- /dev/null
+++ b/sdk/js/src/platform/ingress.ts
@@ -0,0 +1,17 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The /libp2p-http ingress on Node serves a Node request listener too.
+
+export { nodeIngressHandler as ingressHandler } from "../libp2p-http-node.ts";
diff --git a/sdk/js/src/platform/state.browser.ts b/sdk/js/src/platform/state.browser.ts
new file mode 100644
index 00000000..06913964
--- /dev/null
+++ b/sdk/js/src/platform/state.browser.ts
@@ -0,0 +1,58 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// State in a browser: an IndexedDB database per state location, one record
+// per name, kept by the browser for the page's origin. A put replaces the
+// record in one transaction, so a reader sees the old or the new value.
+
+import type { StateStore } from "./types.ts";
+
+const STORE = "state";
+
+function request(req: IDBRequest): Promise {
+  return new Promise((resolve, reject) => {
+    req.onsuccess = () => resolve(req.result);
+    req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed"));
+  });
+}
+
+function openDatabase(name: string): Promise {
+  const req = indexedDB.open(`sam-mesh:${name}`, 1);
+  req.onupgradeneeded = () => {
+    req.result.createObjectStore(STORE);
+  };
+  return request(req);
+}
+
+/** The store for a state location: a database named after it. */
+export function openState(location: string): StateStore {
+  let db: Promise | undefined;
+  const open = () => (db ??= openDatabase(location));
+  return {
+    async read(name) {
+      const tx = (await open()).transaction(STORE, "readonly");
+      const value = await request(tx.objectStore(STORE).get(name));
+      return value instanceof Uint8Array ? value : undefined;
+    },
+    async write(name, data) {
+      const tx = (await open()).transaction(STORE, "readwrite");
+      await request(tx.objectStore(STORE).put(new Uint8Array(data), name));
+    },
+  };
+}
+
+/** A browser has no file system a page may read; give the token itself. */
+export async function readTextFile(path: string): Promise {
+  throw new Error(`cannot read ${path}: a browser has no files to read a token from; pass the value instead`);
+}
diff --git a/sdk/js/src/platform/state.ts b/sdk/js/src/platform/state.ts
new file mode 100644
index 00000000..49df0c48
--- /dev/null
+++ b/sdk/js/src/platform/state.ts
@@ -0,0 +1,48 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// State on Node: a directory, one file per name, owner-only, written to a
+// temporary name and renamed into place. Where a token file is read from.
+
+import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { StateStore } from "./types.ts";
+
+/** The store for a state location: a directory, created on first write. */
+export function openState(location: string): StateStore {
+  return {
+    async read(name) {
+      try {
+        return new Uint8Array(await readFile(join(location, name)));
+      } catch (err) {
+        if ((err as NodeJS.ErrnoException).code === "ENOENT") {
+          return undefined;
+        }
+        throw err;
+      }
+    },
+    async write(name, data) {
+      await mkdir(location, { recursive: true, mode: 0o700 });
+      const path = join(location, name);
+      const tmp = `${path}.tmp`;
+      await writeFile(tmp, data, { mode: 0o600 });
+      await rename(tmp, path);
+    },
+  };
+}
+
+/** A text file, for a token an operator or a platform placed on disk. */
+export async function readTextFile(path: string): Promise {
+  return readFile(path, "utf8");
+}
diff --git a/sdk/js/src/platform/transports.browser.ts b/sdk/js/src/platform/transports.browser.ts
new file mode 100644
index 00000000..e020de13
--- /dev/null
+++ b/sdk/js/src/platform/transports.browser.ts
@@ -0,0 +1,32 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// How a member in a browser reaches the mesh: WebSocket, the one transport a
+// page can open to a router (sam-one's single port, or a router behind a
+// TLS-terminating proxy as wss), secured with Noise. A browser cannot run
+// libp2p's TLS, which needs a self-signed certificate over a raw socket;
+// routers and nodes accept Noise beside TLS, and both bind the connection to
+// the peer ID.
+
+import { noise } from "@chainsafe/libp2p-noise";
+import { webSockets } from "@libp2p/websockets";
+import type { Libp2pOptions } from "libp2p";
+
+export function transports(): NonNullable {
+  return [webSockets()];
+}
+
+export function connectionEncrypters(): NonNullable {
+  return [noise()];
+}
diff --git a/sdk/js/src/platform/transports.ts b/sdk/js/src/platform/transports.ts
new file mode 100644
index 00000000..f37910df
--- /dev/null
+++ b/sdk/js/src/platform/transports.ts
@@ -0,0 +1,32 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// How a member on Node reaches the mesh: TCP and WebSocket, the transports
+// the routers listen on. TLS 1.3 first, as every Go peer and the Python SDK
+// offer it (internal/node/node.go); Noise accepted, so a member in a
+// browser, which speaks Noise alone, is reached end to end through a relay.
+
+import { noise } from "@chainsafe/libp2p-noise";
+import { tcp } from "@libp2p/tcp";
+import { tls } from "@libp2p/tls";
+import { webSockets } from "@libp2p/websockets";
+import type { Libp2pOptions } from "libp2p";
+
+export function transports(): NonNullable {
+  return [tcp(), webSockets()];
+}
+
+export function connectionEncrypters(): NonNullable {
+  return [tls(), noise()];
+}
diff --git a/sdk/js/src/platform/types.ts b/sdk/js/src/platform/types.ts
new file mode 100644
index 00000000..4118e0b7
--- /dev/null
+++ b/sdk/js/src/platform/types.ts
@@ -0,0 +1,25 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// What differs between Node and a browser, as the rest of the SDK sees it.
+// Each concern has a Node module and a .browser.ts twin with the same
+// exports; package.json's "browser" field points a bundler at the twin.
+
+/** Where a member keeps its identity and credential between runs. */
+export interface StateStore {
+  /** The bytes under a name, or undefined when nothing was written yet. */
+  read(name: string): Promise;
+  /** Writes the bytes under a name so that a reader sees the old or the new value, never a mix. */
+  write(name: string, data: Uint8Array): Promise;
+}
diff --git a/sdk/js/src/platform/wasm.browser.ts b/sdk/js/src/platform/wasm.browser.ts
new file mode 100644
index 00000000..68aaf04e
--- /dev/null
+++ b/sdk/js/src/platform/wasm.browser.ts
@@ -0,0 +1,25 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// biscuit-wasm is built for bundlers: its entry imports the .wasm as an ES
+// module, which the bundler of the page resolves (webpack's
+// asyncWebAssembly, vite-plugin-wasm, or the esbuild plugin in
+// scripts/bundle-browser.mjs). The module is loaded once the page first
+// needs it.
+
+export type BiscuitWasm = typeof import("@biscuit-auth/biscuit-wasm");
+
+export async function loadBiscuitWasm(): Promise {
+  return import("@biscuit-auth/biscuit-wasm");
+}
diff --git a/sdk/js/src/platform/wasm.ts b/sdk/js/src/platform/wasm.ts
new file mode 100644
index 00000000..35479ed4
--- /dev/null
+++ b/sdk/js/src/platform/wasm.ts
@@ -0,0 +1,52 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// biscuit-wasm is built for bundlers and imports its .wasm as a module,
+// which Node only does behind a flag. Instantiating it by hand avoids the
+// flag; the module's import list says what it needs.
+
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+
+export type BiscuitWasm = typeof import("@biscuit-auth/biscuit-wasm");
+
+export async function loadBiscuitWasm(): Promise {
+  const dir = new URL("./", import.meta.resolve("@biscuit-auth/biscuit-wasm"));
+  const bindings = (await import(new URL("biscuit_bg.js", dir).href)) as BiscuitWasm & {
+    __wbg_set_wasm(exports: WebAssembly.Exports): void;
+  };
+  const module = await WebAssembly.compile(await readFile(fileURLToPath(new URL("biscuit_bg.wasm", dir))));
+  const imports: WebAssembly.Imports = {};
+  for (const imp of WebAssembly.Module.imports(module)) {
+    const ns = (imports[imp.module] ??= {}) as Record;
+    if (imp.module === "./biscuit_bg.js") {
+      ns[imp.name] = (bindings as unknown as Record)[imp.name] as WebAssembly.ImportValue;
+    } else if (imp.name === "performance_now") {
+      ns[imp.name] = () => performance.now();
+    } else {
+      throw new Error(`biscuit-wasm needs an unknown import ${imp.module}:${imp.name}`);
+    }
+  }
+  const instance = await WebAssembly.instantiate(module, imports);
+  bindings.__wbg_set_wasm(instance.exports);
+  // The module's start hook logs a greeting to the console.
+  const log = console.log;
+  console.log = () => {};
+  try {
+    (instance.exports as { __wbindgen_start(): void }).__wbindgen_start();
+  } finally {
+    console.log = log;
+  }
+  return bindings;
+}
diff --git a/sdk/js/src/session.ts b/sdk/js/src/session.ts
index 49db9b9a..50f57249 100644
--- a/sdk/js/src/session.ts
+++ b/sdk/js/src/session.ts
@@ -29,7 +29,6 @@ import {
   HTTP_PROTOCOL,
   a2aEndpoint,
   fetchOverStream,
-  httpIngressHandler,
   httpRequestOverStream,
   meshURL,
   splitMeshURL,
@@ -39,6 +38,7 @@ import {
   type HTTPResponse,
   type ProviderOptions,
 } from "./libp2p-http.ts";
+import { ingressHandler } from "./platform/ingress.ts";
 import { BanSet, GOSSIP_EVENTS_TOPIC, MeshEvent_Type, verifyMeshEvent } from "./sync.ts";
 
 export interface JoinOptions extends MeshHostOptions {
@@ -491,7 +491,7 @@ export class MeshSession {
       isBanned: (peerId) => this.banned.has(peerId),
       onAuthorized: (peerId, verified) => this.authenticatedPeers.set(peerId, verified.expiration),
     };
-    await this.node.handle(HTTP_PROTOCOL, httpIngressHandler(endpoint, providerOptions), HTTP_HANDLER_OPTIONS);
+    await this.node.handle(HTTP_PROTOCOL, ingressHandler(endpoint, providerOptions), HTTP_HANDLER_OPTIONS);
     this.#policyTimer = setInterval(() => void this.syncPolicy().catch(() => {}), this.#policySyncMs);
     this.#policyTimer.unref?.();
     this.endpoint = endpoint;
diff --git a/sdk/python/src/agent_mesh/host.py b/sdk/python/src/agent_mesh/host.py
index 9bb0504b..3d04b651 100644
--- a/sdk/python/src/agent_mesh/host.py
+++ b/sdk/python/src/agent_mesh/host.py
@@ -13,8 +13,9 @@
 # limitations under the License.
 
 """The libp2p host a member joins the mesh with, configured the way sam-node's
-is (internal/node/node.go): TLS is the only security protocol and yamux the
-muxer. py-libp2p is trio-based, so everything here is trio async."""
+is (internal/node/node.go): TLS for the security protocol, which every peer
+offers first, Noise accepted beside it, and yamux the muxer. py-libp2p is
+trio-based, so everything here is trio async."""
 
 from __future__ import annotations
 
@@ -33,10 +34,13 @@
 from libp2p import new_host
 from libp2p.abc import IHost, INetStream
 from libp2p.crypto.ed25519 import create_new_key_pair
+from libp2p.crypto.x25519 import create_new_key_pair as create_new_x25519_key_pair
 from libp2p.custom_types import TProtocol
 from libp2p.network.config import ConnectionConfig
 from libp2p.peer.id import ID
 from libp2p.peer.peerinfo import PeerInfo, info_from_p2p_addr
+from libp2p.security.noise.transport import PROTOCOL_ID as NOISE_PROTOCOL_ID
+from libp2p.security.noise.transport import Transport as NoiseTransport
 from libp2p.security.tls.transport import PROTOCOL_ID as TLS_PROTOCOL_ID
 from libp2p.security.tls.transport import IdentityConfig, TLSTransport
 from libp2p.stream_muxer.exceptions import MuxedStreamError
@@ -163,9 +167,13 @@ def create_mesh_host(identity: Identity, listen_addrs: Sequence[str] = ()) -> tu
     # but does not complete it, and go-libp2p then refuses the mux upgrade.
     # Without it the muxer is negotiated with multistream-select as before.
     tls = TLSTransport(key_pair, identity_config=IdentityConfig(cert_template=_certificate_template()))
+    # TLS first, as every Go peer and the JS SDK offer it; Noise accepted, so
+    # a member in a browser, which speaks Noise alone, is reached end to end
+    # through a relay. Both bind the connection to the peer ID.
+    noise = NoiseTransport(key_pair, noise_privkey=create_new_x25519_key_pair().private_key)
     host = new_host(
         key_pair=key_pair,
-        sec_opt={TLS_PROTOCOL_ID: tls},
+        sec_opt={TLS_PROTOCOL_ID: tls, NOISE_PROTOCOL_ID: noise},
         muxer_opt={TProtocol(YAMUX_PROTOCOL_ID): Yamux},
         enable_websocket=True,
         # py-libp2p 0.7 dials wss with certificate verification off unless
diff --git a/sdk/python/src/agent_mesh/libp2p_http.py b/sdk/python/src/agent_mesh/libp2p_http.py
index f773bb92..f470fa0d 100644
--- a/sdk/python/src/agent_mesh/libp2p_http.py
+++ b/sdk/python/src/agent_mesh/libp2p_http.py
@@ -22,6 +22,7 @@
 import base64
 import json
 import logging
+import re
 from dataclasses import dataclass, field
 from typing import AsyncIterator, Awaitable, Callable, Mapping, Optional, Sequence, Union
 
@@ -114,8 +115,13 @@ class ProviderOptions:
     on_authorized: Optional[Callable[[str, VerifiedBiscuit, str], None]] = None
 
 
+_DOT_ENCODED = re.compile("%2e", re.IGNORECASE)
+
+
 def _has_dot_segment(path: str) -> bool:
-    return any(seg in (".", "..") for seg in path.split("/"))
+    # A URL parser reads %2e as a dot too (WHATWG URL, path state), so the
+    # check sees what the backend will see.
+    return any(_DOT_ENCODED.sub(".", seg) in (".", "..") for seg in path.split("/"))
 
 
 async def _read_http_request(stream: INetStream, conn: h11.Connection, limit: int) -> tuple[h11.Request, bytes]:
diff --git a/sdk/python/tests/test_libp2p_http.py b/sdk/python/tests/test_libp2p_http.py
index e0274334..a7a6adff 100644
--- a/sdk/python/tests/test_libp2p_http.py
+++ b/sdk/python/tests/test_libp2p_http.py
@@ -211,6 +211,9 @@ async def main():
                 assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://other", "/card")).status == 404
                 assert (await http_request_over_stream(caller, pid, b"", "a2a://agent", "/card")).status == 401
                 assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://agent", "/../other/x")).status == 400
+                # A URL parser reads %2e as a dot too; the spelling does not get past the check.
+                assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://agent", "/%2e%2e/other/x")).status == 400
+                assert (await http_request_over_stream(caller, pid, caller_biscuit, "a2a://agent", "/.%2E/other/x")).status == 400
             nursery.cancel_scope.cancel()
 
     async def with_timeout():
diff --git a/site/content/docs/concepts/networking.md b/site/content/docs/concepts/networking.md
index 9a8097b5..dda1d662 100644
--- a/site/content/docs/concepts/networking.md
+++ b/site/content/docs/concepts/networking.md
@@ -89,9 +89,11 @@ sends any request data.
 
 ## What travels where
 
-Every hop between two nodes is encrypted by libp2p's TLS 1.3 secure channel.
-TLS is the only security transport enabled, so that FIPS-validated
-cryptography can be used end to end. A relay forwards ciphertext and learns
+Every hop between two nodes is encrypted by a libp2p secure channel that
+binds the connection to the peer's identity: TLS 1.3, which Go peers and the
+Node and Python SDKs use, or Noise, which a browser can speak. Routers and
+nodes offer TLS first and accept Noise; two peers that both have TLS land on
+it. A relay forwards ciphertext and learns
 only that the two peers are talking. The control plane sees enrollments,
 refreshes, router leases, policy fetches and catalog reports. It never sees a
 request.
diff --git a/site/content/docs/guides/native-sdks.md b/site/content/docs/guides/native-sdks.md
index 2b9f12f0..9f5fcecf 100644
--- a/site/content/docs/guides/native-sdks.md
+++ b/site/content/docs/guides/native-sdks.md
@@ -110,6 +110,9 @@ pip install sam-mesh               # Python 3.11 or later
 The Python package is imported as `agent_mesh`. It runs on trio, because
 py-libp2p does; under asyncio, use it through `anyio` with the trio backend.
 
+The JS package also runs in a browser page; see
+[In a browser](#in-a-browser) below.
+
 ## 3. Be an agent
 
 This program joins the mesh and answers A2A requests for `a2a://agent`
@@ -916,6 +919,45 @@ or a pattern) and the members it may reach; see
 - **Reading the policy.** `session.policyRules` (`session.policy_rules`)
   holds the Datalog the session enforces, for logging or tests.
 
+## In a browser
+
+The JS SDK is the same package in a page. `AgentMesh.enroll`, `join`,
+`acceptA2A`, `fetch()` and the rest work as above; what differs is how
+the page reaches the mesh and where it keeps its state:
+
+- The page connects to a router over WebSocket and secures the connection
+  with Noise. Routers and nodes offer TLS first and accept Noise, so a
+  Node, Python or Go member reached through a relay meets the page on
+  Noise, end to end. `sam-one` listens on WebSocket by default; behind
+  `--tunnel` or any TLS-terminating proxy it advertises the router as
+  `wss`, which a page served over `https` needs.
+- The control plane answers the page's requests from any origin. Enrollment
+  and refresh authenticate by the token or biscuit the request carries, so a
+  page on another origin sends nothing a program could not send already.
+- `stateDir` names an IndexedDB database instead of a directory; a reload
+  resumes the saved enrollment without a token. `bootstrapTokenPath` and
+  `jwtPath` are refused, a browser has no files: pass `bootstrapToken` or
+  `jwt`.
+- `acceptA2A` takes a `handler` (a function from `Request` to `Response`) or
+  a `url`; a Node `listener` has no HTTP server to run on in a page.
+
+The example `sdk/js/examples/browser` is the Echo agent above in a page,
+answering with the A2A SDK's `JsonRpcTransportHandler` behind a handler.
+Bundle it with the page's own bundler, or with the script the repository
+uses:
+
+```bash
+cd sdk/js && npm run build
+node scripts/bundle-browser.mjs examples/browser/app.js build/browser-example
+```
+
+and serve `build/browser-example`. The `browser` field of `package.json`
+tells a bundler which files to swap for the browser; `@biscuit-auth/biscuit-wasm`
+imports its `.wasm` as a module, which webpack (`asyncWebAssembly`), Vite
+(`vite-plugin-wasm`) and the script above resolve. `tests/ui/browser-sdk.spec.js`
+runs the page in Chromium against `sam-one`, once directly and once behind
+a TLS-terminating edge, with Node members calling it and being called.
+
 ## What the SDKs do not do
 
 - They do not publish services. An MCP server, a model or an agent that
@@ -925,10 +967,8 @@ or a pattern) and the members it may reach; see
   policy enforcement of `sam-box` runs beside a `sam-node`.
 - They do not serve the discovery table. A member is a client of it; the
   routers hold the records.
-- They do not run in a browser. Node.js and CPython only. The JS SDK
-  dials routers over WebSocket, which a browser can do, but routers and
-  nodes accept libp2p TLS alone, which a browser cannot speak, and the SDK
-  keeps its state and speaks HTTP on streams with Node's own modules.
+- The Python SDK does not run in a browser; the JS SDK does, see
+  [In a browser](#in-a-browser).
 
 The wire contract and the interoperability facts the tests pin are in
 [`sdk/README.md`](https://github.com/google/sam/blob/main/sdk/README.md).
diff --git a/tests/integration/noise_test.go b/tests/integration/noise_test.go
new file mode 100644
index 00000000..18330b6e
--- /dev/null
+++ b/tests/integration/noise_test.go
@@ -0,0 +1,103 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package integration_test
+
+import (
+	"context"
+	"crypto/ed25519"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/sam/api"
+	"github.com/google/sam/internal/identity"
+	"github.com/libp2p/go-libp2p"
+	"github.com/libp2p/go-libp2p/core/host"
+	"github.com/libp2p/go-libp2p/core/network"
+	"github.com/libp2p/go-libp2p/core/peer"
+	"github.com/libp2p/go-libp2p/p2p/security/noise"
+	"github.com/multiformats/go-multiaddr"
+)
+
+// TestNoiseOnlyPeer pins what a browser member is on the wire: a peer that
+// speaks Noise and no libp2p TLS. It authenticates with the router over
+// Noise, is relayed to a sam-node, and the relayed connection, upgraded end
+// to end between the two of them, is Noise as well, so the node's auth
+// handshake and a service call go through. A peer that speaks TLS still
+// lands on TLS: it is offered first.
+func TestNoiseOnlyPeer(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+	mesh := startSDKMesh(t)
+
+	h, err := libp2p.New(
+		libp2p.NoListenAddrs,
+		libp2p.Security(noise.ID, noise.New),
+		libp2p.EnableRelay(),
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = h.Close() })
+	biscuit := goHostBiscuit(t, mesh.cpPriv, h.ID())
+
+	router, err := peer.AddrInfoFromString(mesh.routerAddr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := h.Connect(ctx, *router); err != nil {
+		t.Fatalf("noise-only peer could not connect to the router: %v", err)
+	}
+	if got := securityOf(h, router.ID); got != noise.ID {
+		t.Fatalf("connection to the router is %q, want %q", got, noise.ID)
+	}
+	routerBiscuit := authHandshake(t, ctx, h, router.ID, biscuit)
+	if err := identity.VerifyBiscuitRole(routerBiscuit, mesh.cpPriv.Public().(ed25519.PublicKey), api.RoleRouter, 5*time.Second); err != nil {
+		t.Fatalf("router credential lacks the router role: %v", err)
+	}
+
+	nodeID := mesh.samNode.peerID
+	relayed := multiaddr.StringCast(mesh.routerAddr + "/p2p-circuit/p2p/" + nodeID.String())
+	// A relayed connection reports no security protocol in its ConnState;
+	// that this peer, which has Noise alone, gets one at all is the check.
+	if err := h.Connect(network.WithAllowLimitedConn(ctx, "test"), peer.AddrInfo{ID: nodeID, Addrs: []multiaddr.Multiaddr{relayed}}); err != nil {
+		t.Fatalf("noise-only peer could not reach the node through the router: %v", err)
+	}
+	nodeBiscuit := authHandshake(t, ctx, h, nodeID, biscuit)
+	if err := identity.VerifyBiscuitRole(nodeBiscuit, mesh.cpPriv.Public().(ed25519.PublicKey), api.RoleNode, 5*time.Second); err != nil {
+		t.Fatalf("node credential lacks the node role: %v", err)
+	}
+	// The MCP service answers a bare GET with its own 400 (the streamable
+	// HTTP transport wants an SSE Accept); an answer from the service is what
+	// shows the request crossed the noise connection into the node.
+	if status, body := libp2pHTTPGet(t, ctx, h, nodeID, biscuit, "/mcp/calc"); status != 200 && (status != 400 || !strings.Contains(body, "text/event-stream")) {
+		t.Fatalf("service call over the noise connection: %d %s", status, body)
+	}
+
+	// The TLS peer of the other tests is unchanged: TLS is offered first.
+	tlsPeer := newAdmittedGoPeer(t, ctx, mesh.cpPriv, mesh.routerAddr)
+	if got := securityOf(tlsPeer, router.ID); !strings.HasPrefix(got, "/tls/") {
+		t.Fatalf("TLS peer's connection to the router is %q, want /tls/...", got)
+	}
+}
+
+// securityOf is the security protocol of h's connection to p.
+func securityOf(h host.Host, p peer.ID) string {
+	conns := h.Network().ConnsToPeer(p)
+	if len(conns) == 0 {
+		return ""
+	}
+	return string(conns[0].ConnState().Security)
+}
diff --git a/tests/ui/browser-sdk.spec.js b/tests/ui/browser-sdk.spec.js
new file mode 100644
index 00000000..94809917
--- /dev/null
+++ b/tests/ui/browser-sdk.spec.js
@@ -0,0 +1,99 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The JS SDK in a browser page (sdk/js/examples/browser) as a member of a
+// sam-one mesh: the page enrolls with the join token from an origin of its
+// own, joins the router over WebSocket and Noise, answers A2A requests from
+// a Node member, calls a Node agent, and after a reload resumes the saved
+// enrollment without a token. Once against sam-one directly and once behind
+// a TLS-terminating edge reached by name, the topology `sam-one --tunnel`
+// leaves the page in, where the router is advertised as wss.
+
+const { test, expect } = require('@playwright/test');
+const stack = require('./lib/sam-one');
+
+test.use({ ignoreHTTPSErrors: true });
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(120_000);
+
+const topologies = [
+  { name: 'direct', edge: false },
+  { name: 'behind a TLS edge', edge: true },
+];
+
+for (const topology of topologies) {
+  test(`a browser page is a member of a sam-one mesh (${topology.name})`, async ({ page }) => {
+    const why = stack.missing();
+    test.skip(why !== '', why);
+
+    const stops = [];
+    try {
+      let samOne;
+      let caFile;
+      if (topology.edge) {
+        const cert = stack.selfSignedCert('localhost');
+        test.skip(cert === null, 'openssl is not installed');
+        caFile = cert.certFile;
+        // The edge's port must be known before sam-one starts, since sam-one
+        // advertises it; the edge then proxies to wherever sam-one bound.
+        const edgePort = await stack.freePort();
+        samOne = await stack.startSamOne({ externalUrl: `https://localhost:${edgePort}` });
+        stops.push(() => samOne.stop());
+        const edge = await stack.startTLSEdge({ host: 'localhost', origin: samOne.localUrl.replace('http://', ''), cert: cert.cert, key: cert.key, port: edgePort });
+        stops.push(() => edge.stop());
+      } else {
+        samOne = await stack.startSamOne();
+        stops.push(() => samOne.stop());
+      }
+      const site = await stack.serveStatic(stack.PAGE_DIR);
+      stops.push(() => site.stop());
+
+      // Enroll and join from the page, on an origin of its own.
+      const query = new URLSearchParams({ controlPlaneUrl: samOne.publicUrl, bootstrapToken: samOne.joinToken, allowInsecure: 'true' });
+      await page.goto(`${site.url}/?${query}`);
+      await page.getByRole('button', { name: 'Join' }).click();
+      await expect(page.locator('#status')).toContainText('accepting a2a://agent', { timeout: 30_000 });
+      const browserPeer = (await page.locator('#peer-id').textContent()).trim();
+      expect(browserPeer).toMatch(/^12D3Koo/);
+
+      // A Node member calls the page's agent through the router.
+      const nodeEnv = stack.exampleEnv({ publicUrl: samOne.publicUrl, joinToken: samOne.joinToken, caFile }, 'caller');
+      const callOut = await stack.runExample('a2a-call', nodeEnv, [browserPeer, 'hello from node']);
+      const nodePeer = /on the mesh as (\S+)/.exec(callOut)?.[1];
+      expect(nodePeer, callOut).toBeTruthy();
+      expect(callOut).toContain('agent: Echo agent (browser)');
+      expect(callOut).toContain(`${nodePeer} said: hello from node`);
+      await expect(page.locator('#log')).toContainText(`message from ${nodePeer}: hello from node`);
+
+      // The page calls a Node agent.
+      const agent = await stack.startExampleAgent('a2a-agent', stack.exampleEnv({ publicUrl: samOne.publicUrl, joinToken: samOne.joinToken, caFile }, 'agent'));
+      stops.push(() => agent.stop());
+      await page.locator('#target-peer').fill(agent.peerId);
+      await page.locator('#message').fill('hello from a browser');
+      await page.getByRole('button', { name: 'Call' }).click();
+      await expect(page.locator('#answer')).toContainText(`Echo agent: ${browserPeer} said: hello from a browser`, { timeout: 30_000 });
+
+      // A reload resumes the saved enrollment: same peer, no token.
+      const resume = new URLSearchParams({ controlPlaneUrl: samOne.publicUrl, allowInsecure: 'true' });
+      await page.goto(`${site.url}/?${resume}`);
+      await page.getByRole('button', { name: 'Join' }).click();
+      await expect(page.locator('#status')).toContainText('accepting a2a://agent', { timeout: 30_000 });
+      expect((await page.locator('#peer-id').textContent()).trim()).toBe(browserPeer);
+    } finally {
+      for (const stop of stops.reverse()) {
+        stop();
+      }
+    }
+  });
+}
diff --git a/tests/ui/lib/sam-one.js b/tests/ui/lib/sam-one.js
new file mode 100644
index 00000000..e906730a
--- /dev/null
+++ b/tests/ui/lib/sam-one.js
@@ -0,0 +1,249 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// The pieces the browser SDK test stands up around Chromium: sam-one from
+// bin/, a TLS-terminating edge in front of it that routes by name the way
+// `sam-one --tunnel` puts one there, a static server for the page, and the
+// Node example programs from sdk/js as the other members.
+
+const { spawn, spawnSync } = require('node:child_process');
+const fs = require('node:fs');
+const http = require('node:http');
+const https = require('node:https');
+const net = require('node:net');
+const os = require('node:os');
+const path = require('node:path');
+const tls = require('node:tls');
+
+const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
+const SDK_JS = path.join(REPO_ROOT, 'sdk', 'js');
+const PAGE_DIR = path.join(SDK_JS, 'build', 'browser-example');
+
+/** Why the test cannot run here, or '' when everything is in place. */
+function missing() {
+  if (!fs.existsSync(path.join(REPO_ROOT, 'bin', 'sam-one'))) {
+    return 'bin/sam-one is not built (make build)';
+  }
+  if (!fs.existsSync(path.join(PAGE_DIR, 'index.html')) || !fs.existsSync(path.join(SDK_JS, 'build', 'examples', 'a2a-call.js'))) {
+    return 'the sdk/js browser example is not built (tests/ui/run.sh builds it)';
+  }
+  return '';
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const srv = net.createServer();
+    srv.listen(0, '127.0.0.1', () => {
+      const { port } = srv.address();
+      srv.close(() => resolve(port));
+    });
+    srv.on('error', reject);
+  });
+}
+
+async function waitFor(url, what, tries = 100) {
+  for (let i = 0; i < tries; i++) {
+    try {
+      const res = await fetch(url);
+      if (res.ok) {
+        return;
+      }
+    } catch {
+      // not up yet
+    }
+    await new Promise((r) => setTimeout(r, 100));
+  }
+  throw new Error(`timed out waiting for ${what} at ${url}`);
+}
+
+/** Runs bin/sam-one on a free loopback port; externalUrl is what it advertises. */
+async function startSamOne({ externalUrl } = {}) {
+  const port = await freePort();
+  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sam-one-ui-'));
+  const args = ['--bind-address', '127.0.0.1', '--port', String(port), '--data-dir', dataDir, '--log-level', 'warn'];
+  if (externalUrl) {
+    args.push('--external-url', externalUrl);
+  }
+  const proc = spawn(path.join(REPO_ROOT, 'bin', 'sam-one'), args, { stdio: ['ignore', 'pipe', 'pipe'] });
+  let output = '';
+  proc.stdout.on('data', (d) => (output += d));
+  proc.stderr.on('data', (d) => (output += d));
+  const localUrl = `http://127.0.0.1:${port}`;
+  try {
+    await waitFor(`${localUrl}/readyz`, 'sam-one');
+  } catch (err) {
+    proc.kill();
+    throw new Error(`${err.message}\n${output}`);
+  }
+  return {
+    localUrl,
+    publicUrl: externalUrl ?? localUrl,
+    joinToken: fs.readFileSync(path.join(dataDir, 'join-token'), 'utf8').trim(),
+    get output() {
+      return output;
+    },
+    stop() {
+      proc.kill();
+      fs.rmSync(dataDir, { recursive: true, force: true });
+    },
+  };
+}
+
+/** A self-signed certificate for host, from openssl; null when openssl is not installed. */
+function selfSignedCert(host) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sam-edge-'));
+  const cert = path.join(dir, 'cert.pem');
+  const key = path.join(dir, 'key.pem');
+  const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', key, '-out', cert, '-days', '1', '-subj', `/CN=${host}`, '-addext', `subjectAltName=DNS:${host}`], { stdio: 'pipe' });
+  if (r.error || r.status !== 0) {
+    fs.rmSync(dir, { recursive: true, force: true });
+    return null;
+  }
+  return { certFile: cert, cert: fs.readFileSync(cert), key: fs.readFileSync(key), dir };
+}
+
+function hostOf(req) {
+  const h = req.headers.host ?? '';
+  return h.includes(':') ? h.slice(0, h.lastIndexOf(':')) : h;
+}
+
+/**
+ * Terminates TLS for host and proxies to origin, HTTP and WebSocket
+ * upgrades alike. A handshake for another server name fails and a request
+ * for another host gets 403, as an edge that routes by name treats a client
+ * that reached it by IP.
+ */
+async function startTLSEdge({ host, origin, cert, key, port = 0 }) {
+  const [originHost, originPort] = origin.split(':');
+  const context = tls.createSecureContext({ cert, key });
+  const server = https.createServer(
+    {
+      cert,
+      key,
+      SNICallback: (name, cb) => (name.toLowerCase() === host ? cb(null, context) : cb(new Error(`edge: unknown server name ${name}`))),
+    },
+    (req, res) => {
+      if (hostOf(req).toLowerCase() !== host) {
+        res.writeHead(403, { 'content-type': 'text/plain' });
+        res.end(`403 Forbidden (edge: unknown host ${req.headers.host})\n`);
+        return;
+      }
+      const upstream = http.request({ host: originHost, port: Number(originPort), method: req.method, path: req.url, headers: { ...req.headers, host: origin } }, (ur) => {
+        res.writeHead(ur.statusCode, ur.headers);
+        ur.pipe(res);
+      });
+      upstream.on('error', () => {
+        res.writeHead(502);
+        res.end();
+      });
+      req.pipe(upstream);
+    },
+  );
+  server.on('upgrade', (req, socket, head) => {
+    if (hostOf(req).toLowerCase() !== host) {
+      socket.end('HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n');
+      return;
+    }
+    const upstream = net.connect(Number(originPort), originHost, () => {
+      const lines = [`${req.method} ${req.url} HTTP/1.1`];
+      for (let i = 0; i < req.rawHeaders.length; i += 2) {
+        const name = req.rawHeaders[i];
+        lines.push(`${name}: ${name.toLowerCase() === 'host' ? origin : req.rawHeaders[i + 1]}`);
+      }
+      upstream.write(lines.join('\r\n') + '\r\n\r\n');
+      if (head.length > 0) {
+        upstream.write(head);
+      }
+      socket.pipe(upstream).pipe(socket);
+    });
+    upstream.on('error', () => socket.destroy());
+    socket.on('error', () => upstream.destroy());
+  });
+  await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
+  return { url: `https://${host}:${server.address().port}`, port: server.address().port, stop: () => server.close() };
+}
+
+const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.map': 'application/json', '.wasm': 'application/wasm' };
+
+/** Serves a directory of static files on a free loopback port. */
+async function serveStatic(dir) {
+  const server = http.createServer((req, res) => {
+    const rel = decodeURIComponent(new URL(req.url, 'http://x').pathname).replace(/^\/+/, '') || 'index.html';
+    const file = path.join(dir, rel);
+    if (!file.startsWith(dir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
+      res.writeHead(404);
+      res.end();
+      return;
+    }
+    res.writeHead(200, { 'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream' });
+    fs.createReadStream(file).pipe(res);
+  });
+  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+  return { url: `http://127.0.0.1:${server.address().port}`, stop: () => server.close() };
+}
+
+/** Runs one of sdk/js's built examples to completion and returns its output. */
+function runExample(name, env, args = []) {
+  return new Promise((resolve, reject) => {
+    const proc = spawn('node', [path.join(SDK_JS, 'build', 'examples', `${name}.js`), ...args], { cwd: REPO_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
+    let out = '';
+    proc.stdout.on('data', (d) => (out += d));
+    proc.stderr.on('data', (d) => (out += d));
+    const timer = setTimeout(() => proc.kill(), 30_000);
+    proc.on('close', (code) => {
+      clearTimeout(timer);
+      code === 0 ? resolve(out) : reject(new Error(`${name} exited with ${code}:\n${out}`));
+    });
+  });
+}
+
+/** Starts one of sdk/js's built example agents and waits for the line that names its peer. */
+function startExampleAgent(name, env, ready = /accepting a2a:\/\/agent as (\S+)/) {
+  return new Promise((resolve, reject) => {
+    const proc = spawn('node', [path.join(SDK_JS, 'build', 'examples', `${name}.js`)], { cwd: REPO_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
+    let out = '';
+    const timer = setTimeout(() => {
+      proc.kill();
+      reject(new Error(`${name} did not come up:\n${out}`));
+    }, 30_000);
+    const onData = (d) => {
+      out += d;
+      const m = ready.exec(out);
+      if (m) {
+        clearTimeout(timer);
+        resolve({ peerId: m[1], stop: () => proc.kill(), get output() { return out; } });
+      }
+    };
+    proc.stdout.on('data', onData);
+    proc.stderr.on('data', onData);
+    proc.on('close', () => {
+      clearTimeout(timer);
+      reject(new Error(`${name} exited:\n${out}`));
+    });
+  });
+}
+
+/** The environment the Node examples take to join sam-one at publicUrl. */
+function exampleEnv({ publicUrl, joinToken, caFile }, stateName) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), `sam-${stateName}-`));
+  fs.writeFileSync(path.join(dir, 'join-token'), joinToken + '\n', { mode: 0o600 });
+  return {
+    SAM_CONTROL_PLANE_URL: publicUrl,
+    SAM_BOOTSTRAP_TOKEN_PATH: path.join(dir, 'join-token'),
+    SAM_STATE_DIR: path.join(dir, 'state'),
+    ...(caFile ? { NODE_EXTRA_CA_CERTS: caFile } : {}),
+  };
+}
+
+module.exports = { PAGE_DIR, missing, freePort, startSamOne, selfSignedCert, startTLSEdge, serveStatic, runExample, startExampleAgent, exampleEnv };
diff --git a/tests/ui/run.sh b/tests/ui/run.sh
index a5461e09..6b64471c 100755
--- a/tests/ui/run.sh
+++ b/tests/ui/run.sh
@@ -32,6 +32,14 @@ export SAM_CONSOLE_URL="${STACK_CONSOLE_URL}"
 
 stack_start
 
+# The browser SDK test (browser-sdk.spec.js) runs sdk/js in a page against
+# bin/sam-one; the page and the Node examples it talks to are built here.
+cd "${REPO_ROOT}/sdk/js"
+npm ci --no-audit --no-fund
+npm run build
+npm run examples
+node scripts/bundle-browser.mjs examples/browser/app.js build/browser-example
+
 cd "${REPO_ROOT}/tests/ui"
 npm ci --no-audit --no-fund
 # --with-deps needs root to install OS libraries; only CI runners allow that.