# Telnyx Agent SDK — Full Documentation > Build and run stateful AI agents with scheduling, message history, SQL, and WebSockets. Complete page content for the Agent SDK section of the Telnyx developer docs (https://developers.telnyx.com). > This file: https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-llms-full-txt · Root index: https://developers.telnyx.com/llms.txt ## Subsections Focused per-subsection full-content files: - [Agent SDK (Beta)](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-agent-sdk-beta-llms-full-txt) ## Agent SDK (Beta) ### Overview > Source: https://developers.telnyx.com/docs/agent-sdk.md The Agent SDK spans server and client. The server side brings together the **Agent Runtime**, **Harness Core**, **AI Adapter**, **Tools Adapter**, and **Comms Adapter**. The client side adds the **Client SDK**. **Beta:** The Agent SDK is taking shape in stages. Start today with the available **Agent Runtime** capabilities as we expand toward the full server-and-client experience shown below. Explore the [Runtime API](/docs/agent-sdk/api-reference). ## The Agent SDK at a Glance The Agent SDK has three coordinated surfaces: 1. **Agent harness (server):** four components work together: - A generic **Harness Core** owns the thinking loop, tool calls, and remember/recall cycle found in agent frameworks. - The **AI Adapter** connects generic model calls to Telnyx Inference. - The **Tools Adapter** connects generic tool calls to capabilities such as search, browsing, RAG, and MCP. - The **Comms Adapter** connects generic events and actions to the Telnyx communications suite. 2. **Agent runtime (server):** persistent identity, messages, state, schedules, queues, connections, RPC, and lifecycle. 3. **Client SDK (client):** an optional connection layer for browsers and applications to reconnect, observe state and messages, and invoke typed RPC methods. The Agent runtime spans the Telnyx Edge Compute and Storage foundation: `StatefulActor`, persistent storage and SQL, alarms, WebSockets, RPC, bindings, object storage, and CloudFS. The generic Harness Core uses the runtime for persistent memory. The three adapters connect that core to Telnyx Inference, tools, and the communications suite without becoming new runtime layers. The separation is deliberate: - **Agent Runtime** simplifies connections, state persistence, and other infrastructure. - **Harness Core** decides what to do each turn—the standard loop in every agent framework. - **Adapters** connect the agent to Telnyx AI, tools, and communications suites. - **Client SDK** speaks the same protocol as the server agent. Together, these layers handle the plumbing so you can focus on your business-specific agent logic. ## Three Ways to Consume the Server Stack Choose how much of the stack you want to take on. Start from the primitives, runtime, or harness; the layers below remain available. Layer Way 1 Build from Primitives Maximum Control Way 2 Start from Runtime Bring Your Harness Way 3 Start from Harness Fastest Start Your Code You Own You Own You Own Agent Harness You Own You Own Telnyx Owns Agent Runtime You Own Telnyx Owns Telnyx Owns Shared Foundation Edge Compute + Storage Every Way | Layer | Way 1: Build from Primitives | Way 2: Start from Runtime | Way 3: Start from Harness | |---|---|---|---| | Your Code | **You Own** | **You Own** | **You Own** | | Agent Harness | **You Own** | **You Own** | **Telnyx Owns** | | Agent Runtime | **You Own** | **Telnyx Owns** | **Telnyx Owns** | **Shared Foundation:** Edge Compute + Storage ## Where to Go Next - [Quickstart](/docs/agent-sdk/quickstart) — build a working agent in one file - [How Agents Run](/docs/agent-sdk/concepts/how-agents-run) — understand the actor execution model - [Calling LLMs](/docs/agent-sdk/concepts/calling-llms) — compare harness wiring patterns - [API reference](/docs/agent-sdk/api-reference) — inspect every runtime method and override hook --- ### Quickstart > Source: https://developers.telnyx.com/docs/agent-sdk/quickstart.md The simplest agent receives a message, queues a background task to process it, then schedules a follow-up. ```ts import { Agent } from "@telnyx/edge-runtime"; export class SupportAgent extends Agent { // Called when a new message arrives (from your function's fetch handler) async receive(text: string, from: string): Promise { await this.setState({ from }); await this.messages.add("user", text); // persisted to history await this.queue("process"); // persistent background task } // Runs asynchronously — webhook acks immediately, LLM runs in the background async process(): Promise { const history = await this.messages.toLangChain(); // or toOpenAI(), toAnthropic() // ... call your LLM here ... const reply = "Hello! How can I help?"; await this.messages.add("assistant", reply); await this.setState({ lastReply: reply, at: Date.now() }); await this.schedule(86_400, "nudge", null, { id: "nudge" }); // follow up in 24h } async nudge(): Promise { const last = await this.messages.last(); if (last?.role !== "assistant") return; // customer replied — skip // customer hasn't replied — send a follow-up } } // The function that routes inbound webhooks to the right actor export default { async fetch(req: Request, env: Env): Promise { const { text, from } = await req.json() as { text: string; from: string }; const actorName = from.replace(/^\+/, ""); // strip leading + for actor ID await env.SUPPORT.idFromName(actorName).receive(text, from); return new Response("ok"); }, }; ``` `Env` is a global type generated by `telnyx-edge types` from your `telnyx.toml` — it includes `SUPPORT: ActorNamespace` and any other bindings you declare. Run `telnyx-edge types` before `tsc` or `ship` to keep it in sync. **`telnyx.toml`:** ```toml name = "support-agent" main = "src/index.ts" compatibility_date = "2026-05-01" [[actors]] binding = "SUPPORT" type = "SupportAgent" ``` ## Deploy Generate binding types, then ship: ```bash telnyx-edge types # generates telnyx-env.d.ts from your telnyx.toml telnyx-edge ship # bundles, uploads, and deploys to the edge ``` Your function is live at `https://-.telnyxcompute.com` — see [edge compute configuration](/docs/edge-compute/configuration) for the full reference. ## Next steps - [Calling LLMs](/docs/agent-sdk/concepts/calling-llms) — wire in a real model, with or without a framework - [Message History](/docs/agent-sdk/message-history) — the `this.messages` API and framework adapters - [Scheduled Tasks](/docs/agent-sdk/scheduled-tasks) — `queue`, `schedule`, `every`, and retries - [Persistent State](/docs/agent-sdk/state) — `setState`, `getState`, and typed state - [Roll Your Own Agent](/docs/agent-sdk/examples/roll-your-own) / [LangGraph Agent](/docs/agent-sdk/examples/langgraph) — two complete examples --- ### How Agents Run > Source: https://developers.telnyx.com/docs/agent-sdk/concepts/how-agents-run.md An `Agent` is a [StatefulActor](/docs/edge-compute/stateful-actors) — understanding four properties of that execution model explains why the SDK looks the way it does. ## One actor per conversation You address an agent by name: `env.CONVOS.idFromName("15550001111")` always routes to the same actor instance, anywhere in the fleet. That makes the caller id (or user id, or session id) the unit of isolation — each conversation gets its own history, state, and timers, with no cross-talk and no shared database to partition. See [Addressing](/docs/edge-compute/stateful-actors/concepts/addressing). Actor IDs must contain only ASCII letters (`A`–`Z`, `a`–`z`), digits, `-`, `_`, `.`, `:`, and spaces. Phone numbers work as actor IDs when you strip the leading `+` — store the full E.164 number in actor state if you need it for outbound calls. ## Turns are serialized An actor runs **one method call at a time**. While `process()` is thinking, a second inbound message waits — it never interleaves. That is what makes the read-modify-write patterns in agent code safe without locks: `this.messages.add()` then `this.queue("process")` can't race another webhook for the same customer. See [Execution model](/docs/edge-compute/stateful-actors/concepts/execution-model). ## Think in the background Inbound RPC methods run under a **30-second wall-clock budget** — fine for `receive()`, tight for an LLM round-trip plus tool calls. That's why the quickstart acks the webhook and defers the thinking: ```ts async receive(text: string, from: string): Promise { await this.messages.add("user", text); await this.queue("process"); // returns immediately } ``` Queued and scheduled tasks fire inside the actor's `alarm()` handler, which has a larger budget — on the order of minutes — and [retries on failure](/docs/agent-sdk/scheduled-tasks). Put LLM calls, tool use, and outbound API work there. ## Everything persistent survives restarts History, state, and pending tasks live in the actor's persistent storage, not in memory. If the actor is evicted, crashes, or the pod restarts, a pending task's timer re-fires after recovery, and the scheduler re-arms to the earliest pending task whenever the actor wakes. A 24-hour follow-up scheduled today fires tomorrow no matter what happens in between. See [Lifecycle](/docs/edge-compute/stateful-actors/concepts/lifecycle). --- ### Message History > Source: https://developers.telnyx.com/docs/agent-sdk/message-history.md `this.messages` is a persistent, ordered log of `AgentMessage` objects kept in the actor's own storage — see [where this is stored](/docs/agent-sdk/state#where-this-is-stored). It is per-actor — each `idFromName("user-123")` gets its own independent history. ```ts // Append a message await this.messages.add("user", "What's my account balance?"); await this.messages.add("assistant", "Your balance is $42."); // Read history const all = await this.messages.all(); // all messages, oldest first const last = await this.messages.last(); // most recent message const recent = await this.messages.last(10); // last 10 messages ``` ## Framework adapters `this.messages` can convert history to the format expected by popular LLM SDKs: ```ts // LangChain / LangGraph const msgs = await this.messages.toLangChain(); await agent.invoke({ messages: msgs }); // OpenAI Chat Completions const msgs = await this.messages.toOpenAI(); await openai.chat.completions.create({ model: "gpt-4o", messages: msgs }); // Anthropic Messages const msgs = await this.messages.toAnthropic(); await anthropic.messages.create({ model: "claude-opus-4-5", max_tokens: 1024, messages: msgs }); ``` `toAnthropic()` omits `system` messages: the Anthropic Messages API takes the system prompt as a top-level `system` parameter, not as a conversation message. Pass it on the request yourself. The `toOpenAI()` payload feeds Telnyx Inference directly — `env.TELNYX.ai.openai.chat.createCompletion({ model, messages })` on the pre-authenticated [Telnyx API binding](/docs/edge-compute/telnyx-api), or any OpenAI-compatible endpoint over HTTP. See [Calling LLMs](/docs/agent-sdk/concepts/calling-llms) for both wiring patterns. ## Message shape ```ts interface AgentMessage { role: "system" | "user" | "assistant" | "tool"; content: string; name?: string; // tool name for role:"tool", speaker label otherwise toolCalls?: ToolCall[]; // present on assistant turns that request tools toolCallId?: string; // links a tool result back to a ToolCall } ``` --- ### Scheduled Tasks > Source: https://developers.telnyx.com/docs/agent-sdk/scheduled-tasks.md `this.queue()`, `this.schedule()`, and `this.every()` let you defer work persistently. Tasks survive pod restarts — the scheduler uses the actor's built-in alarm mechanism. A task's name must match a method on your class. ```ts // Run immediately (next alarm tick) await this.queue("process"); // Run after a delay (in seconds) await this.schedule(3600, "sendReminder"); // Run repeatedly (interval in seconds) await this.every(300, "checkStatus"); // With a stable id — re-scheduling replaces the prior task (dedup) await this.schedule(86_400, "nudge", null, { id: "daily-nudge" }); // Cancel a named task await this.cancelSchedule("daily-nudge"); // List pending tasks const tasks = await this.listSchedules(); ``` ## Task dispatch When a task fires, the platform calls the method by name on your actor instance: ```ts export class MyAgent extends Agent { async sendReminder(): Promise { // runs when the scheduled timer fires } // Fallback for tasks whose method name doesn't exist on the class protected override async onTask(name: string, payload: unknown): Promise { console.error(`Unknown task: ${name}`); } } ``` ## Retries Tasks retry up to 5 times by default on failure. Configure with `maxRetries`: ```ts await this.queue("process", data, { maxRetries: 3 }); ``` --- ### Persistent State > Source: https://developers.telnyx.com/docs/agent-sdk/state.md `this.setState()` merges a patch into the actor's current state. Use it for small structured values you want to read quickly — the current conversation state, user preferences, last-seen timestamp. ```ts export interface MyState extends Record { status: "idle" | "processing" | "waiting"; lastReply: string; at: number; } export class MyAgent extends Agent { // Default state for a new actor protected override initialState(): MyState { return { status: "idle", lastReply: "", at: 0 }; } async process(): Promise { await this.setState({ status: "processing" }); // ... do work ... await this.setState({ status: "idle", lastReply: "Done.", at: Date.now() }); } async report(): Promise { return this.getState(); } } ``` `setState` is a **recursive merge** (RFC 7396) — top-level keys you pass are merged in, nested objects are deep-merged, and `null` deletes a key. `replaceState` replaces the whole state object. State is one value, read whole. The moment data turns relational or queryable — orders, events, anything you'd filter or aggregate — put it in the actor's embedded [SQL database](/docs/agent-sdk/sql) instead. ## Where this is stored *(SDK ≥ 0.13.0)* An agent's four persistent stores — the state above, [message history](/docs/agent-sdk/message-history), [progress events](/docs/agent-sdk/api-reference/event-log), and [scheduled tasks](/docs/agent-sdk/scheduled-tasks) — are kept in a per-actor SQL database rather than in key/value entries. This is the SDK's own storage layer, separate from the [SQL database](/docs/agent-sdk/sql) you open yourself at `this.ctx.storage.sql`. Nothing in the API changed: the same calls, with the same semantics, read and write the same data. What changes is how they scale — reads no longer degrade as a log grows, so `messages.all()` and `events.read()` on a long-lived agent stay flat instead of getting slower every turn. An agent that already has data migrates itself, once, **the first time it writes** after picking up 0.13.0 — not on first activation, so an agent that is only read from stays where it is until something writes to it. There is nothing to run and no downtime. **Do not roll back across 0.13.0.** Once an agent has migrated, an SDK that predates the migration does not know to look in the SQL database and reads the actor as empty — its state, history, events, and schedules are all still stored, but no longer visible to your code. Upgrading is safe; downgrading is not. --- ### SQL Storage > Source: https://developers.telnyx.com/docs/agent-sdk/sql.md `Agent` extends [`StatefulActor`](/docs/edge-compute/stateful-actors), and every actor carries a private, persistent **SQLite database** at `this.ctx.storage.sql`. There is nothing to declare in `telnyx.toml` — the database is created on first use, and it works inside an agent exactly as it does on a plain actor. ## Which tier holds the data? An agent has three persistent tiers, all in the same actor: | Tier | API | Shape | Reach for it when | |---|---|---|---| | [State](/docs/agent-sdk/state) | `getState()` / `setState()` | one small value, merge-patched | current status, preferences, last-seen — read as a whole every turn | | [Message history](/docs/agent-sdk/message-history) | `this.messages` | append-only conversation log | what was said, in order, feeding the LLM | | **SQL** | `this.ctx.storage.sql` | tables, indexes, aggregates | anything you'd `WHERE`, `GROUP BY`, or index — orders, events, tool results | State is read and written as one value; history is read back in order. The moment you want *"the last five orders over $10"* — a lookup neither tier answers without a scan — put rows in SQL. ## Using it from an agent `exec()` is **synchronous** — the database is a local file in the actor's own process — with positional `?` binds and a cursor you drain with `toArray()`: ```ts import { Agent } from "@telnyx/edge-runtime"; export class Support extends Agent { async recordOrder(sku: string, cents: number): Promise { this.ctx.storage.sql.exec( `CREATE TABLE IF NOT EXISTS orders(sku TEXT, cents INTEGER, at INTEGER)`, ); this.ctx.storage.sql.exec( `INSERT INTO orders(sku, cents, at) VALUES (?, ?, ?)`, sku, cents, Date.now(), ); } // An answer the LLM can use as tool output — no scan, no external database async topSkus(): Promise> { return this.ctx.storage.sql .exec<{ sku: string; total: number }>( `SELECT sku, SUM(cents) AS total FROM orders GROUP BY sku ORDER BY total DESC LIMIT 5`, ) .toArray(); } } ``` Everything the actor surface guarantees applies unchanged: the database is private to this one agent instance — per conversation, if you key actors by conversation — reads always reflect your prior writes, and [serialized turns](/docs/agent-sdk/concepts/how-agents-run) mean no other call interleaves mid-write. Wrap related writes in `this.ctx.storage.transactionSync(() => { ... })` to commit them atomically. Patterns that come up in agents: - **Webhook dedup.** `INSERT` the event id into a table with a `UNIQUE` constraint before `queue()`-ing work; a thrown constraint violation means you already handled that event. - **Tool-call ledger.** Record each tool invocation and result as a row; answer "what did you do?" or audit questions with a query instead of replaying history. - **Searchable history.** `this.messages` is an ordered log, not an index. If the agent needs keyword lookup over past conversation, append each message to an SQL table too — in the same method that calls `messages.add()` — and query it with `LIKE` plus an index. ## Semantics and limits The full contract — cursor rules, multi-statement batches, `transactionSync`, binding types, and the limits (1 GB per actor database, 2 MiB per bound value, integer range) — is on the actor [SQL guide](/docs/edge-compute/stateful-actors/guides/storage/sql). It applies verbatim inside an `Agent`: the SDK reserves [`alarm()`](/docs/agent-sdk/api-reference/agent) for its scheduler, but all of `ctx.storage` stays yours. For data that more than one function or caller must query — shared across agents, or read from the CLI and REST API — use a standalone [SQL Database](/docs/edge-compute/sqldb) instead; the embedded database is strictly per-instance. --- ### WebSockets > Source: https://developers.telnyx.com/docs/agent-sdk/websockets.md An `Agent` can terminate WebSockets **today**. The capability comes from [`StatefulActor`](/docs/edge-compute/stateful-actors/websockets): define one method, `webSocket()`, and the agent owns the live socket — frames dispatch one at a time, serialized with RPCs and tasks like every other call, with history, state, and timers right there in the handler. Actor WebSocket support is in **beta** — the platform guide carries the full contract and current caveats. ## The shape A function terminates the handshake — authenticate once, pick the agent, hand off — and the agent handles every frame after that: ```ts import { Agent } from "@telnyx/edge-runtime"; import type { ActorNamespace } from "@telnyx/edge-runtime"; import type { WebSocket } from "ws"; // transitive dependency of @telnyx/edge-runtime export class Assistant extends Agent { // req carries whatever headers your front door stamped at the handshake async webSocket(ws: WebSocket, req: Request): Promise { ws.on("message", async (data, isBinary) => { if (isBinary) return; await this.messages.add("user", String(data)); await this.queue("respond"); // think in the background }); } async respond(): Promise { const history = await this.messages.toOpenAI(); // ... call your LLM here — see Calling LLMs ... const reply = "On it."; await this.messages.add("assistant", reply); // Fan out to every socket open on this agent this.ctx.broadcast(JSON.stringify({ role: "assistant", text: reply })); } } interface Env { ASSISTANT: ActorNamespace; } export default { async fetch(req: Request, env: Env): Promise { if (req.headers.get("Upgrade")?.toLowerCase() !== "websocket") { return new Response("expected websocket", { status: 426 }); } // Authenticate here — this runs once per connection. Stamp what you learn // as headers for the actor; the actor WebSockets guide shows the pattern. // ⚠️ Derive the actor name from a verified credential (JWT, session cookie), // not a caller-controlled query param. This example uses ?user= for brevity. const user = new URL(req.url).searchParams.get("user"); if (!user) return new Response(null, { status: 401 }); return env.ASSISTANT.idFromName(user).fetch(req); }, }; ``` That front door is hand-written, and it serves whatever shape you give it. To get the `/agents//` address the `AgentClient` examples use — with the upgrade check, credential extraction, name encoding, and health routes already written — mount the agent instead: see [Mounting Agents](/docs/agent-sdk/mounting-agents) and [`mountAgents`](/docs/agent-sdk/api-reference/mount). The frame handler follows the same rule as the webhook path in the [Quickstart](/docs/agent-sdk/quickstart): append to history, `queue()` the LLM turn, return. A message handler runs under the **30-second method budget**, and while it runs every other frame, RPC, and task on this agent waits — so `respond()` does the slow thinking in a task, then pushes the reply to every open socket with `this.ctx.broadcast()`. ## Pushing without an inbound frame Tasks run inside the agent, so a timer can push. Use the scheduler — **not** raw alarms, which the SDK [reserves](/docs/agent-sdk/api-reference/agent) for the task queue: ```ts async webSocket(ws: WebSocket, req: Request): Promise { // First socket arms the loop; the stable id makes re-arming an upsert await this.every(30, "tick", null, { id: "tick" }); } async tick(): Promise { if (this.ctx.count() === 0) { await this.cancelSchedule("tick"); // no listeners — stand down return; } this.ctx.broadcast(JSON.stringify({ type: "status", ...(await this.getState()) })); } ``` ## What to know before shipping - **Connections are capped at one hour today**, measured from the handshake, even on a socket actively exchanging frames — surfacing as an abnormal close (code `1006`). The cap may change while the feature is in beta. Reconnecting is the client's job: back off and reopen the same name. `idFromName` routes the new socket to the same agent, where history, state, and pending tasks all survived — only the socket is new. - **Sends aren't held for persistence.** `ws.send()` and `broadcast()` are immediate; a handler that sends and then throws leaves the client holding a frame about state that never committed. Frames that carry the message log's `seq` let a client detect gaps and re-derive on reconnect. - **Bound per-frame work.** Frames that arrive while a handler runs queue up to 256 events or 1 MiB; overflow closes the socket with `1013`. Batch chatty clients into fewer, larger frames. Close codes, reconnect strategy, and the full contract: [actor WebSockets](/docs/edge-compute/stateful-actors/websockets) and [Connection Lifecycle](/docs/edge-compute/stateful-actors/websockets/lifecycle). ## The agent connection layer This page is the platform surface, and it stays. On top of it *(SDK ≥ 0.10.0)*: `AgentClient` (`@telnyx/edge-runtime/client`), a browser SDK with automatic reconnect, state/message sync, and cursor-based resume, paired with `AgentSocketServer` (`@telnyx/edge-runtime/agent-socket`) on the agent side — including token authorization via an `authorize` hook and claim-gated RPC. Client-callable methods are opt-in via the [`@rpc()` decorator](/docs/agent-sdk/api-reference). Full reference pages for the connection layer are in progress. --- ### Mounting Agents > Source: https://developers.telnyx.com/docs/agent-sdk/mounting-agents.md An agent is only reachable through a function. Before `mountAgents`, that meant hand-writing the front door: parse the URL, check for an upgrade, pull the credential, encode the name, pick the binding, call `idFromName(...).fetch(req)`, and answer health checks — the same file in every project, subtly different each time. `mountAgents` *(SDK ≥ 0.12.0)* is that file, already written. You supply the routing table and your policy; it supplies everything else. ```ts import { mountAgents } from "@telnyx/edge-runtime/mount"; import type { ActorNamespace } from "@telnyx/edge-runtime"; interface Env { CONVERSATION: ActorNamespace; BILLING: ActorNamespace; } export default { fetch: mountAgents((env) => ({ conversation: env.CONVERSATION, billing: env.BILLING, })), }; ``` **`fetch:` is not optional.** `mountAgents` returns a handler, and a function entry must default-export an **object** carrying that handler on `fetch`. Writing `export default mountAgents(map)` produces a bundle the runtime refuses at load time — the deploy does not start, so there is no request to debug. The map is the entire routing table. Nothing is discovered, and no name is derived from a class name: an agent is reachable once it is written there, and an unknown mount key is a `404` with no binding consulted. It is a function of `env`, so bindings resolve per request and nothing is cached between them. ## One address, three transports Each mounted agent answers on `//`: - `` is the path prefix the mount owns — `/agents` unless you set [`base`](/docs/agent-sdk/api-reference/mount#mountoptions). - `` is a key of the map above. - `` is the routing name of one instance — one agent, one persistent identity. So the function above serves `/agents/conversation/alice` and `/agents/billing/acct-1004`. **The shape of the request selects the transport, not the path.** The same address answers all three: | The client sends | Address | It gets | |---|---|---| | `Upgrade: websocket` | `/agents/conversation/alice` | the agent socket protocol | | `GET` | `/agents/conversation/alice?subscribe=state` | Server-Sent Events | | `POST` | `/agents/conversation/alice/rpc/humanReply` | one RPC call | This is why the [AgentClient](/docs/agent-sdk/api-reference/agent-client) reference connects to `wss://my-func.telnyxcompute.com/agents/conversation/alice` — that URL is the default `base`, the `conversation` mount key, and the name `alice`. A browser talking to the function above needs no route of its own: ```ts import { AgentClient } from "@telnyx/edge-runtime/client"; const agent = new AgentClient( "wss://my-func.telnyxcompute.com/agents/conversation/alice", { token: sessionToken, subscribe: ["state", "messages"], resume: true }, ); agent.onState(render); await agent.stub.humanReply("On it."); ``` An RPC call from a page with no SDK at all is the same address with a method on the end: ```bash curl -X POST \ -H "Authorization: Bearer $TOKEN" \ -H "content-type: application/json" \ -d '["On it."]' \ https://my-func.telnyxcompute.com/agents/conversation/alice/rpc/humanReply ``` The request body is the argument list, and the response is the same `result` / `error` frame a WebSocket caller would receive. Only methods decorated with [`@rpc()`](/docs/agent-sdk/api-reference/agent/rpc) are callable. **SSE does not stream on deployed functions today.** The edge gateway buffers a response until it completes before returning it, 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. Use the WebSocket path in production. It carries the same frames under the same policy on the same address, so the change is `new WebSocket(url)` in place of `new EventSource(url)` — or `AgentClient`, which does the reconnect and resume for you. This is a platform-side gap tracked as **COMPUTE-819**; the SSE surface stays and starts streaming once the gateway allows it. The full caveat is on the [AgentHttpServer](/docs/agent-sdk/api-reference/agent-http-server) page. ## Authorization composes — it does not replace The mount adds **no policy of its own**. It routes, and it runs the one `authorize` callback you give it. That callback does not stand in for the agent's own `authorize(token, req)`: both run, and they do different jobs. **Gate one, at the edge.** `authorize(req, route)` runs after the address resolves and **before anything touches an actor**, on every transport — an upgrade, an SSE stream, and each RPC POST alike. It receives the request unmodified and the resolved [route](/docs/agent-sdk/api-reference/mount#mountroute): the mount key, the decoded name, the actor id, the transport, and the method for an RPC. Answer with a `Response` to reject (nothing is forwarded, no actor wakes), with headers to admit and stamp, or with nothing to admit as-is. **Gate two, inside the agent.** The caller's credential rides through **untouched** — `?token=` or an `Authorization` header, exactly as sent. The agent's [`authorize`](/docs/agent-sdk/api-reference/agent/connection#authorize) resolves this connection's [claims](/docs/agent-sdk/api-reference/agent/connection#claim) from it exactly as it would with no mount in front, and [`onAttach`](/docs/agent-sdk/api-reference/agent/connection#onattach) may still veto. ```ts // The function: who is this, and may they address this agent at all? export default { fetch: mountAgents((env) => ({ conversation: env.CONVERSATION }), { authorize: async (req, { name }) => { const user = await verify(req.headers.get("authorization")); if (!user) return new Response("unauthorized", { status: 401 }); if (user.deskId !== name) return new Response("forbidden", { status: 403 }); return { "x-desk-user": user.id, "x-desk-role": user.role }; }, }), }; ``` ```ts // The agent: given the credential, what may this connection DO? import { Agent, type Claim } from "@telnyx/edge-runtime"; export class Conversation extends Agent { protected override authorize(token: string | undefined): readonly Claim[] { if (token === undefined) return ["read"]; // anonymous watcher return isOperator(token) ? ["read", "rpc"] : ["read"]; } } ``` Identity at the mount, grants in the agent. Two rules make the stamped identity safe to trust: - **A stamped header 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 what you stamp. A throw from the mount's `authorize` is not an admission — it propagates and the request never reaches an actor. Reading the body is safe: the agent is handed an independent copy, so a signature you verify here still arrives there intact. That copy is made only for a request that carries a body, and it does buffer while both copies are outstanding — on a large streamed upload, prefer deciding from the headers. ## Names your customers already use Actor ids are not free-form: only ASCII letters, digits, `-`, `_`, and `.` are addressable, and an id built from anything else belongs to an instance that never activates. The names you actually route on are not so confined — a phone number, an email address, a composite key, a name with accents or emoji. The mount decodes the name segment and re-encodes it with [`encodeAgentName`](/docs/agent-sdk/api-reference/mount#encodeagentname), which maps any name onto the addressable set reversibly: ```ts import { encodeAgentName, decodeAgentName } from "@telnyx/edge-runtime/mount"; encodeAgentName("alice"); // "alice" — already addressable, unchanged encodeAgentName("+15550100"); // ":2b:15550100" encodeAgentName("user@ex.com"); // "user:40:ex.com" decodeAgentName(":2b:15550100"); // "+15550100" — render an id back for a human ``` `/agents/conversation/%2B15550100` therefore addresses the agent for `+15550100`, and no two names ever share an instance. Three things to keep in mind: - **The mapping is part of an actor's identity and is frozen.** The id a name first resolves to is where that instance's state and timers live. Do not re-derive the rule by hand — call `encodeAgentName` from any front door of your own that addresses the same instances, or it will point the name at a different, empty instance. - **Case is significant.** `alice` and `Alice` are two agents. Lowercase names before routing on them if you mean them to be one. - **Encode the raw name exactly once.** Encoding an already-encoded name escapes its `:` signs and addresses something else. ### How long a name may be The limit is on the actor id **once URL-escaped** — `MAX_ESCAPED_ACTOR_ID_LENGTH`, **225** characters — because the id is carried into the name of the file the instance's persistent state is kept in, which is capped at 255 bytes. How many characters of *name* that allows depends on which characters they are, since an escape is longer than what it replaces: | A name of… | costs per character | one run fits | |---|---|---| | ASCII letters, digits, `-`, `_`, `.` | 1 | 225 | | accented Latin, Greek, Cyrillic (2 bytes) | 4 | 54 | | most CJK and symbols such as `☕` (3 bytes) | 6 | 36 | | emoji and other astral characters (4 bytes) | 8 | 27 | Every separate escaped run also spends 6 on the pair of `:` bracketing it, so a mixed name fits less than any single row suggests. A name whose id would exceed the ceiling is refused by the mount with `400 malformed_name` before any actor wakes, and `encodeAgentName` throws rather than handing back an unusable id. **An over-long id does not fail on first use.** The instance activates, serves requests, and accepts writes; the long name is only needed when it is loaded again after being evicted from memory, and from then on the instance cannot be opened and what it stored is unreachable. Nothing about the first, healthy run of it looks wrong — which is why the limit is checked up front. See [Limits](/docs/agent-sdk/limits#max_escaped_actor_id_length) and [`escapedActorIdLength()`](/docs/agent-sdk/limits#escapedactoridlength). ## What the mount answers itself | Request | Answer | |---|---| | `/health/liveness`, `/health/readiness` | `200 ok`, no actor woken, nothing about what is mounted in the body | | A path outside `base` | your [`fallback`](/docs/agent-sdk/api-reference/mount#mountoptions), or `404` | | Unknown mount key, malformed address, empty name | `404` — one body for all of them | | A name with no faithful actor id | `400`, naming the problem | | A shape matching no transport — a `PUT`, a `GET` on an RPC address, an upgrade to one | `405` with an `Allow` header | No actor is woken for any of them. Refusals from the mount carry a JSON body with an `error` of `not_found`, `method_not_allowed`, or `malformed_name`; answers from the agent — including its own errors — are relayed unchanged. Set `health: false` when the surrounding function owns its own health routes. ## Composing with routes of your own A mount does not have to own the whole function. `fallback` receives every request whose path is not under `base`, so your own handler keeps its routes and the agent addresses never reach it: ```ts export default { fetch: mountAgents((env) => ({ conversation: env.CONVERSATION }), { base: "/agents", // the paths the mount owns fallback: myRouter.fetch, // everything else stays yours health: false, // ...including your own health checks }), }; ``` ## Cross-origin browsers Absent `cors`, the mount sends no CORS headers and answers `OPTIONS` with `405`, so a page from another origin cannot reach it. Opt in when a browser page served elsewhere must open an `EventSource` or `fetch` an RPC address: ```ts { cors: { origin: ["https://desk.example.com"], credentials: true, // requires a concrete origin — never "*" }, } ``` It is a passthrough and nothing more: it does not authenticate, and it never substitutes for `authorize`. Mount names are ordinary path segments, not secrets — they are part of the address every legitimate client already knows. ## Next - [`mountAgents` reference](/docs/agent-sdk/api-reference/mount) — every option, type, and refusal in full - [AgentClient](/docs/agent-sdk/api-reference/agent-client) — the browser/Node client that connects to the address above - [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server) and [AgentHttpServer](/docs/agent-sdk/api-reference/agent-http-server) — what answers on the agent side of each transport - [WebSockets](/docs/agent-sdk/websockets) — the platform surface underneath, and what a hand-written front door looks like --- ### Roll Your Own Agent > Source: https://developers.telnyx.com/docs/agent-sdk/examples/roll-your-own.md No framework — your `process()` method **is** the agent loop. The Agent SDK supplies the persistent parts (history, retries, the follow-up timer); the LLM is one call on the pre-authenticated [Telnyx API binding](/docs/edge-compute/telnyx-api), which speaks the `this.messages.toOpenAI()` payload directly — no API key to manage. Prefer another provider? Any OpenAI-compatible endpoint works over `fetch`, with its key in a [secret](/docs/edge-compute/configuration/secrets). Each customer gets their own `Conversation` actor, keyed by phone number. **`src/conversation.ts`** — the actor: ```ts import { Agent } from "@telnyx/edge-runtime"; const SYSTEM_PROMPT = "You are a terse, helpful SMS support agent. Answer in one or two sentences."; // Minimal hand-typed slice of the binding — `telnyx-edge types` generates the full types interface ConvEnv { TELNYX: { messages: { send(m: { from: string; to: string; text: string }): Promise; }; ai: { openai: { chat: { createCompletion(req: { model: string; messages: Array<{ role: string; content: string }>; reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; }): Promise<{ choices: Array<{ message: { content: string } }> }>; }; }; }; }; } interface ConvState extends Record { from: string; to: string; at: number; } export class Conversation extends Agent { protected override initialState(): ConvState { return { from: "", to: "", at: 0 }; } async receive({ text, from, to }: { text: string; from: string; to: string }): Promise { await this.setState({ from, to }); await this.messages.add("user", text); await this.queue("process"); // ack the webhook now, think in the background } async process(): Promise { // toOpenAI() emits Chat Completions format — the binding takes it directly const history = await this.messages.toOpenAI(); // Pre-authenticated Telnyx client: no base URL, no key. An API error // rejects, which marks the task failed — the scheduler retries with backoff. const res = await this.env.TELNYX.ai.openai.chat.createCompletion({ model: "zai-org/GLM-5.3-Flash", messages: [{ role: "system", content: SYSTEM_PROMPT }, ...history], reasoning_effort: "high", }); const reply = res.choices[0].message.content; await this.messages.add("assistant", reply); const { from, to } = await this.getState(); await this.env.TELNYX.messages.send({ from: to, to: from, text: reply }); await this.setState({ at: Date.now() }); // Follow up in 24 hours if the customer goes quiet await this.schedule(86_400, "nudge", null, { id: "nudge" }); } async nudge(): Promise { const last = await this.messages.last(); if (last?.role !== "assistant") return; // customer replied — skip const { from, to } = await this.getState(); await this.env.TELNYX.messages.send({ from: to, to: from, text: "Just checking in — did that sort things out?", }); } } ``` **`src/index.ts`** — the function that routes inbound webhooks to the right actor: ```ts import type { ActorNamespace } from "@telnyx/edge-runtime"; import { Conversation } from "./conversation.js"; export { Conversation }; interface Env { CONVOS: ActorNamespace; } export default { async fetch(req: Request, env: Env): Promise { if (new URL(req.url).pathname.startsWith("/health")) return new Response("ok"); const hook = await req.json() as { data: { event_type: string; payload: { from: { phone_number: string }; to: Array<{ phone_number: string }>; text: string } }; }; if (hook.data.event_type !== "message.received") return new Response("ignored"); const { from, to, text } = { from: hook.data.payload.from.phone_number, to: hook.data.payload.to[0]?.phone_number ?? "", text: hook.data.payload.text, }; // Strip leading '+' — actor IDs must not contain '+'. Store the full E.164 in state. await env.CONVOS.idFromName(from.replace(/^\+/, "")).receive({ text, from, to }); return new Response("ok"); }, }; ``` Verify Telnyx webhook signatures before processing — see [receiving webhooks](/docs/development/api-fundamentals/webhooks/receiving-webhooks). The examples above omit verification for brevity; production code must check the `telnyx-signature-ed25519` header. **`telnyx.toml`:** ```toml name = "support-agent" main = "src/index.ts" compatibility_date = "2026-05-01" [[actors]] binding = "CONVOS" type = "Conversation" [telnyx] binding = "TELNYX" ``` That's the whole deployment — the `[telnyx]` binding carries auth, so there is no API key to provision. Prefer a framework running the loop instead? See the [LangGraph version](/docs/agent-sdk/examples/langgraph) of this same agent. Task delivery is [at-least-once](/docs/agent-sdk/api-reference/agent/scheduling#delivery-contract): a crash after `messages.add()` or `messages.send()` succeeds retries the whole `process()` method. For production, guard outbound side effects — e.g. check state before sending, or use a stable message ID to deduplicate. --- ### LangGraph Agent > Source: https://developers.telnyx.com/docs/agent-sdk/examples/langgraph.md Here an agent framework does the thinking. LangGraph's ReAct agent runs **inside** the actor's `process()` method — with tool calling, multi-step reasoning, the works — while the Agent SDK supplies what the framework doesn't have: persistent per-customer history, retries, and the follow-up timer. `this.messages.toLangChain()` returns plain `{ role, content }` messages, which LangGraph accepts as-is. The LLM is `ChatOpenAI` pointed at Telnyx Inference — swap `baseURL`, key, and model to bring any OpenAI-compatible provider. The same pattern fits any agent framework that runs on Node. Install the framework alongside the runtime: ```bash npm install @langchain/openai @langchain/langgraph @langchain/core zod ``` **`src/conversation.ts`** — the actor: ```ts import { Agent } from "@telnyx/edge-runtime"; import { ChatOpenAI } from "@langchain/openai"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; const SYSTEM_PROMPT = "You are a terse, helpful SMS support agent. Answer in one or two sentences."; const llm = new ChatOpenAI({ model: "zai-org/GLM-5.3-Flash", reasoningEffort: "high", apiKey: process.env.TELNYX_API_KEY, configuration: { baseURL: "https://api.telnyx.com/v2/ai/openai" }, }); const lookupOrder = tool( async ({ orderId }) => { // ...query your order system... return JSON.stringify({ orderId, status: "shipped", eta: "Friday" }); }, { name: "lookup_order", description: "Look up the status of a customer order by id.", schema: z.object({ orderId: z.string() }), }, ); const supportAgent = createReactAgent({ llm, tools: [lookupOrder] }); interface ConvEnv { TELNYX: { messages: { send(m: { from: string; to: string; text: string }): Promise; }; }; } interface ConvState extends Record { from: string; to: string; at: number; } export class Conversation extends Agent { protected override initialState(): ConvState { return { from: "", to: "", at: 0 }; } async receive({ text, from, to }: { text: string; from: string; to: string }): Promise { await this.setState({ from, to }); await this.messages.add("user", text); await this.queue("process"); // ack the webhook now, think in the background } async process(): Promise { // toLangChain() emits plain {role, content} — LangGraph takes it as-is const history = await this.messages.toLangChain(); const out = await supportAgent.invoke({ messages: [{ role: "system", content: SYSTEM_PROMPT }, ...history], }); const reply = String(out.messages.at(-1)?.content ?? ""); // Throwing before this point marks the task failed — the scheduler retries await this.messages.add("assistant", reply); const { from, to } = await this.getState(); await this.env.TELNYX.messages.send({ from: to, to: from, text: reply }); await this.setState({ at: Date.now() }); // Follow up in 24 hours if the customer goes quiet await this.schedule(86_400, "nudge", null, { id: "nudge" }); } async nudge(): Promise { const last = await this.messages.last(); if (last?.role !== "assistant") return; // customer replied — skip const { from, to } = await this.getState(); await this.env.TELNYX.messages.send({ from: to, to: from, text: "Just checking in — did that sort things out?", }); } } ``` Only the framework's final reply lands in `this.messages` — intermediate tool calls and tool results stay inside the LangGraph run. If you want them in the persistent history too, `append()` them from `out.messages`. **`src/index.ts`** — the function that routes inbound webhooks to the right actor: ```ts import type { ActorNamespace } from "@telnyx/edge-runtime"; import { Conversation } from "./conversation.js"; export { Conversation }; interface Env { CONVOS: ActorNamespace; } export default { async fetch(req: Request, env: Env): Promise { if (new URL(req.url).pathname.startsWith("/health")) return new Response("ok"); const hook = await req.json() as { data: { event_type: string; payload: { from: { phone_number: string }; to: Array<{ phone_number: string }>; text: string } }; }; if (hook.data.event_type !== "message.received") return new Response("ignored"); const { from, to, text } = { from: hook.data.payload.from.phone_number, to: hook.data.payload.to[0]?.phone_number ?? "", text: hook.data.payload.text, }; // Strip leading '+' — actor IDs must not contain '+'. Store the full E.164 in state. await env.CONVOS.idFromName(from.replace(/^\+/, "")).receive({ text, from, to }); return new Response("ok"); }, }; ``` Verify Telnyx webhook signatures before processing — see [receiving webhooks](/docs/development/api-fundamentals/webhooks/receiving-webhooks). The examples above omit verification for brevity; production code must check the `telnyx-signature-ed25519` header. **`telnyx.toml`:** ```toml name = "support-agent" main = "src/index.ts" compatibility_date = "2026-05-01" [[actors]] binding = "CONVOS" type = "Conversation" [telnyx] binding = "TELNYX" ``` **Set the API key** as a [secret](/docs/edge-compute/configuration/secrets) — it reaches the actor as `process.env.TELNYX_API_KEY`: ```bash telnyx-edge secrets add TELNYX_API_KEY "KEY..." ``` Prefer owning the loop yourself? The [hand-rolled version](/docs/agent-sdk/examples/roll-your-own) of this same agent needs no key at all — it calls inference through the pre-authenticated Telnyx binding. A framework owns its own HTTP stack, so it authenticates like any external client. Task delivery is [at-least-once](/docs/agent-sdk/api-reference/agent/scheduling#delivery-contract): a crash after `messages.add()` or `messages.send()` succeeds retries the whole `process()` method. For production, guard outbound side effects — e.g. check state before sending, or use a stable message ID to deduplicate. --- ### Overview > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference.md The Agent SDK's server-side surface ships in `@telnyx/edge-runtime`; the client SDK ships at `@telnyx/edge-runtime/client` with no server dependencies. ```ts import { Agent, rpc } from "@telnyx/edge-runtime"; import { AgentSocketServer } from "@telnyx/edge-runtime/agent-socket"; import { AgentClient } from "@telnyx/edge-runtime/client"; ``` ## The surface at a glance **Server side** — everything here runs inside your `Agent` subclass. ### [`Agent`](/docs/agent-sdk/api-reference/agent) The base class. Subclass it, declare async methods for your inbound surface, and use the protected members from your own code — one page per member group: | Group | Members | |---|---| | [Lifecycle](/docs/agent-sdk/api-reference/agent/lifecycle) | `constructor` · `ctx` · `env` · `fetch()` · `webSocket()` · `onConnect()` · `alarm()` (reserved) | | [Connection](/docs/agent-sdk/api-reference/agent/connection) | `authorize()` · `onAttach()` · `attachments` — types `Claim`, `Attachment`, `AgentAttachments`; error `AgentSocketsNotEnabledError` | | [State](/docs/agent-sdk/api-reference/agent/state) | `getState()` · `setState()` · `replaceState()` · `initialState()` · `onStateChanged()` | | [Scheduling](/docs/agent-sdk/api-reference/agent/scheduling) | `queue()` · `schedule()` · `every()` · `cancelSchedule()` · `listSchedules()` · `onTask()` · `now()` — types `ScheduleOptions`, `TaskRecord` | | [Messages](/docs/agent-sdk/api-reference/message-log) | `this.messages` — an instance of the `MessageLog` class below | ### [`MessageLog`](/docs/agent-sdk/api-reference/message-log) The agent's persistent conversation history, accessed as `this.messages`. `add()` · `append()` · `appendMany()` · `all()` · `last()` · `count()` · `toOpenAI()` · `toAnthropic()` · `toLangChain()` — types `AgentMessage`, `StoredMessage`, `ToolCall` ### [`EventLog`](/docs/agent-sdk/api-reference/event-log) A persistent, replayable progress-event stream with cursor reads and count-based retention, accessed as `this.events` (or constructed standalone). `constructor` · `emit()` · `read()` · `count()` — types `StoredEvent`, `EventLogOptions` ### [`rpc()` / `rpcSurface()`](/docs/agent-sdk/api-reference/agent/rpc) Standalone functions, not `Agent` members: `@rpc()` opts a method onto the remote-callable surface, and `rpcSurface()` introspects what a class exposes. Type `RpcOptions`. ### [`AgentSocketServer`](/docs/agent-sdk/api-reference/agent-socket-server) The server half of the agent socket protocol — construct one inside your subclass (from `@telnyx/edge-runtime/agent-socket`) and delegate `webSocket()` to it. `attach()` · `broadcastPatch()` · `broadcastSnapshot()` · `broadcastMessages()` · `broadcastEvent()` · `watcherCount` · `close()` · `handleCall()` · `authorizeEnabled` — types `AgentSocketServerOptions`, `AgentServerSocket`, `AgentConnectContext`, `HandleCallOptions`, `HandleCallResult` ### [`AgentHttpServer`](/docs/agent-sdk/api-reference/agent-http-server) The HTTP front door for an `AgentSocketServer` (from `@telnyx/edge-runtime/agent-http`): the agent's streams over Server-Sent Events, its RPC surface over POST — same frames and policy as the WebSocket path, no SDK needed on the page. `fetch()` · `handleRpc()` · `handleSse()` The SSE half does not stream on deployed functions yet — the edge gateway buffers the response, so use the WebSocket path in production. See [AgentHttpServer](/docs/agent-sdk/api-reference/agent-http-server) for the full caveat. ### [`mountAgents()`](/docs/agent-sdk/api-reference/mount) The front door *(SDK ≥ 0.12.0)*, from `@telnyx/edge-runtime/mount`: one call routes every agent a function serves, giving each `//` — the `/agents/conversation/alice` shape the `AgentClient` examples connect to — with the request's shape selecting WebSocket, SSE, or RPC. Export it as `export default { fetch: mountAgents(...) }`; a bare-function default export is refused at load time. `mountAgents()` · `encodeAgentName()` · `decodeAgentName()` · `MAX_AGENT_NAME_LENGTH` — types `AgentMountMap`, `MountOptions`, `MountRoute`, `MountCorsOptions`, `MountAuthorizeResult`, `MountHeadersInit`, `MountTransport` Its `authorize` **composes with** the agent's own: the mount stamps identity before any actor wakes, the caller's credential rides through untouched, and the agent still resolves its claims. Start at [Mounting Agents](/docs/agent-sdk/mounting-agents). **Client side** — the one export that runs outside the agent: ### [`AgentClient`](/docs/agent-sdk/api-reference/agent-client) The browser/Node client (from `@telnyx/edge-runtime/client`, no server dependencies): connects over WebSocket, mirrors the agent's state, and makes typed RPC calls through `stub`. `stub` · `claims` · `onState()` · `onMessages()` · `onEvents()` · `isConnected()` · `close()` — types `AgentClientOptions`, `AgentEvent`, `Stream` The URL it connects to is the one [`mountAgents`](/docs/agent-sdk/api-reference/mount) serves. ## Rules that apply across the whole surface - **Every `Agent` member is `protected`** — this is an inside-the-class API, called from your own methods. It is not part of the RPC surface your subclass exposes to stubs. - **The remote surface is opt-in** *(SDK ≥ 0.10.0)* — methods callable by connected clients over the agent socket must be decorated with `@rpc()`; undecorated methods answer `method_private` on the wire while remaining callable in-process: ```ts import { Agent, rpc } from "@telnyx/edge-runtime"; export class Support extends Agent { @rpc({ description: "Human takes over the thread" }) async humanReply(text: string) { /* callable by clients */ } async recalc() { /* in-process only */ } } ``` - **`alarm()` is claimed by the SDK.** The task scheduler runs on the actor's single alarm slot. Do not override `alarm()` in an `Agent` subclass — schedule a [task](/docs/agent-sdk/api-reference/agent/scheduling) instead. (On a plain `StatefulActor`, `alarm()` remains yours — see [Alarms](/docs/edge-compute/stateful-actors/alarms).) Everything from `StatefulActor` is still there — `this.ctx`, `this.env`, RPC dispatch, `fetch()` — see the [StatefulActor Runtime API](/docs/edge-compute/stateful-actors/api-reference). Budgets and retry behavior live in [Limits](/docs/agent-sdk/limits). --- ### Agent Class > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent.md `Agent` extends `StatefulActor`. Subclass it, declare async methods for your inbound surface, and use the protected members inside them. ```ts import { Agent } from "@telnyx/edge-runtime"; interface MyState extends Record { status: "idle" | "processing"; } export class SupportAgent extends Agent { protected override initialState(): MyState { return { status: "idle" }; } async receive(text: string): Promise { await this.messages.add("user", text); await this.queue("process"); } } ``` ## Type parameters | Parameter | Constraint | Default | Meaning | |---|---|---|---| | `E` | `extends Env` | `Env` | Your environment/bindings type — becomes `this.env` | | `State` | `extends Record` | `Record` | Your persistent state shape | ## Construction You don't construct agents yourself — the runtime does. On every activation the base constructor also **re-arms the task scheduler** to the earliest pending task (inside `ctx.blockConcurrencyWhile`), which is what makes timers survive crashes and restarts. If you need your own one-shot init, override the constructor and call `super(ctx, env)` first, exactly as with a [StatefulActor](/docs/edge-compute/stateful-actors/api-reference/base). ## Override hooks | Hook | Fires | |---|---| | `initialState(): State` | When state is read and nothing has been stored yet — return the default for a fresh actor | | `onStateChanged(next, prev)` | After every `setState()` **and `replaceState()`** resolves, with the new state and the state before the change | | `onTask(name, payload, ctx)` | When a due task's `name` matches no method on the class — `ctx.attempt` carries the delivery attempt | | `onConnect(conn)` | When a client connection is established — the socket layer is [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server) | | `authorize(token, req?)` | Once per connection on the built-in [connection surface](/docs/agent-sdk/api-reference/agent/connection) *(SDK ≥ 0.11.0)* — map the credential to claims, or throw to reject | | `onAttach(att)` | After `authorize` resolves, before the connection is admitted — inspect claims and veto with `att.close()` | | `now(): number` | Time source for the scheduler (defaults to `Date.now()`) — override in tests for a deterministic clock | ## Reserved: `alarm()` The SDK claims the actor's alarm slot to drive the task scheduler: when the alarm fires, `Agent.alarm()` drains every due task, dispatches each to the method it names, and re-arms to the next deadline. **Overriding `alarm()` in an `Agent` subclass breaks `queue`/`schedule`/`every`.** If you need timed work, schedule a task. --- ### Lifecycle > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent/lifecycle.md The runtime constructs and activates agent instances for you; these are the members involved in that lifecycle — construction, the inherited actor primitives, the inbound HTTP/WebSocket entry points, and the alarm slot the SDK reserves for the task scheduler. ## constructor ## AgentOptions ## ctx ## env ## fetch() ## webSocket() ## onConnect() ## alarm() --- ### Connection > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent/connection.md The built-in connection surface *(SDK ≥ 0.11.0)*: override `authorize` or `onAttach` (or [`webSocket`](/docs/agent-sdk/api-reference/agent/lifecycle#websocket) itself) and the default `webSocket` serves the agent socket protocol for you, no [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server) wiring required — overriding [`onConnect`](/docs/agent-sdk/api-reference/agent/lifecycle#onconnect) alone does not activate it. The default `authorize` admits every connection as a **read-only watcher** — remote [`@rpc()`](/docs/agent-sdk/api-reference/agent/rpc) calls require an explicit `authorize` override that validates the credential and grants `"rpc"` deliberately. ## authorize() ## onAttach() ## attachments ## connectionEngine ## AgentSocketsNotEnabledError ## Attachment ## AgentAttachments ## Claim --- ### State > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent/state.md Agent state is one persistent value of your `State` type. `setState` **merges**, which is what you want for the common case — update two fields without re-writing the rest. ```ts interface ConvState extends Record { status: "idle" | "processing"; lastReply: string; at: number; } export class Conversation extends Agent { protected override initialState(): ConvState { return { status: "idle", lastReply: "", at: 0 }; } async process(): Promise { await this.setState({ status: "processing" }); // merge: other keys untouched // ... await this.setState({ status: "idle", at: Date.now() }); } } ``` State lives in the actor's persistent storage — the same layer the scheduler and the message log write to — as a single entry holding the whole `State` value. Reads return that value in full, and `setState` re-persists it inside a storage transaction, so keep it small and fixed-shape: status flags, cursors, the instance's working memory. It follows the storage layer's codec rules and per-value size cap (see the [storage reference](/docs/edge-compute/stateful-actors/api-reference/storage)). For data that accumulates or needs querying — rows you'd filter, aggregate, or join — use the agent's embedded [SQL database](/docs/agent-sdk/sql) instead; it lives in the same instance and scales to 1 GB per agent. ## initialState() ## getState() ## setState() The merge is recursive, and `null` deletes: ```ts // stored: { status: "processing", job: { id: "j1", step: 2, note: "retrying" } } await this.setState({ job: { step: 3, note: null } }); // stored: { status: "processing", job: { id: "j1", step: 3 } } ``` Untouched keys survive at every level — `status` and `job.id` were never rewritten. ## replaceState() ## onStateChanged() One override fans state out to connected clients — `broadcastSnapshot` pushes to every socket subscribed to `state`, and the hook covers `setState` and `replaceState` alike: ```ts private desk = new AgentSocketServer(this, { getState: () => this.getState(), }); protected override async onStateChanged(next: ConvState, prev: ConvState): Promise { await this.desk.broadcastSnapshot(next); } ``` For patch-level pushes — sending only what changed — broadcast from a `setState` override instead, where the patch object is in hand; the example on [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server) shows that variant. --- ### Scheduling > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent/scheduling.md Tasks are persistent named timers. Each task names a method on your class; when the timer fires, the SDK calls that method with the task's payload. All of it rides the actor's single [alarm](/docs/edge-compute/stateful-actors/alarms) slot, re-armed to the earliest pending deadline. ```ts await this.queue("process", { attempt: "first" }); await this.schedule(3600, "sendReminder"); await this.every(300, "checkStatus"); ``` All three create the same kind of task and differ only in when the timer first fires: `queue()` as soon as possible, `schedule()` once after a delay, `every()` on a fixed interval. Reach for a task whenever work doesn't have to finish inside the inbound call: an inbound method has a 30-second budget, while a task runs in the alarm turn with a budget on the order of minutes — so the pattern for LLM and outbound API work is record intent, `queue()` the work, return immediately. [How Agents Run](/docs/agent-sdk/concepts/how-agents-run) walks through that loop; budgets live in [Limits](/docs/agent-sdk/limits). ## queue() ## schedule() ## every() ## ScheduleOptions ## Dispatch - A due task calls `this[task.name](task.payload)` — one argument, the payload. - If no such method exists, the fallback fires instead: `onTask(name, payload, { attempt })`. - Task methods are ordinary methods. A method that should be schedulable but **not** RPC-callable from a stub can be named with a leading `_` — the runtime excludes `_`-names from RPC, but the scheduler still dispatches to them. ## onTask() ## now() ## Failure and retries - A task that **throws** is retried with exponential backoff (starting around a second, capped at 5 minutes), up to `maxRetries` times after the first delivery — 6 runs total by default. - A task that exhausts its retries is **parked**: deleted without firing again, and there is no callback when that happens. - A recurring (`every`) task resets its attempt count after each successful run, and schedules its next fire from the start of the drain turn (the timestamp captured before dispatch), not from when the method returns. ## Delivery contract Delivery is **at-least-once**: a crash after your method runs but before the task is marked done re-runs it on the next activation. Write task handlers to be idempotent — the same rule as [alarm handlers](/docs/edge-compute/stateful-actors/alarms). Payloads are stored in the actor's persistent storage and must be codec-safe — JSON-native values plus `Date`, `Map`, `Set`, `ArrayBuffer`/TypedArray, `BigInt`, `RegExp`. Functions, class instances, or circular references throw a `CodecError` — see [Errors](/docs/edge-compute/stateful-actors/api-reference/errors). ## cancelSchedule() ## listSchedules() ## TaskRecord --- ### RPC > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent/rpc.md The remote surface is opt-in: only methods decorated with `@rpc()` are callable by connected clients, and `rpcSurface()` reports what a class exposes. Both are standalone functions imported from `@telnyx/edge-runtime`, not `Agent` members. These functions are the server half of the remote-call story. `@rpc()` marks what may be called; [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server) enforces the opt-in on the wire (an undecorated method answers `method_private`); and on the far end, [AgentClient](/docs/agent-sdk/api-reference/agent-client)'s `stub` is how callers reach what you exposed — `agent.stub.humanReply("…")` dispatches to the `@rpc()`-decorated `humanReply` on your class. ## rpc() ## rpcSurface() ## RpcOptions --- ### MessageLog > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/message-log.md `this.messages` is the agent's persistent conversation log. Messages are ordered by an assigned monotonic `seq` (insertion order, not wall-clock), and the log is append-only — there is no delete. The log is the agent's memory across turns. Append messages as they happen — user input, assistant replies, tool results — and rebuild the next LLM request from history: `last(n)` for a bounded window, or an adapter for a provider-shaped payload. Because the log persists with the instance, a crash, a restart, or a week of idleness doesn't lose the thread. The [Message History](/docs/agent-sdk/message-history) guide walks the full loop. ## Accessing the log The log hangs off one `Agent` property: ## new MessageLog() You rarely construct one — `this.messages` is built for you — but a standalone log over the actor's storage is a plain constructor call: ## AgentMessage ## ToolCall ## StoredMessage ## add() ## append() ## appendMany() ## all() ## last() ## count() ## Adapters Each adapter reads the full history and converts it. They differ in how they treat `system` and `tool` messages: | Adapter | Output | `system` | `tool` / `toolCalls` | |---|---|---|---| | `toLangChain()` | plain `{ role, content }` | included | **dropped** — only `user`/`assistant`/`system` survive | | `toOpenAI()` | Chat Completions messages | included | mapped to `tool_calls` / `tool_call_id`, `args` JSON-stringified | | `toAnthropic()` | Messages-API turns | **dropped** — pass the system prompt as the top-level `system` param | assistant `toolCalls` become `tool_use` blocks; `tool` results become `user` turns with `tool_result` blocks | ```ts const history = await this.messages.toOpenAI(); // → ready for env.TELNYX.ai.openai.chat.createCompletion({ model, messages: history }) // (the pre-authenticated binding) or any OpenAI-compatible client ``` If you record tool calls in history and hand it to LangChain, note the drop: persist what the framework needs via `append()` with plain roles, or rebuild framework state from `toOpenAI()` output instead. ## toOpenAI() ## toAnthropic() ## toLangChain() --- ### EventLog > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/event-log.md `EventLog` is a persistent, replayable progress-event stream over ordered KV storage. `emit(type, payload)` assigns a strictly increasing `seq` and persists the row, so consumers can replay events in order after a restart, or resume from a cursor (`read(afterSeq)`). Retention is count-based: the log keeps at most `retain` rows (default 1000), and each emit past that bound prunes the oldest rows — the storage footprint stays bounded regardless of emit rate, and pruning never reuses a `seq`. ## Accessing the log Every agent holds a built-in log on one property *(SDK ≥ 0.11.0)*: ## Obtaining an EventLog For a separate stream, an agent constructs its own log over the actor's durable storage — typically once, as a field: ```ts import { Agent, EventLog } from "@telnyx/edge-runtime"; class ResearchAgent extends Agent { private readonly activity = new EventLog(this.ctx.storage, { retain: 500 }); } ``` The first argument is the actor's `ctx.storage`, so the log shares the actor's durability and single-writer guarantees; `retain` bounds how many rows are kept (default 1000). One actor can hold several logs for unrelated streams — each `EventLog` keys its rows independently. ## new EventLog() ## Worked example: streaming progress from a long-running task The pattern has three parts: the task **emits** durable events, the socket server **replays and pushes** them, and the client **subscribes** from a cursor. **1. Emit from the task, push to live watchers.** `emit` persists the row and returns its `seq`; reading strictly after `seq - 1` yields the stored row to hand to [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server)'s `broadcastEvent`: ```ts class ResearchAgent extends Agent { private readonly activity = new EventLog(this.ctx.storage, { retain: 500 }); private readonly desk = new AgentSocketServer(this, { getState: () => this.getState(), // cursor replay: a client attaching with { events: N } gets everything after N getEvents: (afterSeq) => this.activity.read(afterSeq), }); override webSocket(ws: WebSocket, req: Request) { return this.desk.attach(ws, req); } async runReport(): Promise { const steps = ["fetch", "analyze", "summarize"]; for (let i = 0; i < steps.length; i++) { await this.doStep(steps[i]); const seq = await this.activity.emit("progress", { step: steps[i], done: i + 1, total: steps.length, }); const [event] = await this.activity.read(seq - 1); this.desk.broadcastEvent(event); // live push to subscribed watchers } } } ``` **2. Subscribe on the client.** [AgentClient](/docs/agent-sdk/api-reference/agent-client)'s `onEvents` delivers each event in `seq` order; `from` replays missed events after a disconnect, so a page that reconnects mid-task picks up exactly where it left off: ```ts const agent = new AgentClient(url, { token, subscribe: ["events"], resume: true }); agent.onEvents((e) => renderProgress(e.type, e.payload), { from: lastSeenSeq }); ``` Because the log is durable and `seq` never regresses, this survives agent restarts mid-task: the replay comes from storage, not from anything held in memory. ## emit() ## read() ## count() ## StoredEvent ## EventLogOptions --- ### AgentSocketServer > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent-socket-server.md `AgentSocketServer` is the server half of the agent socket protocol — it drives every WebSocket an agent holds. Construct one per actor instance and delegate the actor's `webSocket(ws, req)` to `attach()`. The server sends each admitted connection a state snapshot followed by `hello`, dispatches inbound RPC frames to the actor's [`@rpc()`](/docs/agent-sdk/api-reference/agent/rpc)-decorated methods, and pushes incremental updates through the `broadcast*` methods as the agent's data changes. ```ts import { Agent, type Env, type MergePatch } from "@telnyx/edge-runtime"; import { AgentSocketServer, type AgentServerSocket } from "@telnyx/edge-runtime/agent-socket"; interface ConvState extends Record { status: string; } export class Conversation extends Agent { private desk = new AgentSocketServer(this, { getState: () => this.getState(), getMessages: () => this.messages.all(), // token → claims; throw to reject; `undefined` = anonymous client authorize: (token) => (token === undefined ? ["read"] : ["read", "rpc"]), }); async webSocket(ws: AgentServerSocket, req: Request): Promise { await this.desk.attach(ws, req); } protected override async setState(patch: MergePatch): Promise { const next = await super.setState(patch); this.desk.broadcastPatch(patch); // incremental push to every client return next; } } ``` With an `authorize` hook configured, every connection becomes an authorized session: a client that opens with an `attach` frame presents its token, `authorize(token)` maps it to claims, and per-stream subscriptions are negotiated; a client that never attaches is authorized as anonymous — `authorize(undefined)` — so admit it with limited claims, or throw to reject. RPC `call` frames require the `"rpc"` claim, which is what lets read-only watchers and fully privileged operators share one socket endpoint. ## attach() ## broadcastPatch() ## broadcastSnapshot() ## broadcastMessages() ## broadcastEvent() ## watcherCount ## close() ## handleCall() ## authorizeEnabled ## AgentSocketServerOptions ## AgentServerSocket ## AgentConnectContext ## HandleCallOptions ## HandleCallResult --- ### AgentHttpServer > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent-http-server.md `AgentHttpServer` *(SDK ≥ 0.11.0)* is the HTTP front door for an [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server): the agent's streams over Server-Sent Events (**GET** answers a `text/event-stream` response), and its RPC surface over **POST** to `…/rpc/`. It is a second encoder over the same server — same frames, same `authorize` / `onAttach` policy, same dispatch as the WebSocket path — so a plain page with an `EventSource` and `fetch()` needs no SDK at all. **SSE does not stream on deployed functions today.** The edge gateway buffers a response until it completes before returning it, so an open-ended `text/event-stream` never reaches the client — an `EventSource` against a deployed agent connects and then receives nothing. The RPC path over POST is unaffected, and SSE works normally in local development. Use the WebSocket path in production: it carries the same frames under the same `authorize` / `onAttach` policy, and `mountAgents` serves it on the same address, so the change is `new WebSocket(url)` in place of `new EventSource(url)` (or [AgentClient](/docs/agent-sdk/api-reference/agent-client), which does the reconnect and resume for you). This is a platform-side gap, tracked as **COMPUTE-819**; the SSE surface documented here stays and starts streaming once the gateway allows it. Construct one over the socket server the actor's `webSocket` delegates to, and route HTTP requests from the actor's `fetch` into [`fetch()`](#fetch): ```ts import { Agent, type Env } from "@telnyx/edge-runtime"; import { AgentSocketServer, type AgentServerSocket } from "@telnyx/edge-runtime/agent-socket"; import { AgentHttpServer } from "@telnyx/edge-runtime/agent-http"; export class Conversation extends Agent { private desk = new AgentSocketServer>(this, { getState: () => this.getState(), getMessages: () => this.messages.all(), authorize: (token) => (token === "secret" ? ["read", "rpc"] : ["read"]), }); private door = new AgentHttpServer(this.desk); async webSocket(ws: AgentServerSocket, req: Request): Promise { await this.desk.attach(ws, req); } override fetch(req: Request): Promise { return this.door.fetch(req); } } ``` On the SSE path the credential rides the `token` query parameter — an `EventSource` cannot set headers — and `subscribe` picks the streams (`subscribe=state,messages`; omitted = every stream the server offers). Each server frame becomes one SSE event, and the `id:` carries resume cursors so the browser's automatic reconnect replays only what it missed. On the RPC path the credential rides `Authorization: Bearer ` (preferred) or the same query parameter; the JSON-array request body is the argument list, and the response body is the same `result` / `error` frame a WebSocket caller would receive. `mountAgents` wires this up for you: a mounted agent's `GET` and `POST` requests are handed to its `fetch`, where an `AgentHttpServer` over the agent's connection engine answers them on `/agents//` — no routing code of your own. See [`mountAgents`](/docs/agent-sdk/api-reference/mount). Two limits, by design: SSE is one-way, so there are no server→client calls mid-stream (RPC is client-initiated POST only), and there is no ping/pong — an `EventSource` detects a dropped stream and reconnects by itself. ## fetch() ## handleRpc() ## handleSse() --- ### AgentClient > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/agent-client.md `AgentClient` is the client SDK for connecting to a live agent over WebSocket. It keeps a local mirror of the agent's persistent state — a snapshot on connect, incremental RFC 7396 merge-patches as the agent changes state — and makes typed RPC calls to the agent's remote-callable methods through `stub`. It ships at `@telnyx/edge-runtime/client` with no server dependencies: it uses the platform's native `WebSocket` (browser, or Node ≥ 22). ```ts import { AgentClient } from "@telnyx/edge-runtime/client"; type DeskStub = { humanReply(text: string): Promise; }; interface DeskState { status: string; } const agent = new AgentClient( "wss://my-func.telnyxcompute.com/agents/conversation/alice", ); agent.onState((s) => render(s)); await agent.stub.humanReply("On it — give me a minute."); ``` That URL is the address [`mountAgents`](/docs/agent-sdk/api-reference/mount) serves: `/agents` is its default `base`, `conversation` is a key of the mount map, and `alice` is the agent's routing name. A function that mounts its agents therefore needs no route of its own for this to resolve — see [Mounting Agents](/docs/agent-sdk/mounting-agents). Against a hand-written front door, use whatever address that front door serves. The client reconnects automatically with jittered exponential backoff and heartbeats the link, so a dead socket is detected and rebuilt. Calls made while the link is down are buffered and flushed once it is back; calls in flight when the link drops reject. Passing any of `token` / `subscribe` / `resume` switches the client into attach mode: every (re)connect opens the session with an `attach` frame presenting the token, and the server answers with the granted claims — readable via `claims` — and the accepted streams (`"state"`, `"messages"`, `"events"`). With `resume: true`, the client re-attaches after a drop with its last-seen `messages`/`events` cursors and the server replays exactly what was missed — no full re-snapshot, no duplicates. Without any attach option, the client speaks the plain protocol (snapshot + `hello`) and works against servers that only speak that. ```ts const agent = new AgentClient(url, { token: sessionToken, // the server derives this connection's claims from it subscribe: ["state", "messages", "events"], resume: true, // replay exactly what was missed across reconnects }); agent.onMessages(({ snapshot, appended }) => updateThread(snapshot, appended)); agent.onEvents((e) => progress(e.type, e.payload), { from: 1 }); // after the server's attached answer: agent.claims → e.g. ["read", "rpc"] ``` ## new AgentClient() ## stub ## claims ## onState() ## onMessages() ## onEvents() ## isConnected() ## close() ## AgentClientOptions ## AgentEvent ## Stream --- ### mountAgents > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/mount.md `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`. ```ts import { mountAgents } from "@telnyx/edge-runtime/mount"; import type { ActorNamespace } from "@telnyx/edge-runtime"; interface Env { CONVERSATION: ActorNamespace; } export default { fetch: mountAgents((env) => ({ conversation: env.CONVERSATION })), }; ``` **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, `//`, where `` is a key of your map and `` is the routing name of one instance. The **request** picks the transport; the path never changes: | Request | Address | Serves | |---|---|---| | `Upgrade: websocket` | `//` | the agent socket protocol | | `GET` | `//?subscribe=state` | Server-Sent Events | | `POST` | `///rpc/` | one RPC call | `base` defaults to `/agents`, so the map above serves `/agents/conversation/alice` on all three. **That is the URL the [AgentClient](/docs/agent-sdk/api-reference/agent-client) examples connect to** — `wss://my-func.telnyxcompute.com/agents/conversation/alice` is this default `base`, this mount key, and this name: ```ts import { AgentClient } from "@telnyx/edge-runtime/client"; // mount key ─────────────────────────┐ ┌─── routing name const agent = new AgentClient( "wss://my-func.telnyxcompute.com/agents/conversation/alice", ); ``` 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](/docs/agent-sdk/api-reference/agent-http-server) 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/` 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](/docs/agent-sdk/api-reference/agent-http-server) for the full caveat. Use the WebSocket path in production — same address, same policy. ## Two gates, not one The mount's [`authorize`](#mountoptions) does not replace the agent's own `authorize(token, req)`. It **composes with** it, and both run: 1. **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 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`](/docs/agent-sdk/api-reference/agent/connection#authorize) resolves this connection's [claims](/docs/agent-sdk/api-reference/agent/connection#claim) from it exactly as it would unmounted, and `onAttach` may still veto. Stamp identity at the mount; decide grants in the agent. ```ts export default { fetch: mountAgents((env) => ({ conversation: env.CONVERSATION }), { authorize: async (req, { mount, name }) => { const user = await verify(req.headers.get("authorization")); if (!user) return new Response("unauthorized", { status: 401 }); if (user.id !== name) return new Response("forbidden", { status: 403 }); return { "x-desk-user": user.id }; // stamped — overwrites any caller header }, }), }; ``` 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](/docs/agent-sdk/mounting-agents) guide walks the whole path, including composing the mount with routes of your own and the cross-origin opt-in. ## mountAgents() ## AgentMountMap ## MountOptions ## MountRoute ## MountCorsOptions ## MountAuthorizeResult ## MountHeadersInit ## MountTransport ## 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](/docs/agent-sdk/limits#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() ## decodeAgentName() ## MAX_AGENT_NAME_LENGTH --- ### Limits > Source: https://developers.telnyx.com/docs/agent-sdk/limits.md ## Execution budgets | Context | Budget | On overrun | |---|---|---| | Inbound RPC method (e.g. `receive()`) | 30s wall-clock (default) | Call fails with `ActorMethodTimeoutError` | | Queued / scheduled task (runs in `alarm()`) | Larger — on the order of minutes | Run counts as failed; task retries | Do LLM and outbound API work in tasks, not inbound methods — see [How Agents Run](/docs/agent-sdk/concepts/how-agents-run). Full platform numbers live in the [StatefulActor API reference](/docs/edge-compute/stateful-actors/api-reference/base). ## Task retries A task that throws retries up to its `maxRetries` (default **5**) with exponential backoff, then is parked — it stops retrying and no longer fires. `every()` tasks reset their attempt count after each successful run. ## Storage Message history and state live in the actor's persistent storage and are subject to its size caps — see the [storage reference](/docs/edge-compute/stateful-actors/api-reference/storage) and [where this is stored](/docs/agent-sdk/state#where-this-is-stored). Message history is append-only — there is no delete or trim API. `last(n)` reads a bounded window, but writes accumulate forever. For relational or queryable data, use the actor's embedded [SQL database](/docs/agent-sdk/sql) instead — up to 1 GB per agent. ## Actor id length An agent's name becomes an actor id, and that id is carried into names the platform builds for the instance behind it — including the file its persistent state is kept in, which is capped at 255 bytes. The budget is measured on the id **once URL-escaped**, so a `:` costs three characters rather than one, and the ceiling is **225** escaped characters: 225 ASCII characters, 54 two-byte, 36 three-byte, or 27 four-byte (emoji), plus 6 for every escaped run. [`encodeAgentName()`](/docs/agent-sdk/api-reference/mount#encodeagentname) and [`mountAgents()`](/docs/agent-sdk/api-reference/mount#mountagents) apply the rule for you — the two exports below are for a front door you wrote yourself. Worth knowing either way: an over-long id is not rejected on first use. The instance activates and accepts writes, and only becomes unreachable once it is evicted and rehydrated, so a name that is too long looks healthy until the first eviction. ## MAX_ESCAPED_ACTOR_ID_LENGTH ## escapedActorIdLength() ## What's shipped - `Agent` base class with message history, scheduled tasks, and merge-patch state - WebSocket termination via `webSocket()` — see [WebSockets](/docs/agent-sdk/websockets) - The agent socket layer *(SDK ≥ 0.10.0)* — [AgentClient](/docs/agent-sdk/api-reference/agent-client) for browser/Node clients (automatic reconnect, state mirroring, typed RPC) and [AgentSocketServer](/docs/agent-sdk/api-reference/agent-socket-server) on the agent side, with the [`@rpc()`](/docs/agent-sdk/api-reference/agent/rpc) opt-in - `BlobStore` for blob access within actors - The mount front door *(SDK ≥ 0.12.0)* — [`mountAgents()`](/docs/agent-sdk/api-reference/mount) routes every agent a function serves on one address shape; see [Mounting Agents](/docs/agent-sdk/mounting-agents) The `Agent` API surface is **Beta** and may change as pieces land. ---