# 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.md · Root index: https://developers.telnyx.com/llms.txt ## Subsections Focused per-subsection full-content files: - [Get Started](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-get-started-llms-full-txt.md) - [Concepts](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-concepts-llms-full-txt.md) - [Build](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-build-llms-full-txt.md) - [Examples](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-examples-llms-full-txt.md) - [Reference](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-reference-llms-full-txt.md) ## Get Started ### 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"); // durable 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 }; await env.SUPPORT.idFromName(from).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 - [Durable 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 --- ## Concepts ### 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 phone number (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). ## 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 durable survives restarts History, state, and pending tasks live in the actor's durable 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). --- ## Build ### Message History > Source: https://developers.telnyx.com/docs/agent-sdk/message-history.md `this.messages` is a persistent, ordered log of `AgentMessage` objects stored in the actor's KV. 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 durably. 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 }); ``` --- ### Durable 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. --- ### 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, durable **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 durable 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); }, }; ``` 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 about five minutes today**, measured from the handshake, even on a socket actively exchanging frames — surfacing as an abnormal close (code `1006`). The cap will be raised in a future update. 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 durability.** `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). ## What's in development This page is the platform surface, and it stays. In development on top of it: `AgentClient`, a browser SDK — browser clients connecting straight to an agent, automatic reconnect, and state sync driven by [`onStateChanged`](/docs/agent-sdk/api-reference/state) — with `onConnect()` on the `Agent` class as the seam it lands on. Until then, `webSocket()` is the supported path. --- ## Examples ### 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 durable 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 }>; }): 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.2", messages: [{ role: "system", content: SYSTEM_PROMPT }, ...history], }); 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, }; await env.CONVOS.idFromName(from).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/scheduling): 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: durable 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.2", 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 durable 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, }; await env.CONVOS.idFromName(from).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/scheduling): 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. --- ## Reference ### Overview > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference.md Everything the Agent SDK adds lives on the `Agent` base class, imported from `@telnyx/edge-runtime`: ```ts import { Agent } from "@telnyx/edge-runtime"; ``` The surface splits into four areas: | Area | Entry points | Reference | |---|---|---| | The base class itself | type parameters, hooks, reserved methods | [Agent Class](/docs/agent-sdk/api-reference/agent) | | Conversation history | `this.messages.*` | [Message Log](/docs/agent-sdk/api-reference/message-log) | | Durable timers | `this.queue()`, `this.schedule()`, `this.every()`, `this.cancelSchedule()`, `this.listSchedules()` | [Scheduling](/docs/agent-sdk/api-reference/scheduling) | | Durable state | `this.getState()`, `this.setState()`, `this.replaceState()` | [State](/docs/agent-sdk/api-reference/state) | Two rules apply across the whole surface: - **Every 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. - **`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/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). --- ### 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 durable 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). ## Properties | Property | Type | Description | |---|---|---| | `this.messages` | `MessageLog` | The conversation history — see [Message Log](/docs/agent-sdk/api-reference/message-log) | Plus everything inherited: `this.ctx`, `this.env`. ## Methods State and scheduling methods are listed with full semantics on their own pages: | Method | Returns | Reference | |---|---|---| | `getState()` | `Promise` | [State](/docs/agent-sdk/api-reference/state) | | `setState(patch)` | `Promise` | [State](/docs/agent-sdk/api-reference/state) | | `replaceState(next)` | `Promise` | [State](/docs/agent-sdk/api-reference/state) | | `queue(method, payload?, opts?)` | `Promise` | [Scheduling](/docs/agent-sdk/api-reference/scheduling) | | `schedule(seconds, method, payload?, opts?)` | `Promise` | [Scheduling](/docs/agent-sdk/api-reference/scheduling) | | `every(seconds, method, payload?, opts?)` | `Promise` | [Scheduling](/docs/agent-sdk/api-reference/scheduling) | | `cancelSchedule(id)` | `Promise` | [Scheduling](/docs/agent-sdk/api-reference/scheduling) | | `listSchedules()` | `Promise` | [Scheduling](/docs/agent-sdk/api-reference/scheduling) | ## 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 connects *(the connection layer is coming next — see [Limits](/docs/agent-sdk/limits))* | | `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. --- ### Message Log > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/message-log.md `this.messages` is the actor's durable 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. ## Types ```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.id } interface ToolCall { id: string; name: string; args: unknown; } // As persisted — what reads return: interface StoredMessage extends AgentMessage { seq: number; // monotonic, assigned on append at: Date; // wall-clock stamp, informational only } ``` ## Writing | Method | Returns | Semantics | |---|---|---| | `add(role, content)` | `Promise` | Shorthand for `append({ role, content })` | | `append(msg)` | `Promise` | Appends one `AgentMessage`, atomically assigns and returns its `seq` | | `appendMany(msgs)` | `Promise` | Appends a batch atomically, returns the last assigned `seq` | ## Reading | Method | Returns | Semantics | |---|---|---| | `all()` | `Promise` | Full history, chronological — paginates internally, no length cap | | `last()` | `Promise` | The single most-recent message | | `last(n)` | `Promise` | The last `n` messages, in chronological order | | `count()` | `Promise` | Total messages ever appended | `all()` reads the entire history every time — on a long-lived conversation prefer `last(n)` for a bounded context window. ## 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. --- ### Scheduling > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/scheduling.md Tasks are durable 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. ## Creating tasks | Method | Semantics | |---|---| | `queue(method, payload?, opts?)` | Run as soon as possible — identical to `schedule(0, ...)` | | `schedule(seconds, method, payload?, opts?)` | Run once, `seconds` from now | | `every(seconds, method, payload?, opts?)` | Run repeatedly at a fixed interval; throws if `seconds` is not positive | All three return `Promise` — the task id. ```ts await this.queue("process", { attempt: "first" }); await this.schedule(3600, "sendReminder"); await this.every(300, "checkStatus"); ``` ### `ScheduleOptions` ```ts interface ScheduleOptions { id?: string; // stable id: re-scheduling REPLACES the prior task (dedup) maxRetries?: number; // retries after the first delivery; default 5 } ``` With a stable `id`, scheduling is an upsert — the prior task with that id is replaced and the timer re-armed. Without one, every call creates a new task under a random id. ## 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. ## 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 durable 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). ## Inspecting and cancelling | Method | Returns | Semantics | |---|---|---| | `cancelSchedule(id)` | `Promise` | Remove a pending task; `false` if no such id | | `listSchedules()` | `Promise` | Every pending task record | ```ts interface TaskRecord { id: string; name: string; // the method it will call payload?: unknown; due: number; // epoch ms of the next fire everyMs?: number; // recurrence interval, if repeating attempts: number; // consecutive failed deliveries so far maxRetries: number; createdAt: Date; } ``` --- ### State > Source: https://developers.telnyx.com/docs/agent-sdk/api-reference/state.md Agent state is one durable 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() }); } } ``` ## Methods | Method | Returns | Semantics | |---|---|---| | `getState()` | `Promise` | The stored state, or `initialState()` if nothing has been written yet | | `setState(patch)` | `Promise` | Recursive-merges `patch` (RFC 7396) in a transaction; returns the merged state | | `replaceState(next)` | `Promise` | Writes `next` wholesale; returns it | - The merge is **recursive** (RFC 7396) — top-level keys are merged in, nested objects are deep-merged, and `null` deletes a key. - Values must be codec-safe, like all actor storage — see [Errors](/docs/edge-compute/stateful-actors/api-reference/errors) for `CodecError`. ## Hooks ### `initialState(): State` The default for a fresh actor. Called whenever state is read and nothing has been stored yet; the default implementation returns `{}`. Reading alone doesn't persist it — the first write does. ### `onStateChanged(next, prev)` Fires after every `setState()` resolves, with the merged state and the state before the patch. Override it to react to changes — mirror to an external system, log transitions, invalidate caches. `replaceState()` **does** fire this hook — the `next` argument is the new state and `prev` is the state before replacement. --- ### 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 durable storage and are subject to its key/value size caps — see the [storage reference](/docs/edge-compute/stateful-actors/api-reference/storage). 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. ## What's shipped vs. in development **Shipped today:** - `Agent` base class with message history, scheduled tasks, and merge-patch state - WebSocket termination via `webSocket()` — see [WebSockets](/docs/agent-sdk/websockets) - `BlobStore` for blob access within actors **In development:** - `AgentClient` browser SDK — browser clients connecting straight to an agent, with automatic reconnect and state sync on top of `onStateChanged`. `onConnect()` is the seam it lands on. The `Agent` API surface is **Beta** and may change as pieces land. ---