mountAgents (SDK ≥ 0.12.0) is the front door for a function that serves agents.
One call replaces the routing file you would otherwise hand-write — URL parsing,
upgrade detection, credential extraction, name encoding, the hand-off to a namespace,
health routes — and leaves your function holding only the policy that is actually
yours. It ships at @telnyx/edge-runtime/mount.
The address, and what selects the transport
Every agent you mount answers on the same shape,<base>/<mount>/<name>, where
<mount> is a key of your map and <name> is the routing name of one instance. The
request picks the transport; the path never changes:
base defaults to /agents, so the map above serves
/agents/conversation/alice on all three. That is the URL the
AgentClient examples connect to —
wss://my-func.telnyxcompute.com/agents/conversation/alice is this default base,
this mount key, and this name:
mountAgents is what makes
this shape route. The upgrade is handed to the agent’s webSocket; GET and POST
are handed to the agent’s fetch, where an
AgentHttpServer over the agent’s
connection engine answers them. The forwarded request keeps its URL and query string,
so subscribe, Last-Event-ID resume, and the /rpc/<method> segment all arrive as
the caller wrote them.
Two gates, not one
The mount’sauthorize does not replace the agent’s own
authorize(token, req). It composes with it, and both run:
- At the edge, before any actor wakes —
authorize(req, route)sees the inbound request and what the mount resolved from it (mount,name,actorId,transport, and for RPC themethod). Return aResponseto reject — nothing is forwarded and no actor is woken — or return headers to stamp identity onto the request, or nothing to admit as-is. It runs identically on all three transports, so no request shape reaches an agent around it. - Inside the agent — the caller’s credential rides through untouched (
?token=, or anAuthorizationheader), soAgent.authorizeresolves this connection’s claims from it exactly as it would unmounted, andonAttachmay still veto.
mountAgents()
mountAgents<Route every agent in one call:E>(map,options?): (req,env) =>Promise<Response>
mountAgents returns the fetch handler an
umbrella function exports, so the front door you would otherwise hand-write
— URL parsing, upgrade checks, credential extraction, name encoding, the
hand-off, health routes — is already written, and the only thing left in
your function is the policy that is actually yours.
mountAgents returns the handler, not the module’s default export: a
function entry must export an OBJECT carrying a fetch handler, so the
result goes on the fetch property. Exporting the returned function
directly is refused at load time.
One address, every transport
Callers get one shape, identical across every agent you mount, and the
request selects the transport — the path never changes:
<base> defaults to /agents, so a support mount serves
/agents/support/alice on all three. GET and POST are handed to the agent’s
own fetch, where an AgentHttpServer over the agent’s connection engine
answers them; the upgrade is handed to the agent’s webSocket. The
forwarded request keeps its URL and query string, so the SSE subscribe
filter, Last-Event-ID resume, and the /rpc/<method> segment all arrive
exactly as the caller wrote them.
Names
The name segment is decoded and then re-encoded to an actor id with
encodeAgentName, so names that are natural for
customers — +15550100, an email address, a composite key, a name with
accents or emoji — address a persistent instance without the caller thinking
about encoding.
An actor id is not free-form: only ASCII letters, digits, -, _, and
. are addressable, and an id built from anything else belongs to an
instance that never activates. encodeAgentName is what stands between a
natural name and that constraint — it maps every other character onto the
addressable set reversibly, so no two names share an id. Names that are
already addressable pass through byte-for-byte, so mounting an existing
agent does not re-point it. A hand-written front door addressing the same
instances should call encodeAgentName for the identical result.
That mapping is part of an actor’s identity and does not change. The id
a name first resolves to is where that instance’s state and timers live;
re-deriving the rule by hand, or expecting it to change, re-points a name at
a different and empty instance. Letter case is significant throughout —
alice and Alice are two instances.
A name that has no faithful id — one that is empty, one whose escaped id
would be longer than MAX_ESCAPED_ACTOR_ID_LENGTH characters, or
malformed Unicode — is refused with a 400 naming the problem, on the
request that carried it. Nothing is addressed and no actor is woken. How long
a name may be depends on the characters in it, since escaping expands them;
see MAX_ESCAPED_ACTOR_ID_LENGTH.
The map is the whole routing table
Only what your map returns is reachable. There is no discovery and no
derivation from class names: an unknown mount key is a 404 and no binding
is consulted for it.
What it answers itself
/health/livenessand/health/readiness→200 ok, with no actor woken and nothing about what is mounted in the body (seehealth).- A path outside
base→fallback, or404. - An address the mount owns but cannot resolve — an unknown mount key, a
malformed address, an empty name →
404, one body for all of them. - A name that will not decode, one whose actor id would exceed
MAX_ESCAPED_ACTOR_ID_LENGTH characters once escaped, or one with no
faithful actor id →
400, with a message naming the problem. - A request whose shape matches no transport (a
PUT, aGETon an RPC address, an upgrade to an RPC address) →405with anAllowheader. No actor is woken for any of them.
\{"error": "not_found" | "method_not_allowed" | "malformed_name", "message": "…"\}. Answers from the
agent — including its own errors — are relayed unchanged.
What it does and does not decide
The mount adds no policy of its own. It routes, and it runs the one
authorize callback you give it — which
runs identically on every transport, so no request shape reaches an agent
without passing it. Everything past that is the agent’s: its
authorize(token, req) resolves claims, its onAttach may still veto, and
a mounted agent that overrides no connection seam still refuses sockets
exactly as it would unmounted.
Two things worth knowing before you trust a header inside an agent:
- Headers
authorizestamps overwrite what the caller sent under the same name, so a stamped identity cannot be forged. - Headers it does not stamp are forwarded as the caller sent them. Trust only what you stamp.
env of the request that uses it and nothing is held between them.
Mount names are ordinary path segments, not secrets: they are part of the
address every legitimate client already knows.
Type Parameters
Parameters
Returns
The handler to expose as your function entry’s
fetch.
(req, env) => Promise<Response>
AgentMountMap
AgentMountMap<The agents this function serves, keyed by the mount name that addresses them. Written as a function ofE> = (env) =>Readonly<Record<string,ActorNamespace|undefined>>
env so the bindings resolve per request:
Parameters
Returns
Readonly<Record<string, ActorNamespace | undefined>>
MountOptions
Options for mountAgents. Type Parameters
Properties
authorize?
Your one line of policy: decide whether this request may reach the agent, and what identity to stamp on it. Runs after the address resolves and BEFORE anything touches an actor, on every transport — a WebSocket upgrade, an SSE stream, and each RPC POST alike. Return aoptionalauthorize?: (req,route) =>MountAuthorizeResult|Promise<MountAuthorizeResult>
Response to reject (nothing is forwarded and no actor is
woken), a MountHeadersInit to admit and stamp, or nothing to
admit as-is.
authorize(token, req) seam rather than replacing it: the stamped
headers are added to the forwarded request, the caller’s
credential (?token=, or an Authorization header) rides through
untouched, and the agent still resolves its own claims from both. Stamp
identity here; decide grants there.
A stamped header always wins: it overwrites any header of the same name
the caller sent, so a client cannot forge an identity your agent trusts.
Headers you do NOT stamp are forwarded as the caller sent them — trust
only the ones you stamp.
A throw from this callback is not an admission: it propagates, and the
request never reaches an actor.
Reading the body here is safe — verify a signature over it, or check an
RPC’s argument shape. The agent is handed an independent copy, so a body
consumed here still arrives there intact. That copy is made only for a
request that carries a body (never for a WebSocket upgrade or a GET), and
it does mean the body is buffered while both copies are outstanding: on a
large streamed upload, prefer deciding from the headers.
Parameters
Returns
MountAuthorizeResult | Promise<MountAuthorizeResult>
base?
The path prefix the mount owns. Every address isoptionalbase?:string
<base>/<mount>/<name>. A leading slash is optional and
a trailing one is ignored, so "agents", "/agents", and "/agents/"
are the same base. Use "/" to mount at the root.
Default Value
"/agents"
cors?
Opt in to cross-origin access, for a browser page served from another origin (anoptionalcors?:MountCorsOptions
EventSource on the SSE address, a fetch on the RPC one).
Absent — the default — the mount sends no CORS headers at all and answers
OPTIONS with 405, so a cross-origin page cannot reach it. This is a
passthrough and nothing more: it does not authenticate, and it never
substitutes for authorize.
fallback?
What to do with a request whose path is not underoptionalfallback?: (req,env) =>Response|Promise<Response>
base.
This is how the mount composes inside a hand-written fetch: the paths
you own fall through to your handler, the agent addresses do not.
Returns
Response | Promise<Response>
Default Value
a 404 — the mount answers for the whole function.
health?
Answeroptionalhealth?:boolean
/health/liveness and /health/readiness with 200 ok.
These are answered by the mount itself — no actor is addressed, no
authorize runs, and the body carries nothing about what is mounted.
Set false when the surrounding function owns its own health routes.
Default Value
true
MountRoute
What the mount resolved from the request, handed toauthorize so the policy can decide with
the routing already done.
Properties
actorId
The actor id this name resolves to —readonlyactorId:string
encodeAgentName(name).
method?
The method name, for areadonlyoptionalmethod?:string
rpc request only.
mount
The mount key from the address — a key of the map you returned.readonlymount:string
name
The routing name from the address, decoded (readonlyname:string
"+15550100", not "%2B15550100").
transport
The transport the request’s shape selected.readonlytransport:MountTransport
MountCorsOptions
Cross-origin passthrough for MountOptions.cors — opt-in, never a default. Properties credentials?Allow credentialed requests (cookies, TLS client certs). Requires a concreteoptionalcredentials?:boolean
origin, never "*".
Default Value
false
headers?
Request headers a cross-origin caller may send, beyond the CORS-safelisted ones. Default Valueoptionalheaders?: readonlystring[]
["authorization", "content-type", "last-event-id"] — what
the RPC and SSE addresses actually read.
maxAge?
How long (seconds) a browser may cache the preflight answer. Default ValueoptionalmaxAge?:number
origin
origin:The origins allowed, matched exactly.string| readonlystring[]
"*" allows any origin and is
refused in combination with credentials, which browsers reject anyway.
MountAuthorizeResult
MountAuthorizeResult =WhatResponse|MountHeadersInit|void
authorize may answer:
- a
Response— reject; no actor is woken and nothing is forwarded; - a MountHeadersInit — admit, stamping those headers onto the forwarded request (each one overwrites any same-named header the caller sent);
- nothing — admit, stamping nothing.
MountHeadersInit
MountHeadersInit =The headersHeaders|Record<string,string> | [string,string][]
authorize may stamp — the
same shapes the Headers constructor accepts, so the ergonomic literal is
the common case and a built Headers works when a policy needs one:
MountTransport
MountTransport =The transport a request selected, as resolved from its shape (never from the path): a WebSocket upgrade, a GET for Server-Sent Events, or a POST to"websocket"|"sse"|"rpc"
…/rpc/<method>.
Names
A routing name is what a caller writes in the address; an actor id is what the platform files an instance’s state and timers under, and only ASCII letters, digits,-, _, and . are addressable. mountAgents runs every name it resolves through
encodeAgentName, so natural names — a phone number, an email address, a composite
key, a name with accents or emoji — reach a persistent instance without the caller
thinking about encoding. Call the same function from any front door you write by hand
that must address the same instances; re-deriving the rule points a name at a
different, empty instance.
The ceiling is on the id once URL-escaped, not on the name — see
MAX_ESCAPED_ACTOR_ID_LENGTH on
the Limits page for what each kind of character costs (225 ASCII, 54 two-byte, 36
three-byte, 27 emoji, plus 6 per escaped run) and why an over-long id fails only after
an eviction rather than on first use.
encodeAgentName()
encodeAgentName(Encode a routing name into the actor id that addresses its instance. An actor id is not free-form. It is a path segment on every call to the instance, it is folded into the persistent records the instance owns, and it names the instance’s timers — and only ASCII letters, digits,name):string
-, _, and
. survive all three. An id built from anything else is not merely ugly: it
cannot be addressed, and the instance never activates.
Names customers actually route on are not confined to that set — a phone
number (+15550100), an email address, a composite key, a name with accents
or emoji. This encoder maps any such name onto the addressable set, and back
again:
: + the
lowercase hex of that run’s UTF-8 bytes + :. Characters inside the set are
written through untouched. : itself is never written through — it is
always escaped (: → :3a:) — which is what makes an escape impossible to
confuse with an ordinary name and the encoding reversible by
decodeAgentName.
Two names therefore never share an id: the id says exactly which characters
were escaped, so it decodes back to one name and one name only.
How long a name may be
The limit is on the ID once URL-escaped, not on the name: an actor id may
occupy at most MAX_ESCAPED_ACTOR_ID_LENGTH characters escaped, and a
name whose id would be longer is refused here rather than handed back as an
id the platform cannot keep an instance under.
So how many characters of name fit depends on which characters they are,
because an escape is longer than what it replaces — two hex digits per UTF-8
byte, plus the two : bracketing each run, which cost three apiece once
URL-escaped:
-, _, .) gets the full 225, since
it passes through unchanged and has nothing to escape. Everything else costs
more: 4 characters per accented Latin letter (54 of them fit), 6 per
three-byte character such as ☕ or most CJK (36 fit), and 8 per emoji or
other astral character (27 fit), with a further 6 for each separate run of
them. Budget for the characters your names actually use, use
escapedActorIdLength when you need the exact number, and prefer
catching the throw over assuming a name fits.
This encoding is part of an actor’s identity, and is frozen
The id an instance is first addressed by is the id its persistent state, its
timers, and every later request are filed under. Changing how a name
encodes re-points that name at a different, empty instance and orphans
everything the old one held. Treat the mapping as fixed for the lifetime
of the deployment: the same name must always yield the same id. It will not
be changed under you, and you should not re-derive it — call this function
instead.
That is also why mountAgents applies it to every name it resolves: a
mounted agent needs no encoding code, and a hand-written front door that
addresses the SAME instances stays in step by calling this rather than
writing the rule out again.
Names already inside the addressable set pass through byte-for-byte, so
adopting the encoder never re-points an instance that was already reachable.
Two things to get right
Takes the RAW name, exactly once. Encoding an already-encoded name
escapes its : signs (":2b:15550100" → ":3a:2b:3a:15550100") and
addresses a different instance — pass the name as the caller wrote it, not a
value read out of a URL path without decoding it first.
Letter case is significant: Alice and alice are different names and
different instances. Lowercase your names before you route on them if you
mean them to be the same.
Parameters
Returns
string
The actor id for that name, built only from ASCII letters, digits,
-, _, ., and :.
Throws
TypeError when the name has no faithful id and encoding it would
hand back one that cannot be addressed: an empty name, one whose id would
occupy more than MAX_ESCAPED_ACTOR_ID_LENGTH characters once escaped
(which a name of more than that many characters always does), or one
containing an unpaired surrogate. The message names the offending input and
the limit it missed.
decodeAgentName()
decodeAgentName(Recover the routing name an actor id was encoded from — the exact inverse of encodeAgentName.id):string
decodeAgentName(encodeAgentName(name)) is name, for every name
encodeAgentName accepts. Use it to render an id back as something a person
reads: a log line, an admin listing, a dashboard row.
Returns
string
The routing name it was built from.
Throws
TypeError when id is not something encodeAgentName could
have produced: an empty id, a character outside the addressable set sitting
unescaped, a . or .. (which the encoder always escapes), two escapes in
a row, an unterminated escape, an escape that is not whole bytes of hex, or
hex that is not valid UTF-8.
MAX_AGENT_NAME_LENGTH
The longest routing name the mount will address, in characters of the decoded (pre-encoding) name. This is the ceiling a name can have under MAX_ESCAPED_ACTOR_ID_LENGTH, not a second rule: every character of a name costs at least one character of the escaped actor id it encodes to, so a name longer than this cannot possibly have an id within the limit and is refused without encoding it. A name at or under it is not thereby accepted — one character of a name can cost eight or more, so MAX_ESCAPED_ACTOR_ID_LENGTH is what actually decides, and only a name spelled entirely from ASCII letters, digits,constMAX_AGENT_NAME_LENGTH:225=MAX_ESCAPED_ACTOR_ID_LENGTH
-, _, and . gets the whole
length. See MAX_ESCAPED_ACTOR_ID_LENGTH for what other characters
cost.
A name over this length answers 400 malformed_name.