Skip to main content
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 entry must export an object carrying fetch, not the handler itself. mountAgents returns a fetch handler, so it goes on the fetch property of the module’s default export. A bare-function default export — export default mountAgents(map) — is refused by the function runtime at load time, and the failure is a bundle that never starts rather than a request that 500s.

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 towss://my-func.telnyxcompute.com/agents/conversation/alice is this default base, this mount key, and this name:
A hand-written front door can serve any shape it likes; 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.
SSE does not stream on deployed functions today. The edge gateway buffers a response until it completes, so an open-ended text/event-stream never reaches the client — an EventSource against a deployed mount connects and then receives nothing. The WebSocket and RPC paths are unaffected, and SSE works normally in local development. This is a platform-side gap tracked as COMPUTE-819; see AgentHttpServer for the full caveat. Use the WebSocket path in production — same address, same policy.

Two gates, not one

The mount’s authorize does not replace the agent’s own authorize(token, req). It composes with it, and both run:
  1. At the edge, before any actor wakesauthorize(req, route) sees the inbound request and what the mount resolved from it (mount, name, actorId, transport, and for RPC the method). Return a Response to 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.
  2. Inside the agent — the caller’s credential rides through untouched (?token=, or an Authorization header), so Agent.authorize resolves this connection’s claims from it exactly as it would unmounted, and onAttach may still veto.
Stamp identity at the mount; decide grants in the agent.
A stamped header always wins: it overwrites a header of the same name the caller sent, so a client cannot forge an identity the agent trusts. Headers you do not stamp are forwarded as the caller sent them — trust only what you stamp. The Mounting Agents guide walks the whole path, including composing the mount with routes of your own and the cross-origin opt-in.

mountAgents()

mountAgents<E>(map, options?): (req, env) => Promise<Response>
Route every agent in one call: 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/liveness and /health/readiness200 ok, with no actor woken and nothing about what is mounted in the body (see health).
  • A path outside basefallback, or 404.
  • 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, a GET on an RPC address, an upgrade to an RPC address) → 405 with an Allow header. No actor is woken for any of them.
Refusals from the mount carry a JSON body: \{"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 authorize stamps 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.
The map is read once per request and never cached, so a binding is resolved from the 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<E> = (env) => Readonly<Record<string, ActorNamespace | undefined>>
The agents this function serves, keyed by the mount name that addresses them. Written as a function of env so the bindings resolve per request:
The map is the entire routing table — the mount discovers nothing on its own and derives no names from class names, so an agent is reachable only once it is written here. Type Parameters Parameters Returns Readonly<Record<string, ActorNamespace | undefined>>

MountOptions

Options for mountAgents. Type Parameters Properties authorize?
optional authorize?: (req, route) => MountAuthorizeResult | Promise<MountAuthorizeResult>
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 a Response to reject (nothing is forwarded and no actor is woken), a MountHeadersInit to admit and stamp, or nothing to admit as-is.
This is edge authentication, and it composes with the agent’s own 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?
optional base?: string
The path prefix the mount owns. Every address is <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?
optional cors?: MountCorsOptions
Opt in to cross-origin access, for a browser page served from another origin (an 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?
optional fallback?: (req, env) => Response | Promise<Response>
What to do with a request whose path is not under 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.
Parameters Returns Response | Promise<Response> Default Value a 404 — the mount answers for the whole function.
health?
optional health?: boolean
Answer /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 to authorize so the policy can decide with the routing already done. Properties actorId
readonly actorId: string
The actor id this name resolves to — encodeAgentName(name).
method?
readonly optional method?: string
The method name, for a rpc request only.
mount
readonly mount: string
The mount key from the address — a key of the map you returned.
name
readonly name: string
The routing name from the address, decoded ("+15550100", not "%2B15550100").
transport
readonly transport: MountTransport
The transport the request’s shape selected.

MountCorsOptions

Cross-origin passthrough for MountOptions.cors — opt-in, never a default. Properties credentials?
optional credentials?: boolean
Allow credentialed requests (cookies, TLS client certs). Requires a concrete origin, never "*". Default Value false
headers?
optional headers?: readonly string[]
Request headers a cross-origin caller may send, beyond the CORS-safelisted ones. Default Value ["authorization", "content-type", "last-event-id"] — what the RPC and SSE addresses actually read.
maxAge?
optional maxAge?: number
How long (seconds) a browser may cache the preflight answer. Default Value

origin
origin: string | readonly string[]
The origins allowed, matched exactly. "*" allows any origin and is refused in combination with credentials, which browsers reject anyway.

MountAuthorizeResult

MountAuthorizeResult = Response | MountHeadersInit | void
What 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 = Headers | Record<string, string> | [string, string][]
The headers 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 = "websocket" | "sse" | "rpc"
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 …/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(name): string
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, -, _, 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 scheme Each run of characters outside the addressable set becomes : + 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:
An all-ASCII name (letters, digits, -, _, .) 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(id): string
Recover the routing name an actor id was encoded from — the exact inverse of encodeAgentName. 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.
It is strict on purpose: an id encodeAgentName could not have produced is refused rather than half-read. That is what makes it safe to point at an id of unknown provenance — an id from an older encoding, or one a caller hand-assembled, is reported instead of being passed off as a name it never was. Parameters 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

const MAX_AGENT_NAME_LENGTH: 225 = MAX_ESCAPED_ACTOR_ID_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, -, _, 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.