Exposing mesh services to external callers
Sometimes an agent, MCP server, or OpenAI-compatible inference endpoint on an
enrolled agentmesh-node needs to be reached by an HTTP client that is not on
the mesh: a hosted assistant, a chat bridge, a browser client, or a paired CLI
such as a2a-serve.
Agent Mesh handles external HTTP ingress at agentmesh-router (and on the
single port of agentmesh-one), so agentmesh-control-plane never carries
data-plane traffic and the serving laptop or sandbox needs no inbound firewall
ports, subdomains, wildcard DNS, or extra TLS certificates.
Every exposed service is addressed at one deterministic path:
<public-url>/ingress/<peer_id>/<service_type>/<service_name>[/<subpath>]
For example:
https://bananas.sam-mesh.dev/ingress/12D3KooW.../a2a/coder
Ingress is for callers without a sidecar
An agent that is already on the mesh does not use /ingress/. It calls a peer
through its own agentmesh-node sidecar
(http://127.0.0.1:<sidecar>/mesh/<peer_id>/a2a/coder), which dials the
serving node directly over libp2p and only crosses a router’s relay when NAT
forces it. The sidecar never advertises the ingress URL, and an A2A agent card
fetched through the sidecar points back at the sidecar, so a client that
starts inside the mesh stays inside it.
The ingress URL is not a second route for that agent, and it will not accept
the agent’s node credential: a standing member Biscuit is refused at
/ingress/, which takes only a Delegated Session or task Biscuit. Choosing
the ingress URL from inside the mesh would mean minting a task Biscuit first
and then hairpinning through a router to reach a peer the sidecar could have
dialed directly.
Both paths evaluate the same policy on the serving node. The in-mesh verifier
and the ingress handler build their Datalog authorizer from one shared
assembly (identity.AuthorizeMeshRequest), so a service grant, HTTP
narrowing, target grant, TaskAuthorizationRule, or local attenuation that
denies a request on one path denies it on the other. Ingress adds two
constraints on top (the admin’s allowed_ingress ceiling and the service’s
allow list) and drops one that only a libp2p stream can satisfy (the
client_peer_id connection binding; see the security architecture, section
5.2). A caller can never gain access by picking the path.
Two-layer opt-in
Nothing on the mesh is reachable from /ingress/ unless both the mesh
administrator and the node owner opt in:
- Admin ceiling (
PolicyRole.allowed_ingress): the node’s mesh role must grant external exposure for that service pattern (a2a://coder,mcp://*, or*). Omittingallowed_auth_modespermits only the zero-trust default (INGRESS_AUTH_MODE_EDGE).INGRESS_AUTH_MODE_PASSTHROUGHis refused unless a role explicitly lists it. - Node service config (
services[].ingress): the node’sagentmesh-node.yamlmust include aningress:block on thata2a,mcp, orinferenceservice. Services withoutingress:always return404on/ingress/.
What the admin writes
In agentmesh-one development mode (no --policy-file), mesh:role:node
grants INGRESS_AUTH_MODE_EDGE on * out of the box so zero-trust external
ingress works with zero configuration. INGRESS_AUTH_MODE_PASSTHROUGH is never
granted by default, even in agentmesh-one: pass --policy-file or update the
policy via POST /policies to enable passthrough for a role.
On a production mesh, grant allowed_ingress on the serving node’s role and
bind external callers to a role whose allowed_services and allowed_targets
permit calling the service:
{
"roles": [
{
"name": "laptop-agent",
"allowed_publish": ["a2a://coder", "mcp://git"],
"allowed_ingress": [
{ "services": ["a2a://coder", "mcp://git"] }
]
},
{
"name": "paired-agent",
"allowed_publish": ["a2a://paired"],
"allowed_ingress": [
{
"services": ["a2a://paired"],
"allowed_auth_modes": [
"INGRESS_AUTH_MODE_EDGE",
"INGRESS_AUTH_MODE_PASSTHROUGH"
]
}
]
},
{
"name": "external-caller",
"allowed_services": ["a2a://coder", "mcp://git"],
"allowed_targets": ["*"]
}
],
"bindings": [
{ "role": "laptop-agent", "members": ["email:alice@example.com"] },
{ "role": "external-caller", "members": ["email:*@example.com"] }
]
}
What the node owner writes
Add ingress: to any a2a, mcp, or inference entry in agentmesh-node.yaml:
version: "v1alpha1"
services:
# Zero-trust default (auth_mode: edge): router + node verify a Delegated
# Session Biscuit and evaluate Datalog RBAC, TAR attenuation, and allow list.
- type: a2a
name: coder
target_url: "http://127.0.0.1:9001"
ingress:
allow:
- "email:alice@example.com"
- "group:teammates"
# Opt-in passthrough: forwards Authorization untouched to a backend that
# enforces its own application-layer pairing token (requires PASSTHROUGH in
# the node's PolicyRole.allowed_ingress).
- type: a2a
name: paired
target_url: "http://127.0.0.1:9002"
ingress:
auth_mode: passthrough
When agentmesh-node starts and announces the service, it logs the public URL
from the mesh’s router lease and exposes it in agentmesh-node debug services:
[Ingress] Published a2a://coder at https://bananas.sam-mesh.dev/ingress/12D3KooW.../a2a/coder (auth_mode=edge)
If the node’s mesh role does not grant external exposure for that service or
auth mode, agentmesh-node logs an error, increments
agentmesh_node_service_provide_failures_total{scope="ingress"}, and falls back
to announcing the service internally so mesh peers can still reach it.
From Go (sdk/meshnet)
A single-binary Go agent using the Embedded Go SDK opts into the
same router ingress without agentmesh-node:
ep, err := srv.ListenA2A(ctx, meshnet.A2AOptions{
Name: "coder",
Description: "Repository coding agent",
Public: true,
IngressAuth: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE,
IngressAllow: []string{"email:alice@example.com", "group:teammates"},
})
ep.PublicListener delivers verified external requests in-process (ep.PublicURL
holds <router_ingress_url>/<peer_id>/a2a/coder), while ep.PrivateListener
serves internal mesh peers over /libp2p-http.
How edge and passthrough behave
auth_mode: edge (default)
- Unauthenticated requests (and requests carrying a raw platform JWT) receive
401 Unauthorizedwith an RFC 9728 header:WWW-Authenticate: Bearer resource_metadata="<public-url>/ingress/.well-known/oauth-protected-resource". - External callers present
Authorization: Bearer <biscuit>, where<biscuit>is a Delegated Session Biscuit minted by the control plane’s OAuth 2.1 Authorization Server (/oauth/authorize+/oauth/token) or RFC 8693 token exchange (POST /oauth/tokenorPOST /token/exchange), optionally attenuated with aTaskAuthorizationRule. Callers holding an OIDC JWT exchange it once atPOST /oauth/tokeninto a Delegated Session Biscuit soagentmesh-routerstays purely on Ed25519 Biscuit verification with zero IdP JWKS network dependencies.mesh:role:routerBiscuits and unattenuated standing node Biscuits (node(...)without atar_block) are rejected. - The router verifies the Biscuit signature, authority revocation ID, banned
peer status,
TaskAuthorizationRulechain (including MCPtools/calltool names), andServiceIngress.allowlocally before dialing any provider node, and checks that the provider’s own credential grantsallowed_ingressfor the service and auth mode under the current mesh policy. Once admitted, both the router and the serving node (defense in depth over/mesh/ingress/1.0.0) enforce the full intersection of mesh Datalog RBAC,TaskAuthorizationRule,ServiceIngress.allow, and localagentmesh-node.yamlattenuation. The serving node repeats theallowed_ingresscheck against its own credential and the mesh policy it holds, so a router that forwards a service the node’s role does not grant gets403and adenyinagentmesh_node_external_ingress_requests_total; an authorizer that stops at a run limit before a verdict answers504(time) or500(facts or iterations), counted asundecided. The node serves/mesh/ingress/1.0.0only to a peer that authenticated asmesh:role:router: a stream from any other peer, authenticated or not, is reset at the libp2p gate before the HTTP server reads it and counted inagentmesh_node_streams_refused_total{scope="not_router"}. Request bodies are capped at 32 MiB on the router’s public listener and again on the node. - The external
Authorizationheader is stripped before reaching the local backend (replaced only iftarget_auth_pathis configured on the node). - For
a2aservices, unauthenticatedGETrequests to the service root or/.well-known/agent-card.jsonreturn the backend’s A2AAgentCardwithsupportedInterfaces[].urlrewritten to the public/ingress/...URL,signaturescleared, andsecuritySchemespopulated withbearer(bearerFormat: "Biscuit") andagentmesh_oauth2.
auth_mode: passthrough (opt-in)
For backends that run their own pairing or token handshake (for example
a2a-serve pair tokens), passthrough forwards the caller’s Authorization
header untouched over /mesh/ingress/1.0.0 while stripping Cookie and
X-Mesh-* headers and preserving the backend’s own AgentCard
securitySchemes.
Combining auth_mode: passthrough with target_auth_path or inline credentials
in target_url (http://user:pass@host) is rejected at config validation and
service registration so node-local static backend credentials can never be
injected on unauthenticated external passthrough routes.
Operator deployment
agentmesh-one (local, VM, or Cloud Run)
No extra flags are needed. agentmesh-one mounts /ingress/ directly onto its
embedded router on the single HTTP/WebSocket port:
agentmesh-one --external-url https://my-hub.run.app --data-dir /data
Multi-binary Kubernetes (agentmesh-router + Gateway)
- Start
agentmesh-routerwith an HTTP ingress listener behind your L7 TLSGateway:(Or terminate TLS directly on the router with--ingress-addr=0.0.0.0:8080 --ingress-url=https://hub.example.com/ingress --public-control-plane=https://hub.example.com --allow-insecure-ingress-http--ingress-tls-cert-pathand--ingress-tls-key-path.) - Route
PathPrefix: /ingress/on yourHTTPRoutedirectly to theagentmesh-routerHTTP ingressService(health check path:/ingress/healthz), keepingagentmesh-control-planeout of the data path. Because routers federate their directory and circuit relays, any router pod can serve/ingress/<peer_id>/...regardless of which router pod holds the node’s relay reservation. - Authenticated
edgerequests are rate-limited per verified Biscuit identity at the router. Because routers behind an L7Gatewaysee the proxy’s internal source IP on TCPRemoteAddr, configure per-client source-IP rate limiting at your L7Gateway(for example Cloud Armor on GKE) to protect unauthenticatedAgentCarddiscovery andpassthroughroutes. - With the Helm chart (
charts/agentmesh),router.ingressPort(8090by default) andgateway.ingressRoute: trueconfigure both the router listener and theHTTPRouteautomatically whengateway.enabled: true. Setrouter.ingressUrl(e.g.https://hub.example.com/ingress),router.publicControlPlaneUrl(e.g.https://hub.example.com), andbootstrap.nodeIngressto advertise the public ingress base URL and seed the initial role ceiling.