# 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: - [Overview](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-overview-llms-full-txt) - [Agent SDK](https://developers.telnyx.com/docs/development/llms/ai-agent-sdk-beta-agent-sdk-beta-llms-full-txt) ## Overview ### Overview > Source: https://developers.telnyx.com/docs/agent-platform.md The Agent Platform is a fully managed infrastructure for building, deploying and operating agents at scale. Agents can be built with no code at all, or with any popular framework. They are hosted on the Edge Compute platform, colocated with frontier open-weight models. Agents are governed under a centralized system of identity, access, observability and audit. Agents can be natively connected with voice, messaging, and SIM-enabled devices. They may be isolated from the internet while remaining securely connected to the user's networks via Virtual Private Cloud networking primitives. The Agent Platform comprises the following components. | Component | Description | |---|---| | [Agent Control Plane](/docs/agents) | Deploy, manage and operate all agents from the one place that ties the platform together. All agents are governed, observed and audited the same way, however they were built. | | [Agent SDK](/docs/agent-sdk) | Build agents in code, with the Telnyx harness or any popular framework. Full control over how agents reason and act, with the same hosting and operations as all other agents. | | [Agent Memory](/docs/agent-memory) | Give agents long-term memory. Every conversation picks up where the last one left off. | | Agent Gateway Coming Soon | Connect agents to models, tools and other agents through one governed entry point. Every request is authenticated, authorized and audited. | | [Agent Browser](/docs/agent-browser) | Let agents use the web the way a person does, in isolated browser sessions. | ## Next Steps - [Deploy the First Agent](/docs/agents/get-started) --- ## Agent SDK ### Overview > Source: https://developers.telnyx.com/docs/agent-sdk.md **Beta.** The Agent SDK is in beta. Its surface may change between releases, and the pages in this tab mark what is not finished yet. ## One Platform All agents are hosted on the [Agent Runtime Platform](/docs/agents/runtime-platform/edge-compute-serverless), whether they are configured or implemented in code. Its abstraction model is described there and holds for everything in this tab. ## Two Packages The Agent SDK is two packages. **The Agent Harness SDK.** `@telnyx/agent-harness`, the harness that runs the turns. [The Agent Harness SDK](/docs/agent-sdk/harness-sdk) documents it. **The Agent Runtime SDK.** `@telnyx/edge-runtime`, the runtime the harness runs on. The Agent Client SDK ships inside it as `@telnyx/edge-runtime/client`, for apps that reach an agent from outside it. [The Agent Runtime SDK](/docs/agent-sdk/runtime-sdk) documents it. Telnyx ships a managed harness as a reference implementation on the two SDKs. See [Deploy the First Agent](/docs/agents/get-started) for detail. ## Three Ways to Implement an Agent in Code From the least work to the most. Whichever way, the user is advised to understand the reference architecture in [Inside the Managed Harness](/docs/agent-sdk/inside/generated-code) first, because every class in this tab extends or replaces a part of it. **Extend the Managed Harness.** The user starts with the managed harness as it is, [exports the project](/docs/agents/cli/export) and customizes the class, which is [Extending the Managed Agent](/docs/agent-sdk/extending). **Bring a Framework.** The user brings the framework that already runs their agent, LangGraph or any other, with its own harness, or takes Telnyx's own, [The Agent Harness SDK](/docs/agent-sdk/harness-sdk), and writes the glue code against the Agent Runtime SDK. [Bring Your Own Framework](/docs/agent-sdk/examples/bring-your-own-framework) shows the glue code, and [the `Agent` class](/docs/agent-sdk/runtime-sdk) is the contract it fulfils. **Write Everything.** The user writes the framework, the harness and the glue code, on the runtime SDK alone. [Bring Your Own Framework](/docs/agent-sdk/examples/bring-your-own-framework#write-the-loop-by-hand) shows the smallest such loop. It is the most work, and the right choice only when no framework fits. ## The Stack Each layer is built on the one below it, and the pages of each layer show how it uses the one below. | Layer | Package or Product | Documented in | |---|---|---| | Configuration | `agent.json` and the `telnyx-acp` CLI | [Agent Control Plane](/docs/agents) | | The managed harness | `@telnyx/acp` | [Inside the Managed Harness](/docs/agent-sdk/inside/generated-code) | | The harness | `@telnyx/agent-harness` | [The Agent Harness SDK](/docs/agent-sdk/harness-sdk) | | The runtime | `@telnyx/edge-runtime` | [The Agent Runtime SDK](/docs/agent-sdk/runtime-sdk) | | The platform | [Stateful Actors](/docs/edge-compute/stateful-actors) and [Functions](/docs/edge-compute/overview) | [Agent Runtime Platform](/docs/agents/runtime-platform/edge-compute-serverless) | The layers reach other Telnyx products as they run. - [Inference](/docs/inference/models) serves the model the harness calls. - [Agent Memory](/docs/agent-memory) gives the managed harness a person's summary before a turn, and keeps each exchange after it. - [STT](/docs/voice/stt/overview) and [TTS](/docs/voice/tts/overview) carry a call's speech for the runtime's voice layer. - The [Voice API](/docs/voice/programmable-voice/voice-api-fundamentals) answers each call with a media stream to the instance, and transfers and hangs up calls. - [Messaging](/docs/messaging/messages/send-message) and [Email](/docs/messaging/email/overview) bring texts and emails in through their webhooks, and carry the replies out. --- ### The Generated Code > Source: https://developers.telnyx.com/docs/agent-sdk/inside/generated-code.md ```bash telnyx-acp deploy ``` ```text {2,3,5,6,7,8,26,28} support-agent/ ├── agent.json ◀ ├── instructions.md ◀ └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ◀ ├── telnyx-env.d.ts ◀ └── package.json ◀ @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts ◀ │ ├── code-entry.ts │ └── vendor.ts ◀ └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` A configuration-based agent is a class the user never sees. It extends `Agent` from the [Agent Runtime SDK](/docs/agent-sdk/runtime-sdk) and drives the [Agent Harness SDK](/docs/agent-sdk/harness-sdk). This chapter opens it in the order the CLI builds it. This page is the code `deploy` generates. [Configuration](/docs/agent-sdk/inside/configuration/model) follows each field of `agent.json` into that code. [The Harness](/docs/agent-sdk/inside/harness) is where those fields are plugged in, once per activation. [Request Handling](/docs/agent-sdk/inside/request-handling/rpc-methods) follows each inbound request to its handler. [Turn Taking](/docs/agent-sdk/inside/turn-taking/admit) is the loop itself, from the admitted message to the delivered reply. Every piece named here can be overridden by the user, and [Extending the Managed Agent](/docs/agent-sdk/extending) describes how. `create` writes no code; [Project Layout](/docs/agents/project-layout) shows the files. The class comes into being at `deploy`, which generates `.telnyx/index.ts` from `agent.json`, with `instructions.md` inlined so that the deployed function reads no files. For `support-agent` the file is this, with the spec abbreviated: ```ts // Generated by `telnyx-acp deploy`. Do not edit: it is rewritten on every deploy. import { mountAgents } from "@telnyx/edge-runtime/mount"; import { defineManagedAgent } from "@telnyx/acp/managed"; import { channelRoutes } from "@telnyx/acp/channels"; // agent.json, with instructions.md inlined: the deployed function reads no files. const spec = { name: "support-agent", model: "zai-org/GLM-5.3-Flash", instructions: "…" } as const; export class SupportAgent extends defineManagedAgent(spec) {} export default { fetch: mountAgents((env) => ({ "support-agent": env.SUPPORT_AGENT }), { fallback: channelRoutes([], { agent: "support-agent", binding: "SUPPORT_AGENT" }), }), }; ``` Three lines carry the agent. - `export class SupportAgent extends defineManagedAgent(spec) {}` is the agent. `defineManagedAgent(spec)` is the managed harness bound to this spec, and `SupportAgent` is the actor type the platform runs, one instance per conversation. - [`mountAgents`](/docs/agent-sdk/internals/mount/mountAgents) is the function's `fetch`. It serves every conversation at `/agents/support-agent/`. - `channelRoutes` is the fallback for everything else the function receives, which is the webhooks. With `web` set, the options also carry `web`, and the fallback serves the chat page at `/web` and the embed script at `/web/embed.js` before the webhook routes. `mountAgents(map, { fallback })` is [`mountAgents()`](/docs/agent-sdk/internals/mount/mountAgents). - `map` is the whole routing table. Its one entry serves the actor namespace `env.SUPPORT_AGENT` under the mount name `support-agent`. - `fallback` receives every request whose path is outside `/agents`. Beside it, `deploy` writes `telnyx.toml`, the manifest that names the actor type and its binding, `telnyx-env.d.ts` with the types, and a `package.json`. It then links `@telnyx/acp` and `@telnyx/edge-runtime` into `.telnyx/node_modules`, in `synth/vendor.ts`, so that the bundle built on this machine resolves them. With a phone number, or a Call Control application among the channels, the base is `defineManagedVoiceAgent(spec)` instead; `export` writes the same class into `src/agent.ts` for the user to own, and counts a TeXML application as answering calls too. The module names on the pages that follow, `managed/agent.ts` and the rest, are files of the `@telnyx/acp` package. --- ### Model > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/model.md ```bash telnyx-acp set model zai-org/GLM-5.3-Flash ``` ```text {2,5,13,14,31} support-agent/ ├── agent.json ◀ ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts ◀ │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ◀ ├── phone.ts ├── project.ts └── io.ts ``` `model` is written by `create --model` and changed by `set model`. On a terminal, `set model` with no value opens the same searchable list as the wizard. At `deploy` it becomes `spec.model` in `.telnyx/index.ts`, and [`buildHarness()`](/docs/agent-sdk/inside/harness) in `managed/agent.ts` builds the language model from it: ```ts // managed/agent.ts, buildHarness() const model = this.host.model ?? chatLanguageModel((this.host.completions ?? defaultCompletions)(this.env, this.spec), this.spec.model); ``` `model` becomes the harness spec's [`model`](/docs/agent-sdk/harness-sdk/create-harness#spec-model). It is an AI SDK v6 language model, which `chatLanguageModel` implements over the chat completions. `chatLanguageModel` and `telnyxChatCompletions` are in `managed/model.ts`. The call goes to the Telnyx inference endpoint through the `TELNYX` binding of the instance's [`env`](/docs/agent-sdk/interface/env), as `ai.openai.chat.createCompletion`, and for a third-party model the key stored as a secret is added to the request as `api_key`. A host that supplies `model` replaces the language model outright; one that supplies `completions` keeps `spec.model` and changes the endpoint it is served from. --- ### Instructions > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/instructions.md ```md instructions.md You are the support agent for Acme. Answer in one or two sentences. ``` ```text {3,5,13,15} support-agent/ ├── agent.json ├── instructions.md ◀ └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts ◀ │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` `instructions` names a file, and the file is edited directly; `set` does not touch it. At `deploy` its text is inlined into `spec.instructions` in `.telnyx/index.ts`, so the deployed function reads no files, and [`buildHarness()`](/docs/agent-sdk/inside/harness) in `managed/agent.ts` hands it to the harness, with the memory summary appended when memory is on: ```ts // managed/agent.ts, buildHarness() instructions: this.spec.memory ? withCard(this.spec.instructions, card) : this.spec.instructions, ``` `instructions` is the harness spec's [`instructions`](/docs/agent-sdk/harness-sdk/create-harness#spec-instructions), the model's system prompt on every step. The harness assembles nothing into it, so the summary is appended before the harness is built. `withCard` is in `managed/memory.ts`. It appends the line that tells the model it remembers people, then the profile's summary, bounded and fenced, or the first-conversation line when the profile has none: ```ts // managed/memory.ts export const MEMORY_CARD_MAX_CHARS = 2000; export function withCard(instructions, card) { const text = card?.trim(); const bounded = text && text.length > MEMORY_CARD_MAX_CHARS ? `${text.slice(0, MEMORY_CARD_MAX_CHARS).trimEnd()}\n[the notes continue; cut here]` : text; const known = bounded ? `${MEMORY_CARD_INTRO}\n\n${bounded}\n` : "This is your first conversation with this person; you know nothing about them yet."; return `${instructions.trimEnd()}\n\n${MEMORY_PREAMBLE}\n${known}`; } ``` `MEMORY_CARD_INTRO` says the notes are things the person said, kept for the model, and not instructions. The card summarizes what the person said, so a line planted in one conversation must not read as a rule in the next. The harness is built once per activation, so the summary it carries is the one read when the conversation started. --- ### Phone > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/phone.md ```bash telnyx-acp add phone +15550001234 ``` ```text {2,5,16,20,21,24} support-agent/ ├── agent.json ◀ ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts ◀ │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts ◀ │ ├── phone.ts ◀ │ ├── email.ts │ ├── web.ts │ └── wire.ts ◀ ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` `add phone ` appends the number to `phone`. Before anything ships, `checkNumbers` in `channels/phone.ts` looks each number up in the account, and a missing or inactive number stops the `deploy`. At `deploy` three things follow from it. - `provisionPhones` in `channels/phone.ts` puts the number on a Call Control application for calls and a messaging profile for texts, created and named ` (telnyx-acp)` when the number is bare. `wireChannels` in `channels/wire.ts` points each artifact's webhook at the function, with the previous webhook remembered in `agent.json` so that `remove phone` and `delete` can restore it. They delete only the artifacts `deploy` created. - Two routes, one per artifact type with `*` for the id, go into the list passed to `channelRoutes` in `.telnyx/index.ts`, because the artifacts are created after the function is built. `channels/index.ts` serves them at `/webhooks/call_control_application/` and `/webhooks/messaging_profile/`. - The base class becomes `defineManagedVoiceAgent(spec)` from `managed/voice.ts`, because the agent now answers calls. It wraps the managed class in the `voice()` mixin from [`@telnyx/edge-runtime/voice`](/docs/agent-sdk/runtime-sdk). [The Messaging Webhook](/docs/agent-sdk/inside/request-handling/messaging-webhook) and [The Call Webhook](/docs/agent-sdk/inside/request-handling/call-webhook) follow a text and a call from there. --- ### Channels > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/channels.md ```bash telnyx-acp add channel messaging_profile: ``` ```text {2,5,20,24} support-agent/ ├── agent.json ◀ ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts ◀ │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ◀ ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` `add channel :` appends an existing artifact to `channels`, by type and id, and never touches a number. At `deploy`, `wireChannels` in `channels/wire.ts` points the artifact's webhook at the function and remembers the previous value, and one route for it goes into the list passed to `channelRoutes` in `.telnyx/index.ts`. `channels/index.ts` serves every route at `/webhooks//` and verifies each webhook against the account's public key before reading it. A `messaging_profile` route runs `messagingWebhook`, a `call_control_application` route runs the call handler, and an `email_domain` route runs `emailWebhook`. **Beta.** A `texml_application` channel is declared and validated, but its handler is not implemented yet. A webhook to it answers 501. --- ### Voice > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/voice.md ```bash telnyx-acp set voice.greeting "Hello, how can I help?" telnyx-acp set voice.harness true ``` ```text {2,5,16,17,18} support-agent/ ├── agent.json ◀ ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts ◀ │ ├── direct-turn.ts ◀ │ └── filler.ts ◀ ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` `voice` is written by `set voice.*`, or its `greeting` by the wizard when the project has a number. It becomes `spec.voice` in `.telnyx/index.ts`, and four functions in `managed/voice.ts` read it: ```ts // managed/voice.ts export function answerOptions(spec) { const v = spec.voice ?? {}; return { eager: v.eager ?? true, ...(v.bargeIn !== undefined ? { bargeIn: v.bargeIn } : {}), ...(v.keypressInterrupts !== undefined ? { keypressInterrupts: v.keypressInterrupts } : {}) }; } export function speechOptionsFor(spec) { const v = spec.voice ?? {}; return { recognizer: { ...(v.stt ?? {}) }, synthesizer: { voice: v.tts?.voice ?? DEFAULT_TTS_VOICE, ...(v.tts?.voiceSettings ? { voiceSettings: v.tts.voiceSettings } : {}) } }; } export function defaultGreeting(spec) { return spec.voice?.greeting?.trim() || `Hi, this is ${spec.name.replace(/[-_]+/g, " ")}. How can I help?`; } export function harnessOnCalls(spec) { return spec.voice?.harness === true; } ``` - `answerOptions` is what the voice class passes to the `voice()` mixin's `answer` when the media socket opens, as [The Call Webhook](/docs/agent-sdk/inside/request-handling/call-webhook) shows. - `speechOptionsFor` feeds `speechProvidersFor`, which builds the STT and TTS providers. - `defaultGreeting` is returned by the voice class's `greeting`, and spoken when the call is answered. - `harnessOnCalls` chooses the turn engine, the direct engine in `managed/direct-turn.ts` by default and the harness when `voice.harness` is true. `answerOptions(spec)` sets the mixin's call options. - `eager` is on unless `voice.eager` is false. A turn then starts on STT's first guess that the caller has finished, and its audio is held until the guess is confirmed. - `bargeIn` and `keypressInterrupts` are passed only when `voice` sets them, and the mixin's own defaults apply otherwise. `speechProvidersFor` hands the speech options to the runtime SDK: ```ts // managed/voice.ts export function speechProvidersFor(spec, make = telnyxSpeechProviders) { return make(speechOptionsFor(spec)); } ``` `telnyxSpeechProviders(options)` is the Telnyx STT and TTS pair from [`@telnyx/edge-runtime/voice`](/docs/agent-sdk/runtime-sdk). Its `recognizer` option tunes STT and its `synthesizer` option tunes TTS. Both reach Telnyx over the runtime's own connection, with no credential in the class. A fifth, `fillerOptions` in `managed/filler.ts`, reads `voice.filler`: ```ts // managed/filler.ts export function fillerOptions(spec) { const f = spec.voice?.filler; if (f === false) return undefined; return { afterMs: f?.afterMs ?? DEFAULT_FILLER_AFTER_MS, phrases: f?.phrases?.length ? f.phrases : DEFAULT_FILLER_PHRASES, maxPerCall: f?.maxPerCall ?? 0 }; } ``` `fillerOptions` is what the voice class's `reply` uses to say one short phrase when the model is slow to start. `DEFAULT_FILLER_AFTER_MS` is 700, and the default phrases are "One moment.", "Let me check that for you." and "Sure, just a second.". [The Voice Loop](/docs/agent-sdk/inside/turn-taking/voice-loop) is where the phrase is spoken. [Voice](/docs/agents/voice) is the user-facing side of the same fields. --- ### Memory > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/memory.md ```bash telnyx-acp set memory '{ "type": "agent_memory", "namespace": "default", "profile": "{{conversation}}" }' ``` ```text {2,5,13,15} support-agent/ ├── agent.json ◀ ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts ◀ │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` The `memory` block is written by `set memory`, or by the wizard. It becomes `spec.memory` in `.telnyx/index.ts`, and each conversation's memory is built on demand by `memory()` in `managed/agent.ts`: ```ts // managed/agent.ts async memory() { const conversation = this.memoryConversation ?? (await this.getState()).from ?? this.conversationName(); if (conversation === undefined) return undefined; if (conversation !== this.memoryKey) { this.memoryKey = conversation; this.memoryInstance = createAgentMemory(this.spec.memory, this.env.TELNYX, conversation); } return this.memoryInstance; } ``` `this.getState()` is [`getState()`](/docs/agent-sdk/interface/getState). Its `from` is the sender that `admit` wrote. On a fresh instance it returns [`initialState()`](/docs/agent-sdk/interface/initialState), which the class defines as `{}`. `this.env.TELNYX` is the Telnyx binding of the instance's [`env`](/docs/agent-sdk/interface/env). Both calls on Agent Memory go through it, and without it there is no memory. The conversation key is the caller's number on a call, set by the voice class, or the sender of the last text or email, held in state. A conversation with neither, such as one reached by `invoke` or `chat`, is keyed on its own name, which `conversationName()` decodes from the instance's id: ```ts // managed/agent.ts conversationName() { try { return decodeAgentName(this.ctx.id); } catch { return undefined; } } ``` `decodeAgentName(this.ctx.id)` is [`decodeAgentName()`](/docs/agent-sdk/internals/mount/decodeAgentName). - `this.ctx.id` is the instance's actor id, from [`ctx`](/docs/agent-sdk/interface/ctx), which the mount encoded from the conversation's name. - It returns that name, such as `alice` for `invoke --conversation alice`. - It throws for an id the mount did not make. The key is then undefined, and the conversation has no memory. `createAgentMemory` in `managed/memory.ts` resolves `{{conversation}}` in the profile template to that key and makes two calls on Agent Memory. - The summary is read at the start, bounded by `CARD_TIMEOUT_MS` of one second. - The conversation is pushed at the end. [Memory](/docs/agents/memory) is the user-facing page. --- ### Tools, Approvals and Limits > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/tools.md ```bash telnyx-acp add tool web_search telnyx-acp add tool mcp --url https://mcp.deepwiki.com/mcp telnyx-acp set approvals. ask telnyx-acp set limits.steps 12 ``` ```text {2,5,13} support-agent/ ├── agent.json ◀ ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── tools.ts ◀ │ ├── mcp.ts ◀ │ ├── model.ts │ ├── memory.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` `add tool` writes `tools`, `set approvals.` writes `approvals`, and `set limits.steps` writes `limits`. All three reach [`buildHarness()`](/docs/agent-sdk/inside/harness) in `managed/agent.ts`, which `harness()` calls with the spec's approvals and `callHarness()` with every `ask` turned to `deny`: ```ts // managed/agent.ts, buildHarness(card, approvals) const { tools, activeTools, authorization } = applyApprovals({ ...specTools(this.spec, this.toolEnv()), ...(this.host.tools ?? {}) }, approvals, this.host.approvalTtlMs); // … limits: { steps: this.spec.limits?.steps ?? DEFAULT_STEPS }, ``` `specTools`, in `managed/tools.ts`, builds a tool set in the `ai` package's shape from the `tools` entries of `agent.json`: for a `web_search` entry, `web_search`, plus `fetch_page` unless `contents` is false and `research` when `research` is true, over the function's `TELNYX` binding; for an `mcp` entry, one tool per listed server tool, named `__`, its input schema the server's JSON Schema, each call made by the Streamable HTTP client in `managed/mcp.ts` with the secret read through `toolEnv().secret` at every request. A host's own tools are merged on top, a host tool of the same name winning. Every built tool catches its own failure and answers the model with `{ error }`, and writes one `toolCall` log line. `applyApprovals`, in `managed/agent.ts`, takes that tool set and the approval policy and returns the tools the harness may call, the ones marked `ask` that pause for a decision, and the authorization that decides them. The policy is one word per tool. | `approvals.` | Effect | |---|---| | `allow`, the default | The tool is offered as it is. | | `ask` | The tool is marked as needing approval, and its action is `agent.tools.`, with any character of the name outside letters and digits escaped. On a call it is `deny`: a call cannot wait for an approval. | | `deny` | The tool is left out of the set, and `activeTools` lists the rest. | `applyApprovals` and the step limit fill four fields of the harness spec. - `tools` is the spec's [`tools`](/docs/agent-sdk/harness-sdk/create-harness#spec-tools). An `ask` tool is passed with `needsApproval: true`, the one value the harness accepts. - `activeTools` is the spec's [`activeTools`](/docs/agent-sdk/harness-sdk/create-harness#spec-activetools), passed only when a tool is denied. - `authorization` is the spec's [`authorization`](/docs/agent-sdk/harness-sdk/create-harness#spec-authorization), passed only when a tool is marked `ask`. It maps every tool in the set to an action, as the harness requires, and carries the host's `approvalTtlMs`. - `limits.steps` is the spec's [`limits`](/docs/agent-sdk/harness-sdk/create-harness#spec-limits). A run whose last allowed step still calls a tool fails with `HARNESS_STEP_LIMIT`. `DEFAULT_STEPS` is 8, and a granted approval lives fifteen minutes by default, within the harness's bounds of five minutes and twenty-four hours. The managed classes attach neither the [scheduling tools](/docs/agent-sdk/harness-sdk/plug-ins/scheduling) nor the [workspace tools](/docs/agent-sdk/harness-sdk/plug-ins/workspace) of the harness package. **Beta.** An `agent_gateway` entry is validated and deployed, but `specTools` builds nothing for it: the gateway client has not landed, so its tools are not called. --- ### Secrets > Source: https://developers.telnyx.com/docs/agent-sdk/inside/configuration/secrets.md ```bash telnyx-acp add secret OPENAI_API_KEY ``` ```text {2,6,13,14,26,31} support-agent/ ├── agent.json ◀ ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ├── telnyx.toml ◀ ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts ◀ │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts ◀ │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ◀ ├── phone.ts ├── project.ts └── io.ts ``` `add secret NAME` prompts for the value, stores it on the platform, and appends the name to `secrets`. At `deploy`, `manifest()` in `synth/index.ts` writes one `[[secrets]]` entry per name into `.telnyx/telnyx.toml`, after the function's identity, the actor and the Telnyx binding: ```toml # Generated by `telnyx-acp deploy` from agent.json. Do not edit; never commit. name = "support-agent" main = "index.ts" compatibility_date = "2026-10-01" [edge_compute] func_id = "…" func_name = "support-agent" [[actors]] binding = "SUPPORT_AGENT" type = "SupportAgent" [telnyx] binding = "TELNYX" [[secrets]] binding = "OPENAI_API_KEY" name = "OPENAI_API_KEY" ``` The class reads each secret through `env.SECRETS.get(name)`, on the instance's [`env`](/docs/agent-sdk/interface/env). A third-party model's key is read by `modelKey` in `managed/agent.ts` under the name that `spec/model.ts` maps the provider to, and `managed/model.ts` sends it as `api_key` on each request. | Provider | Secret | |---|---| | `openai/*` | `OPENAI_API_KEY` | | `anthropic/*` | `ANTHROPIC_API_KEY` | | `google/*` | `GOOGLE_API_KEY` | | `groq/*` | `GROQ_API_KEY` | `modelKey` reads the secret: ```ts // managed/agent.ts async function modelKey(env, model) { const secret = modelKeySecret(model); if (!secret || !env.SECRETS) return undefined; try { return (await env.SECRETS.get(secret)) || undefined; } catch { return undefined; } } ``` `env.SECRETS` is the secrets binding of the instance's [`env`](/docs/agent-sdk/interface/env), present when `telnyx.toml` declares a secret. - `secret` is the name that `modelKeySecret` maps the provider to, as in the table. - A missing binding, a missing secret or a failed read leaves the key out. The inference API then answers with what it needs, and that answer is written to state as `lastError`. --- ### The Harness > Source: https://developers.telnyx.com/docs/agent-sdk/inside/harness.md ```text {13,14,15} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts ◀ │ ├── memory.ts ◀ │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` The harness is the apparatus the configuration is plugged into, the model, the tools, the instructions and the step limit, and every run of the turn taking is pulled through it. It is the harness SDK's [`createHarness()`](/docs/agent-sdk/harness-sdk/create-harness), built once per activation by `harness(card)` in `managed/agent.ts`: ```ts // managed/agent.ts harness(card) { return (this.harnessInstance ??= this.buildHarness(card, this.spec.approvals ?? {})); } buildHarness(card, approvals) { const { tools, activeTools, authorization } = applyApprovals({ ...specTools(this.spec, this.toolEnv()), ...(this.host.tools ?? {}) }, approvals, this.host.approvalTtlMs); const model = this.host.model ?? chatLanguageModel((this.host.completions ?? defaultCompletions)(this.env, this.spec), this.spec.model); return createHarness({ ports: this.buildPorts(), model: observeModelErrors(model, (message) => { this.modelError = message; }), tools, ...(activeTools ? { activeTools } : {}), ...(authorization ? { authorization } : {}), instructions: this.spec.memory ? withCard(this.spec.instructions, card) : this.spec.instructions, limits: { steps: this.spec.limits?.steps ?? DEFAULT_STEPS }, }); } ``` `callHarness(card)` builds a second harness for calls when a tool is marked `ask`: a turn on a call is not a durable run, and nobody can approve while the caller waits, so every `ask` becomes `deny` for that harness and the agent says so once in the logs, `{"voiceTools":"left out on calls","tools":[…]}`. Without any `ask`, calls use the very same instance. The class fills seven fields of the spec. - [`ports`](/docs/agent-sdk/harness-sdk/create-harness#spec-ports) is `buildPorts()`, the instance's own runtime ports. - [`model`](/docs/agent-sdk/harness-sdk/create-harness#spec-model) is the host's `model`, or else `chatLanguageModel` for `spec.model`. `observeModelErrors` wraps it so that a failed run can record the model's own error message. - [`tools`](/docs/agent-sdk/harness-sdk/create-harness#spec-tools) is the tool set as `applyApprovals` leaves it: the tools `specTools` builds from `agent.json` ([Tools, Approvals and Limits](/docs/agent-sdk/inside/configuration/tools)), with the host's merged on top. - [`activeTools`](/docs/agent-sdk/harness-sdk/create-harness#spec-activetools) is passed only when a tool is denied. - [`authorization`](/docs/agent-sdk/harness-sdk/create-harness#spec-authorization) is passed only when a tool is marked `ask`. - [`instructions`](/docs/agent-sdk/harness-sdk/create-harness#spec-instructions) is `spec.instructions`, with the memory summary appended when memory is on. - [`limits`](/docs/agent-sdk/harness-sdk/create-harness#spec-limits) is `{ steps }`, from `spec.limits.steps` or `DEFAULT_STEPS`. The host, the second argument of `defineManagedAgent(spec, host)`, supplies the tools and can replace the model. [Extending the Managed Agent](/docs/agent-sdk/extending#the-host) lists its fields. The ports are built over the instance itself: ```ts // managed/agent.ts constructor(ctx, env, spec, host = {}) { super(ctx, env); this.spec = spec; this.host = host; if (host.authorization) setAgentHarnessAuthorizationProvider(this, host.authorization); } // … buildPorts() { return createRuntimeAgentHarnessPorts(this, { ...(this.host.environmentKeys ? { environmentKeys: this.host.environmentKeys } : {}), }); } ``` `super(ctx, env)` is the runtime SDK's [`constructor()`](/docs/agent-sdk/interface/constructor), and `createRuntimeAgentHarnessPorts(this, { environmentKeys })` is [`createRuntimeAgentHarnessPorts()`](/docs/agent-sdk/harness-sdk/adapters/runtime). - `this` is the instance. The harness reaches its storage and scheduler only through the [ports](/docs/agent-sdk/harness-sdk/ports). - [`environmentKeys`](/docs/agent-sdk/harness-sdk/adapters/runtime#runtimeagentharnessportsoptions) is the host's. It names the bindings that cross into the ports, and none cross without it. - `setAgentHarnessAuthorizationProvider`, from `@telnyx/edge-runtime/internal`, registers the host's `authorization` as the instance's [authorization port](/docs/agent-sdk/harness-sdk/ports/authorization#on-the-agent-runtime). Approvals fail closed without it. The managed classes attach none of the harness SDK's plug-ins. A class of the user's own can add each of them. - [`createHarnessWithScheduling()`](/docs/agent-sdk/harness-sdk/plug-ins/scheduling) adds tools with which the model schedules its own follow-ups. - [`createVirtualWorkspace()`](/docs/agent-sdk/harness-sdk/plug-ins/workspace) adds a per-instance text store with skills, which the model reads and writes through tools. - [`HarnessMiddleware`](/docs/agent-sdk/harness-sdk/plug-ins/middleware) wraps a turn, a model step or a tool call. --- ### The RPC Methods > Source: https://developers.telnyx.com/docs/agent-sdk/inside/request-handling/rpc-methods.md ```bash telnyx-acp invoke support-agent --conversation alice "hi" telnyx-acp rpc support-agent --conversation alice approve ``` ```text {5,13} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` A POST to `/agents///rpc/` calls one method on the conversation's instance. [`mountAgents`](/docs/agent-sdk/internals/mount/mountAgents) resolves the address to the instance and hands the POST to the class's [`fetch()`](/docs/agent-sdk/interface/fetch), and only a method marked [`@rpc`](/docs/agent-sdk/interface/rpc) answers. Who may call is decided by [`authorize()`](/docs/agent-sdk/interface/authorize), which [The Streams](/docs/agent-sdk/inside/request-handling/streams) shows with `fetch`. The class marks five methods. ## receive() `invoke`, `chat` and `rpc … receive` post `["text"]` to `…/rpc/receive`: ```ts // managed/agent.ts async receive(text, from, key) { return this.admit(text, from, key); } ``` The reply is not returned from this call. It arrives through the task the harness queues and is read back on the state stream as `lastReply`, which is what `invoke` waits for. It is never sent out on a channel. `key` makes a redelivered message a no-op, answered with `duplicate: true`. [Admit](/docs/agent-sdk/inside/turn-taking/admit) is the next step. `receive` carries [`@rpc`](/docs/agent-sdk/interface/rpc), as `@rpc({ description: "Deliver one inbound message; the reply is produced off the request path" })`. - Only a method that carries it dispatches over the mount. Every other method of the class is private there. - `description` is what [`rpcSurface()`](/docs/agent-sdk/interface/rpcSurface) reports for the method. ## Approvals and Runs `approve`, `deny`, `inspectApproval`, `pendingApproval` and `run` are reached the same way, and through `telnyx-acp rpc`: ```ts // managed/agent.ts async approve(approvalId) { return (await this.ready()).approvals.grant(approvalId); } async deny(approvalId) { return (await this.ready()).approvals.deny(approvalId); } async inspectApproval(approvalId){ return (await this.ready()).approvals.inspect(approvalId); } async pendingApproval(runId) { const hit = await this.pendingApprovalFor(runId); return hit ? { id: hit.id, runId, toolName: hit.toolName } : undefined; } async run(runId) { return (await this.ready()).get(runId); } ``` Each reads or writes the harness's own ledger, which holds the pending approvals and the status of every run. A run paused on an `ask` continues on its own task once `approve` grants its approval. `pendingApproval` names the approval a paused run waits on, read from the ledger on that later call; `telnyx-acp invoke` asks for it when the state stream has not named it. `approvals.grant()`, `approvals.deny()` and `approvals.inspect()` are the harness's [`approvals`](/docs/agent-sdk/harness-sdk/harness/approvals) ledger, and `get(runId)` is the harness's [`get()`](/docs/agent-sdk/harness-sdk/harness/get). - `approvalId` names an approval, and each of the three returns its [`HarnessApproval`](/docs/agent-sdk/harness-sdk/harness/approvals#harnessapproval). - `deny()` also continues the run. The call is not executed, and the model is told it was denied. - Each of the three is checked against the authorization port for its [required action](/docs/agent-sdk/harness-sdk/harness/approvals#required-actions). The constructor registers that port from the host's `authorization`, as [The Harness](/docs/agent-sdk/inside/harness) shows. - `runId` names a run, and `get()` returns its [`HarnessRun`](/docs/agent-sdk/harness-sdk/harness/get#harnessrun), or `undefined` for an unknown id. - Each method calls `ready()` first, so the runs a restart interrupted are recovered before anything is read or decided. --- ### The Streams > Source: https://developers.telnyx.com/docs/agent-sdk/inside/request-handling/streams.md ```bash telnyx-acp invoke support-agent --conversation alice --stream "hi" ``` ```text {5,13} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` A WebSocket upgrade, or `GET …?subscribe=state`, on a conversation's address opens the stream of its state and messages. No turn runs. [`mountAgents`](/docs/agent-sdk/internals/mount/mountAgents) hands the upgrade to the runtime's default [`webSocket()`](/docs/agent-sdk/interface/webSocket), and the GET, like an RPC's POST, to the class's `fetch`: ```ts // managed/agent.ts fetch(req) { return (this.door ??= new AgentHttpServer(this.connectionEngine)).fetch(req); } ``` `fetch(req)` overrides the runtime SDK's [`fetch()`](/docs/agent-sdk/interface/fetch) with an [`AgentHttpServer`](/docs/agent-sdk/internals/the-http-server) over [`connectionEngine`](/docs/agent-sdk/interface/connectionEngine). - The server answers a GET with Server-Sent Events, and a POST to `…/rpc/` with one RPC call. - `connectionEngine` is the engine the default `webSocket()` runs every socket through, so both transports serve the same streams under the same `authorize`. - The server is built on the first request and kept for the activation. `authorize` decides who may read the streams and who may call the [`@rpc`](/docs/agent-sdk/interface/rpc) methods: ```ts // managed/agent.ts authorize(token, req) { return this.host.authorize ? this.host.authorize(token, req) : ["read", "rpc"]; } ``` `authorize(token, req)` overrides the runtime SDK's [`authorize()`](/docs/agent-sdk/interface/authorize). - `token` is the credential the caller presented, or `undefined` without one. - `req` is the request, with any identity headers a front door stamped on it. - The returned claims are the caller's grants, and a throw rejects the caller. The runtime acts on `"rpc"` alone, which admits the `@rpc` methods. - The runtime's own default returns `["read"]`, which admits the streams and no RPC. The voice class overrides `webSocket()` to answer calls: an upgrade whose address carries `?media=call` is a call's media, as [The Call Webhook](/docs/agent-sdk/inside/request-handling/call-webhook) shows, and any other upgrade is the agent socket protocol, as here. **Beta.** Without a host `authorize`, every caller is granted read and RPC. --- ### The Messaging Webhook > Source: https://developers.telnyx.com/docs/agent-sdk/inside/request-handling/messaging-webhook.md ```bash telnyx-acp text support-agent "hi" ``` ```text {5,13,20} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts ◀ │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` `POST /webhooks/messaging_profile/`. `channelRoutes` verifies the signature, then `messagingWebhook` in `channels/index.ts` reads the event: ```ts // channels/index.ts export async function messagingWebhook(event, env, route, options) { const data = event?.data; if (!data || data.event_type !== "message.received") return new Response("ok"); const p = data.payload ?? {}; const from = p["from"]?.phone_number, to = (p["to"] ?? [])[0]?.phone_number, text = p["text"], id = p["id"]; // …type checks; media-only messages and other profiles' messages are acknowledged and ignored const via = { kind: "messaging", profileId: route.id, from, to }; const result = await stubFor(env, options.binding, from).receiveVia(text, from, id, via); return Response.json({ ok: true, conversation: from, ...result }); } ``` The sender's number is the conversation, and `stubFor` addresses its instance exactly as the mount does: ```ts // channels/index.ts function stubFor(env, binding, conversation) { const ns = env[binding]; if (!ns || typeof ns.idFromName !== "function") throw new Error(`binding ${binding} is not an actor namespace`); return ns.idFromName(encodeAgentName(conversation)); } ``` `encodeAgentName(conversation)` is [`encodeAgentName()`](/docs/agent-sdk/internals/mount/encodeAgentName), the encoding the mount applies to the name in every address. - `conversation` is the sender's number, so the text reaches the same instance as `/agents/support-agent/`. - `env[binding]` is the actor namespace that `telnyx.toml` binds, and `idFromName` returns the stub of the instance with that id. The stub calls any public method of the class in-process. `receiveVia` in `managed/agent.ts` is the second ingress path, and deliberately not [`@rpc`](/docs/agent-sdk/interface/rpc), so only the function's own handlers, which verified the signature, can name where a reply goes: ```ts // managed/agent.ts async receiveVia(text: string, from: string, key: string, via: ReplyVia): Promise<{ runId: string; duplicate: boolean }> { assertReplyVia(via); // Written before the run exists, under the message's key: the run's task may // run the moment it is queued, and it finds its channel by the key it carries. await this.setState({ replyTo: { [key]: via } }); return this.admit(text, from, key); } ``` `this.setState()` is [`setState()`](/docs/agent-sdk/interface/setState). - The entry is keyed by the message's key, not the run's id, and written before `admit` queues the run, since the run's task may start the moment it is queued; [Delivery](/docs/agent-sdk/inside/turn-taking/delivery) reads it as `state.replyTo?.[run.key]`. - `setState()` merges the patch, so the new `replyTo` entry joins those of other messages. - A redelivered message carries the same key, so it overwrites its own entry with the same target and `admit` answers `duplicate: true`. - `noteRun` deletes the entry with `null` once the run ends. The message id is the key, so a redelivered webhook runs nothing. `via` is kept in state under the message key, before the run exists, and [Delivery](/docs/agent-sdk/inside/turn-taking/delivery) reads it when the run completes. --- ### The Call Webhook > Source: https://developers.telnyx.com/docs/agent-sdk/inside/request-handling/call-webhook.md ```text Dial +15550001234 from any phone. ``` ```text {5,16,20} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ◀ ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts ◀ │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts ◀ │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` `POST /webhooks/call_control_application/`. `channelRoutes` verifies the signature, then the call handler in `channels/index.ts` acts on `call.initiated` for an incoming leg. 1. It wakes the call's instance, `warmCall(from)` on the conversation named by the call's id, so that the instance is placed and running and the caller's memory read has begun. The caller hears ringing meanwhile, typically one to three seconds. 2. It answers the inbound leg with the media stream requested in the same command, so the media starts the moment the leg is up. 3. The media server opens a WebSocket to `/agents//?media=call`. [`mountAgents`](/docs/agent-sdk/internals/mount/mountAgents) hands it, query and all, to the instance's [`webSocket()`](/docs/agent-sdk/interface/webSocket), where the voice class answers the call. The `media=call` pair is what tells it a call's media from any other socket to the conversation: an upgrade without it is a client on the agent socket protocol, and goes to the text agent's `webSocket()` instead. The first two steps are the handler's own: ```ts // channels/index.ts, voiceWebhook() await warm(callControlId, typeof payload["from"] === "string" ? payload["from"] : undefined); await calls.command(callControlId, "answer", { client_state: INBOUND_LEG_STATE, stream_url: mediaStreamUrl(`wss://${host}`, agent, callControlId), // …/agents//?media=call stream_track: "inbound_track", // …a bidirectional PCMU stream at 8000 Hz, back to the same leg }); ``` `calls` is `createCallControlClient()` from [`@telnyx/edge-runtime/voice`](/docs/agent-sdk/runtime-sdk). Its `command` goes out over the runtime's own connection, so the function holds no key. `warm` calls `warmCall` on the instance's stub, as [The Messaging Webhook](/docs/agent-sdk/inside/request-handling/messaging-webhook) shows for a text. The instance takes the call in `managed/voice.ts`: ```ts // managed/voice.ts webSocket(ws, req) { // Only a call's media stream is answered with the voice layer: its address // carries `?media=call`. Any other upgrade is a client on the agent socket // protocol, which the text agent's `webSocket` serves. if (!isMediaUpgrade(req)) return super.webSocket(ws, req); this.answer(ws, answerOptions(spec)); const engine = this.turnEngine(); void (engine instanceof Promise ? engine : engine.warm()).catch(() => undefined); } warmCall(from) { this.nameCaller(from); const engine = this.turnEngine(); void (engine instanceof Promise ? engine : engine.warm()).catch(() => undefined); } ``` `webSocket(ws, req)` overrides the runtime SDK's [`webSocket()`](/docs/agent-sdk/interface/webSocket). - It reads the mark off the upgrade's address: `?media=call` is the media server's socket, answered with the voice layer and never handed to `super.webSocket()`; anything else is a client on the agent socket protocol and goes to `super.webSocket(ws, req)`, as on a text agent. - `this.answer(ws, answerOptions(spec))` is the `voice()` mixin's `answer`. It takes the media socket and the options from `voice`, and runs the call from then on. - Both methods start the turn engine's first reading, the harness's `ready()` or the direct engine's `warm()`, so that the first turn does not wait for it. - `warmCall` carries no [`@rpc`](/docs/agent-sdk/interface/rpc), so over the mount it is private, and only the handler reaches it. `streaming.started`, `streaming.failed` and `call.hangup` on the answered leg are logged as one line each; a call that connects but stays silent explains itself in `streaming.failed`. [The Voice Loop](/docs/agent-sdk/inside/turn-taking/voice-loop) is the turn taking that follows. --- ### Admit > Source: https://developers.telnyx.com/docs/agent-sdk/inside/turn-taking/admit.md ```text {13} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` Every ingress path ends at `admit` in `managed/agent.ts`, and from this point on the loop is the same whether the text came by `invoke`, by RPC, from the messaging webhook or from another channel. A call is the one exception, and [The Voice Loop](/docs/agent-sdk/inside/turn-taking/voice-loop) describes it. ```ts // managed/agent.ts async admit(text, from, key) { if (typeof text !== "string" || text.trim() === "") throw new TypeError("receive(text) needs a non-empty string"); if (from !== undefined) await this.setState({ from }); // before the harness is built: its instructions carry this caller's memory const harness = await this.ready(); const accepted = await harness.accept(text, { key: key ?? randomUUID() }); return { runId: accepted.runId, duplicate: accepted.duplicate }; } ``` Three things happen. - The sender is written to state first, so that the memory profile is known before the harness exists. - `ready()` returns the harness, building it on the first call of an activation and recovering any run a crash interrupted. - [`accept()`](/docs/agent-sdk/harness-sdk/harness/accept) admits one run under the key and queues it as a task on the instance. `this.setState({ from })` is [`setState()`](/docs/agent-sdk/interface/setState). It merges `from` into the state and leaves every other key as it was. `harness.accept(text, { key })` is [`accept()`](/docs/agent-sdk/harness-sdk/harness/accept). - `text` is the input the run executes. - `key` is the idempotency key. It is the caller's `key`, or a fresh UUID when none was given. - A key seen before returns the first run with `duplicate: true` and admits nothing. - `runId` and `duplicate` are what `admit` returns to the caller. `ready()` builds the harness and recovers it: ```ts // managed/agent.ts async ready(opts = {}) { const built = opts.calls ? this.callHarnessInstance : this.harnessInstance; let card; if (!built && this.spec.memory) { const memory = await this.memory(); card = await memory?.card(); } const harness = opts.calls ? this.callHarness(card) : this.harness(card); const shared = opts.calls && harness === this.harnessInstance; const run = () => harness.recover().then(() => undefined, (err) => { if (opts.calls && !shared) this.callRecovery = undefined; else this.recovery = undefined; throw err; }); if (opts.calls && !shared) await (this.callRecovery ??= run()); else await (this.recovery ??= run()); return harness; } ``` A call asks for `ready({ calls: true })`, which [The Voice Loop](/docs/agent-sdk/inside/turn-taking/voice-loop) covers: the harness for calls leaves out every tool marked `ask`, and is the same instance when there is none. `harness.recover()` is [`recover()`](/docs/agent-sdk/harness-sdk/harness/recover). - It queues the task of every run still to be executed, including the runs a restart interrupted. - Every entrypoint that touches the harness goes through `ready()`, so recovery runs once per activation, before any new work. - A recovery that throws is tried again by the next call. `admit` returns the run id at once, well inside the 30-second budget of the inbound call. [The Harness](/docs/agent-sdk/inside/harness) is what `ready()` built. --- ### The Run > Source: https://developers.telnyx.com/docs/agent-sdk/inside/turn-taking/run.md ```text {13} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` The run executes as a task on the instance. The harness's tasks arrive at `onTask` in `managed/agent.ts`, which hands them to the harness: ```ts // managed/agent.ts async onTask(name, payload, info) { if (name === DELIVERY_TASK) { await this.retryDelivery(payload); return; } // a reply's next try; see Delivery if (!name.startsWith(HARNESS_TASK_PREFIX)) { await super.onTask(name, payload, info); return; } const harness = await this.ready(); const before = (await this.messages.last())?.seq ?? 0; // runs are dispatched one at a time, so what the log gains is this run's this.modelError = undefined; const run = await harness.dispatch({ name, payload }); await this.noteRun(run, before); } ``` `onTask(name, payload, info)` overrides the runtime SDK's [`onTask()`](/docs/agent-sdk/interface/onTask), the fallback for a task whose name is not a method of the class. - The harness's task names start with `HARNESS_TASK_PREFIX`, `__telnyx_agent_harness_`, and name no method of the class, so the tasks arrive here. - `DELIVERY_TASK`, `__telnyx_acp_deliver`, is the next try at a reply the channel refused; [Delivery](/docs/agent-sdk/inside/turn-taking/delivery) has the window. - Any other task goes to `super.onTask`, the runtime's default, which treats it as delivered. `this.messages.last()` is [`last()`](/docs/agent-sdk/interface/messages#last) on the message log. Runs are dispatched one at a time, so every message past the `seq` it returns is this run's. `harness.dispatch({ name, payload })` is [`dispatch()`](/docs/agent-sdk/harness-sdk/harness/dispatch). - `name` and `payload` are the task as the harness queued it. - It executes the run's turn and returns the run's [`HarnessRun`](/docs/agent-sdk/harness-sdk/harness/get#harnessrun) as the turn left it. Inside `dispatch`, three things happen. - The model is called with the instructions and the conversation's message log. - Tools run, subject to their approvals. - Each assistant message is appended to the log as it lands. When `dispatch` returns, `noteRun` writes the outcome to state, through the runtime SDK: ```ts // managed/agent.ts async noteRun(run, before) { if (!run) return; const state = await this.getState(); const last = await this.messages.last(); const reply = last && last.seq > before && last.role === "assistant" && last.content.trim() !== "" ? last.content : undefined; // … const at = this.now(); const patch = {}; // …the outcome's fields, as in the table below if (Object.keys(patch).length) await this.setState(patch); // …delivery, then the memory push } ``` - [`getState()`](/docs/agent-sdk/interface/getState) returns the runs already noted, so a redelivered task does not note a run twice. - [`last()`](/docs/agent-sdk/interface/messages#last) returns the newest message. It is the reply only when its `seq` is past `before`, its role is `assistant` and it has text. - [`now()`](/docs/agent-sdk/interface/now) is the time a completed or failed outcome records as `at`. - [`setState()`](/docs/agent-sdk/interface/setState) merges the patch. A `null` in it deletes a key, as it deletes `awaitingApprovalRunId` once that run ends. The outcome decides the keys written. | Outcome | State | |---|---| | Completed with a reply | `lastReply`, `lastRunId`, and an entry in `pendingDeliveries` under the run id when the message came in through a channel | | Completed with nothing to say | `lastRunId`, `failedRunId`, `lastError: "EMPTY_REPLY"` | | Failed | `lastRunId`, `failedRunId`, `lastError` with the model's own message when it threw | | Paused on an `ask` | `awaitingApprovalRunId` | The state stream carries each of these, which is how `invoke` ends with a reply, or with a line that says why there is none. With memory on, a completed run's exchange is pushed to Agent Memory under the run's id. [Delivery](/docs/agent-sdk/inside/turn-taking/delivery) is the last step. --- ### Delivery > Source: https://developers.telnyx.com/docs/agent-sdk/inside/turn-taking/delivery.md ```text {13} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts ◀ │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts │ ├── direct-turn.ts │ └── filler.ts ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` A reply to a message that came in through a channel goes back the same way. `noteRun` finds the `via` that `receiveVia` stored under the message's key before the run was admitted, records the reply in `pendingDeliveries` under the run id, and hands it to `sendPending`, the first of its tries: ```ts // managed/agent.ts, noteRun() const via = state.replyTo?.[run.key] ?? state.replyTo?.[run.id]; // …the outcome is written to state, pendingDeliveries[run.id] with it const pending = patch["pendingDeliveries"]?.[run.id] ?? pendingFor(state, run.id); const error = errorFor(state, run.id); // a retry already on the clock, or final const waiting = error && !error.final && error.nextAt !== undefined && error.nextAt > this.now(); if (pending && !delivered(state, run.id) && run.status === "completed" && !waiting) await this.sendPending(pending, (error?.attempts ?? 0) + 1); ``` `state` is what [`getState()`](/docs/agent-sdk/interface/getState) returned at the start of `noteRun`, and the writes go through [`setState()`](/docs/agent-sdk/interface/setState). - `replyTo` holds the `via` that `receiveVia` stored, under the message's idempotency key before the run was queued, so a run that completes at once still finds it. - `pendingDeliveries[run.id]` is written with the outcome before `deliver` runs, so a redelivered task still finds it; one entry per run, so a second run's reply never displaces a first run's that is still waiting on a retry. - `runStarts[run.id]` is the message-log position written before the run's first dispatch; a redelivered task reads the same boundary, so its own reply is never mistaken for one already there. - `deliveredRuns` lists the runs sent, and a `null` under `pendingDeliveries` deletes the entry; `sendPending` writes both once `deliver` returns. A redelivered task continues the attempt count instead of starting a second chain. `deliver` sends the reply: ```ts // managed/agent.ts async deliver(text, via) { if (text.trim() === "") return; // nothing to say is not a message const telnyx = this.env.TELNYX; if (!telnyx) throw new Error(`replying on ${via.kind === "email" ? "an email inbox" : "a messaging profile"} needs the TELNYX binding ([telnyx] in telnyx.toml)`); if (via.kind === "messaging") { await telnyx.messages.send({ from: via.to, to: via.from, text, messaging_profile_id: via.profileId }); } else { // The inbox reply action: recipients, In-Reply-To and References come from the message it answers. await telnyx.post(`/email_inboxes/${encodeURIComponent(via.inboxId)}/messages/${encodeURIComponent(via.messageId)}/actions/reply`, { body: { text } }); } } ``` `this.env.TELNYX` is the Telnyx binding of the instance's [`env`](/docs/agent-sdk/interface/env), declared by `[telnyx]` in `telnyx.toml`. It holds no API key, and the runtime adds the credential before each request leaves. For a text the reply is sent from the number the text was sent to, back to the sender, on the same messaging profile. For an email the reply goes through the inbox's reply action on the message that came in, and the platform threads and addresses it. A `deliver` that throws never fails the run's task; the reply has a retry window of its own: ```ts // managed/agent.ts export const DELIVERY_TASK = "__telnyx_acp_deliver"; export const DELIVERY_RETRY_SECONDS = [30, 60, 120, 300, 600, 1200, 1800]; // about an hour in all private async sendPending(pending, attempt) { try { await this.deliver(pending.text, pending.via); } catch (err) { const status = httpStatus(err); // the API's status, or undefined for no answer const retry = retryableDelivery(status) && attempt <= DELIVERY_RETRY_SECONDS.length; const delay = retry ? DELIVERY_RETRY_SECONDS[attempt - 1] : undefined; console.log(JSON.stringify({ delivery: retry ? "failed" : "gave up", run: pending.runId, via: pending.via, attempt, status, error: err.message, retryInSeconds: delay })); if (!retry) { await this.setState({ pendingDeliveries: { [runId]: null }, deliveryErrors: { [runId]: { …, final: true } } }); return; } await this.setState({ deliveryErrors: { [runId]: { …, nextAt: this.now() + delay * 1000 } } }); await this.schedule(delay, DELIVERY_TASK, { runId: pending.runId, attempt: attempt + 1 }); return; } await this.setState({ deliveredRuns: [...deliveredRuns, runId], pendingDeliveries: { [runId]: null }, deliveryErrors: { [runId]: null } }); } ``` - `retryableDelivery` is true for no answer, a 429 or a 5xx; any other 4xx is final at once, since the API would refuse it again. - Each try is one log line, `delivery: "failed"` with `retryInSeconds` or `delivery: "gave up"`, with the channel, the target, the status and the API's reason. - `deliveryErrors[run.id]` in state says what stands between the person and that reply: `status`, `message`, `attempts`, `nextAt` while a try is due, `final` once given up. It is cleared when the reply goes out. (An agent deployed before this shape held one `pendingDelivery`/`deliveryError` slot; those are read as one entry and cleared by the next write.) - The `DELIVERY_TASK` fires through [`schedule()`](/docs/agent-sdk/interface/schedule) and arrives at `onTask`, which [The Run](/docs/agent-sdk/inside/turn-taking/run) shows; `retryDelivery` finds the `pendingDelivery` for that run and calls `sendPending` with the next attempt. A reply that went out in the meantime, or a run that already delivered, is not delivered twice. - The window spent is final too: after the seventh wait the pending entry is dropped and `final` is set on its error. - A run whose approval expired is reconciled by `ready()` after `recover()`: it gets `failedRunId` and `lastError`, and its `replyTo` entry is forgotten, so the state stream shows the outcome. A reply to `invoke` or `chat` is never delivered anywhere, since it had no channel. It is read from the state stream. `deliver` is overridable, which is how a class changes where replies go. --- ### The Voice Loop > Source: https://developers.telnyx.com/docs/agent-sdk/inside/turn-taking/voice-loop.md ```text {16,17,18} support-agent/ ├── agent.json ├── instructions.md └── .telnyx/ written by deploy ├── index.ts ├── telnyx.toml ├── telnyx-env.d.ts └── package.json @telnyx/acp/ the package ├── managed/ │ ├── index.ts │ ├── agent.ts │ ├── model.ts │ ├── memory.ts │ ├── tools.ts │ ├── mcp.ts │ ├── voice.ts ◀ │ ├── direct-turn.ts ◀ │ └── filler.ts ◀ ├── channels/ │ ├── index.ts │ ├── phone.ts │ ├── email.ts │ ├── web.ts │ └── wire.ts ├── synth/ │ ├── index.ts │ ├── code-entry.ts │ └── vendor.ts └── spec/ ├── schema.ts ├── model.ts ├── phone.ts ├── project.ts └── io.ts ``` A call does not pass through `admit`. The `voice()` mixin from [`@telnyx/edge-runtime/voice`](/docs/agent-sdk/runtime-sdk) runs STT and TTS, takes each end of turn from STT, and asks the class for one thing per utterance, a turn engine. By default that is the direct engine in `managed/direct-turn.ts`. The turn goes straight to the model with the conversation held in memory, and the confirmed exchange is written behind the call. With `voice.harness` true the harness itself is the engine. Each turn then runs the harness's model and tools as a direct turn, not as a persistent run, at the cost of a second or more before the first word. `defineManagedVoiceAgent` in `managed/voice.ts` wraps the managed class in the mixin: ```ts // managed/voice.ts export function defineManagedVoiceAgent(spec, host = {}) { const Base = defineManagedAgent(spec, host); class ManagedVoiceAgent extends voice(Base) { greeting(call) { this.nameCaller(call.from); // the caller's number names their memory profile; the summary read starts here return defaultGreeting(spec); } // turnEngine, speechProviders, reply, turnEnded, callEnded, warmCall … } return ManagedVoiceAgent; } ``` The mixin answers the call, runs barge-in and the keypad, and ends the call. The class overrides its hooks, and adds `warmCall` of its own. - `warmCall` runs before the call is answered, as [The Call Webhook](/docs/agent-sdk/inside/request-handling/call-webhook) shows. - `greeting` is spoken when the call is answered. The mixin's own says nothing. - `turnEngine` returns the engine above. The mixin's own returns none. - `speechProviders` builds the STT and TTS providers from `spec.voice`. - `reply` wraps the mixin's own reply in `withFiller` from `managed/filler.ts`. When the caller is confirmed done and `voice.filler.afterMs` pass without a word from the model, one short phrase is spoken as its own sentence, and the reply follows. The phrase is said, never written, so it reaches neither the message log nor the caller's memory. - `turnEnded` logs the turn's clock and writes the exchange to the [message log](/docs/agent-sdk/interface/messages) after the reply has finished playing. - `callEnded` writes the last reply to state with [`setState()`](/docs/agent-sdk/interface/setState) and, with memory on, pushes the call to Agent Memory under its id. Each turn reaches the engine through `reply`: ```ts // managed/voice.ts reply(transcript, call) { const base = super.reply(transcript, call); if (!this.filler) return base; // …the same reply, wrapped in withFiller } // … turnEngine() { const self = this; if (harnessOnCalls(spec)) return self.ready({ calls: true }); return (this.direct ??= createDirectTurnEngine({ model: voiceModel(spec, host, self.env), // …instructions, history and record })); } ``` `super.reply(transcript, call)` is the mixin's own `reply`, which hands the transcript to the engine that `turnEngine()` returns. - `transcript` is STT's first pass at the caller's words, and `call.confirmed` resolves to the confirmed words. - While the turn is speculative, the mixin calls the engine's `begin()` with the first pass, then commits the confirmed words or discards the turn. - Otherwise it calls the engine's `turn()` with the confirmed words, and the result's `text` is spoken. - A reply the caller cuts short is still a turn. The direct engine remembers the caller's words and what was said of the reply before the cut, with the note `[cut off by the caller]` after it, in its history and in the journal; once playback reports what the caller heard, the entry is corrected to that. The next turn's context holds both, so an interrupted "Sure, what is the new…" and the caller's "12 Main St" line up. With `voice.harness` true the engine is `ready({ calls: true })`, the recovered harness for calls, and both calls are its own. That harness leaves out every tool marked `ask`, since a call cannot wait for an approval; without any `ask` it is the same instance texts use. - [`begin()`](/docs/agent-sdk/harness-sdk/harness/begin) streams provisional text, with no writes or tools until the turn is committed. - [`turn()`](/docs/agent-sdk/harness-sdk/harness/turn) runs the input to completion and records the turn in the message log. By default the direct engine answers both calls itself, with the AI SDK's `streamText` and no harness. Over the mount, the voice class is the managed class unchanged except for [`webSocket()`](/docs/agent-sdk/interface/webSocket), which answers an upgrade marked `?media=call` with the voice layer and hands every other socket to the agent socket protocol as the managed class would, with the same six RPC methods. [Extending the Managed Agent](/docs/agent-sdk/extending#the-voice-hooks) has the hooks' signatures. --- ### Extending the Managed Agent > Source: https://developers.telnyx.com/docs/agent-sdk/extending.md `@telnyx/acp/managed` exports the classes the CLI generates for a configuration project. A code-based agent extends them. | Export | What It Is | |---|---| | `defineManagedAgent(spec, host?)` | A class bound to the spec: the managed loop for texts and `invoke`. | | `defineManagedVoiceAgent(spec, host?)` | The same, wrapped in the `voice()` mixin from [`@telnyx/edge-runtime/voice`](/docs/agent-sdk/runtime-sdk), which answers calls. | | `ManagedAgent` | The loop itself, for a class that builds its own host in the constructor. | | `ReplyVia`, `ManagedAgentSpec`, `ManagedAgentHost` | The types. | Every class is a runtime SDK [`Agent`](/docs/agent-sdk/runtime-sdk), so [`this.messages`](/docs/agent-sdk/interface/messages), [`getState()`](/docs/agent-sdk/interface/getState) and [`setState()`](/docs/agent-sdk/interface/setState), [`queue()`](/docs/agent-sdk/interface/queue), [`schedule()`](/docs/agent-sdk/interface/schedule), [`@rpc`](/docs/agent-sdk/interface/rpc) methods and the rest of its API are there to use. ## The Host The second argument is what `agent.json` cannot say: ```ts import { ManagedAgent, type ManagedAgentHost, type ManagedEnv } from "@telnyx/acp/managed"; import type { ActorContext } from "@telnyx/edge-runtime"; import { tool } from "ai"; import { z } from "zod"; const spec = { name: "support-agent", model: "zai-org/GLM-5.3-Flash", instructions: "…", approvals: { refund_order: "ask" } } as const; const host: ManagedAgentHost = { tools: { refund_order: tool({ description: "Refund an order", inputSchema: z.object({ orderId: z.string() }), execute: async ({ orderId }) => ({ refunded: orderId }), }), }, authorization: myAuthorizationProvider, // who may approve; without it the default allows every decision under the agent's own identity approvalTtlMs: 15 * 60 * 1000, // how long a granted approval stays usable (5 min to 24 h) }; export class SupportAgent extends ManagedAgent { constructor(ctx: ActorContext, env: ManagedEnv) { super(ctx, env, spec, host); } } ``` | Host Field | Meaning | |---|---| | `tools` | A tool set, in the `ai` package's shape. `spec.approvals` marks each tool. An `ask` tool pauses the run until `approve` or `deny`, and a `deny` tool is withheld. | | `authorization` | Identity and policy for approvals, registered as the harness's [authorization port](/docs/agent-sdk/harness-sdk/ports/authorization). | | `approvalTtlMs` | 5 minutes to 24 hours. | | `model` | A language model used outright, instead of `spec.model` over the platform. | | `completions` | Chat completions for `spec.model`, when the platform's endpoint is not the right one. | | `environmentKeys` | Which bindings cross into the harness ports' [`environment`](/docs/agent-sdk/harness-sdk/ports/environment) (none by default). | | `authorize(token, req)` | Who may read the streams and call `@rpc` methods over the mount. Everyone, unless the class narrows it. | ### Approvals, End to End A run that reaches a tool marked `ask` pauses, and the state records the paused run as `awaitingApprovalRunId`; `invoke` ends with a line naming that run instead of waiting. Under `dev`, the line also carries the approval's id. A decision is one RPC on the conversation: ```bash telnyx-acp rpc support-agent --conversation alice inspectApproval telnyx-acp rpc support-agent --conversation alice approve telnyx-acp rpc support-agent --conversation alice deny ``` Under `dev`, approvals are authorized by a fixed local identity that allows every decision, and a paused run is printed with the `rpc … --local approve ` line to decide it. Deployed, a host that passes no `authorization` gets the loop's built-in default: every decision is allowed, under the agent's own identity (`agent:`), so whoever the mount admits to `rpc` decides (everyone, in the beta phase). Pass `authorization` to require a real caller identity and policy; the harness itself refuses a run with an `ask` tool when no provider is registered at all. ## What to Override ```ts import { defineManagedAgent, type ReplyVia } from "@telnyx/acp/managed"; import { rpc } from "@telnyx/edge-runtime"; export class SupportAgent extends defineManagedAgent(spec) { /** Where a reply goes out. The default sends a text on the profile the message came in on. */ protected override async deliver(text: string, via: ReplyVia): Promise { await super.deliver(text, via); await this.queue("audit", { text }); // the class's own persistent follow-up } /** An RPC of the class's own, reachable over the mount and with `telnyx-acp rpc`. */ @rpc({ description: "The last reply" }) async lastReply(): Promise { return (await this.getState()).lastReply; } protected override async onTask(name: string, payload: unknown, info: { attempt: number }): Promise { if (name === "audit") return; // handle the class's own; everything else is the loop's await super.onTask(name, payload, info); } } ``` The loop keeps its six `@rpc` methods, `receive`, `approve`, `deny`, `inspectApproval`, `pendingApproval` and `run`. Methods without `@rpc` are private over the mount, which is how the webhook handler's `receiveVia` (which names where a reply goes) is reachable in-process only. ## The Voice Hooks `defineManagedVoiceAgent(spec)` answers calls. Its hooks, all overridable: | Hook | When | Default | |---|---|---| | `greeting(call)` | the call is answered | `spec.voice.greeting`, or "Hi, this is <name>. How can I help?" | | `turnEngine()` | each turn | the direct engine (model + in-memory history); the harness with `voice.harness` | | `reply(transcript, call)` | each reply | the mixin's reply wrapped in `withFiller`, which says one short phrase when the model is slow to start, tuned by `voice.filler` | | `speechProviders()` | the call starts | STT and TTS, tuned from `spec.voice` | | `turnEnded(metrics, call)` | a turn's playout ends | logs the turn's clock, writes the exchange | | `callEnded(call, reason)` | hang-up | writes the last reply, ingests the call into memory | | `warmCall(from?)` | before the call is answered | starts the instance, names the caller, begins the first turn's reading | `call.from` and `call.to` are the numbers, `call.callControlId` the call. [The Voice Loop](/docs/agent-sdk/inside/turn-taking/voice-loop) shows how the class drives the mixin. ## Dropping to the SDK A class that extends the runtime SDK's `Agent` directly, or `voice(Agent)`, is also a valid entry. The CLI deploys any exported agent class and wires the declared channels to it, and the loop is left to the user. The webhook handlers then call `receiveVia(text, from, key, via)` on that class for a text and open the media socket to it for a call. A class of the user's own implements them as the managed loop does, or as [Bring Your Own Framework](/docs/agent-sdk/examples/bring-your-own-framework) shows. --- ### The Package > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk.md The Agent Harness SDK is the package `@telnyx/agent-harness`. Its export is `createHarness(spec)`, which builds a harness from `spec`. Anything that implements the turn taking drives the harness. The harness never touches the runtime directly. It dictates what it needs from the runtime layer through the ports contract, and whatever implements the contract can host the harness; see [Ports](/docs/agent-sdk/harness-sdk/ports) for detail. | Export | What it is | |---|---| | `@telnyx/agent-harness` | `createHarness`, the ports contract, and the runtime adapter that builds ports from an instance of the Agent Runtime SDK's [`Agent`](/docs/agent-sdk/runtime-sdk) class, and a generic channel contract for discrete-message channels. | | `@telnyx/agent-harness/node` | A plain-Node ports adapter for tests, in memory with a file-backed SQLite database. | | `@telnyx/agent-harness/contract` | The executable ports contract, version 2, that any host adapter must pass. | The pages document the harness in three parts. **The Interface** is what is exposed to the layer above, the call that builds a harness and the harness it returns. | Group | What it is for | |---|---| | [createHarness()](/docs/agent-sdk/harness-sdk/create-harness) | Build a harness from a spec. | | [Turns](/docs/agent-sdk/harness-sdk/harness/turn) | Run one input through the model and the tools. | | [Runs](/docs/agent-sdk/harness-sdk/harness/accept) | Admit inputs as persistent runs, execute and recover them, and decide their approvals. | **Plug-ins** are the parts the package ships for plugging into the harness. | Plug-in | What it is for | |---|---| | [Virtual Workspace](/docs/agent-sdk/harness-sdk/plug-ins/workspace) | Give the model a per-instance text store with skills, as tools. | | [Scheduling](/docs/agent-sdk/harness-sdk/plug-ins/scheduling) | Let the model schedule its own follow-ups. | | [Middleware](/docs/agent-sdk/harness-sdk/plug-ins/middleware) | Wrap a turn, a model step or a tool call. | **The Internals** is how the harness is implemented over the ports contract. | Group | What it is | |---|---| | [Ports](/docs/agent-sdk/harness-sdk/ports) | What the harness requires from the runtime. | | [Adapters](/docs/agent-sdk/harness-sdk/adapters/runtime) | Implementations of the ports contract, and the suite that checks one. | | [Persistence](/docs/agent-sdk/harness-sdk/persistence/run-ledger) | The run ledger and the step memo behind persistent runs. | | [Channels](/docs/agent-sdk/harness-sdk/channels/router) | A generic contract for discrete-message channels. | **Beta.** `@telnyx/agent-harness` is at 0.1.0-beta.2. It depends on `@telnyx/edge-runtime` `^0.16.1` and requires Node 22.13 or later. Its tools are AI SDK tools from `ai` 6.0.266, the exact version it depends on, and a tool set built with another version of `ai` does not type-check against it. --- ### createHarness() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/create-harness.md ## spec.ports ## spec.model Any AI SDK provider supplies the model. A failed call to it surfaces as [`HarnessProviderError`](/docs/agent-sdk/harness-sdk/harness/turn#harnessprovidererror), which keeps only allow-listed diagnostics. ## spec.models The [runtime configuration](#runtime-configuration) selects one for a turn by its name. ## spec.tools Tools are keyed by the name the model calls them by. `createHarness()` throws a `TypeError` unless every tool follows these rules. - It has an `execute` function. - It is an ordinary function tool, not a dynamic or provider tool. - It sets `needsApproval` only to `true`. - It uses no deferred results, no `toModelOutput` and none of the `onInput` hooks. A tool with `needsApproval: true` runs only inside a persistent run. A direct `turn()` whose tools include one throws `TypeError("Harness approval-capable tools require durable runs")`. Such a turn starts with [`accept()`](/docs/agent-sdk/harness-sdk/harness/accept) and executes through [`run()`](/docs/agent-sdk/harness-sdk/harness/run). ## spec.activeTools When it is given, only the named tools are offered to the model. The [runtime configuration](#runtime-configuration) can offer another subset for a turn. ## spec.authorization Both fields are required when any tool sets `needsApproval`. `createHarness()` throws a `TypeError` when a rule is broken. - `toolActions` maps each tool in `spec.tools` to an action, and names no other tool. - An action is one or more runs of letters and digits joined by dots, such as `orders.refund`, at most 512 bytes. - `approvalTtlMs` is an approval's lifetime from its request, in milliseconds, from 300000 (5 minutes) to 86400000 (24 hours). [`approvals`](/docs/agent-sdk/harness-sdk/harness/approvals) decides each held call. ## spec.instructions The text is the model's system prompt on every step. The harness assembles nothing into it, so its content is decided before the harness is built. - [`assemblePrompt()`](/docs/agent-sdk/harness-sdk/plug-ins/workspace#assembleprompt) on a virtual workspace builds it from a base text and skills. - The [runtime configuration](#runtime-configuration) replaces it for a turn. ## spec.output `output` is an AI SDK output definition, such as `Output.object({ schema })` from `ai`. The model's final text is parsed through it, and the parsed value is returned as `output` in the [`HarnessTurnResult`](/docs/agent-sdk/harness-sdk/harness/turn#harnessturnresult), beside `text`. A harness with `output` or `outputs` cannot take [`begin()`](/docs/agent-sdk/harness-sdk/harness/begin) turns. ## spec.outputs The [runtime configuration](#runtime-configuration) selects one for a turn by its name. ## spec.limits `steps` is a positive integer that bounds the model steps of one turn. When the last allowed step still calls a tool, the turn throws [`HarnessStepLimitError`](/docs/agent-sdk/harness-sdk/harness/turn#harnesssteplimiterror). The [runtime configuration](#runtime-configuration) can replace it for a turn. ## spec.middleware Each list wraps one phase of a turn. - `turn` wraps the whole turn. - `modelStep` wraps each call to the model, and requires a model object rather than a model id. - `tool` wraps each tool execution. A list runs in its given order, its first entry outermost. An entry that does not call `next` ends the phase with its own result. A harness with `turn` or `modelStep` middleware cannot take [`begin()`](/docs/agent-sdk/harness-sdk/harness/begin) turns. [`HarnessMiddleware`](/docs/agent-sdk/harness-sdk/plug-ins/middleware) documents the contexts. ## spec.durability A host leaves `durability` unset. Its seams exist for recovery tests. ## Runtime Configuration At the start of every turn, the harness reads one key of the instance's state, `__telnyx_agent_harness`, and resolves the turn's configuration from it. ```ts await this.setState({ __telnyx_agent_harness: { config: { model: "fast", limits: { steps: 4 } } }, }); ``` - `model` and `output` name an entry of `spec.models` and `spec.outputs`. - `instructions` replaces the static text. - `tools` names the tools offered, from `spec.tools`, in place of `spec.activeTools`. - `limits.steps` replaces the step limit. Every field is optional, and a field left out or `null` takes its static value. A value that does not validate, such as one with an unknown name or key, is ignored whole, and the last valid configuration stands. --- ### turn() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/turn.md ## HarnessTurnOptions ## HarnessTurnResult ## HarnessUsage ## HarnessStep ## HarnessStepLimitError ## HarnessProviderError ## HarnessProviderDiagnostics ## HarnessToolOutputError ## HarnessCancellationError ## HarnessObserverError --- ### begin() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/begin.md ## HarnessSpeculativeTurn ## HarnessSpeculationConflictError ## HarnessSpeculationUnsupportedError --- ### accept() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/accept.md ## HarnessRunAcceptance ## HarnessRunAcceptanceOptions `key` is the required idempotency key. It is a non-blank string of at most 512 bytes. A repeat call with the same key and the same input returns the existing run with `duplicate` set to `true`. A repeat call with the same key and different input throws. `admit` is an optional admission check. It runs inside the admission transaction, after the key lookup and before the run is inserted. When it returns `false`, no run is inserted and `accept()` rejects. `journalSeq` is an optional actor-journal sequence. The harness records it as the run's journal boundary before the run executes. It must be a non-negative safe integer, and any other value throws. --- ### dispatch() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/dispatch.md --- ### run() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/run.md --- ### recover() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/recover.md --- ### get() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/get.md ## HarnessRun --- ### status() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/status.md ## HarnessRunStatus | Status | Meaning | |---|---| | `accepted` | Admitted, and waiting for its task. | | `running` | Executing in a task. | | `awaiting_approval` | Paused on a tool call that needs approval. | | `resume_pending` | Its approvals decided, and waiting for the task that continues it. | | `completed` | Finished, with its reply in the message log. | | `failed` | Ended by an error, which `failureCode` classifies, such as `HARNESS_STEP_LIMIT`. | | `canceled` | Ended by `cancel()`. | | `outcome_unknown` | Interrupted or canceled while a tool call was under way. | ## HARNESS_RUN_STATUSES --- ### list() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/list.md ## HarnessRunListOptions --- ### cancel() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/cancel.md --- ### approvals > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/harness/approvals.md ## inspect() ## grant() ## deny() ## Required Actions Each decision is checked against the [authorization port](/docs/agent-sdk/harness-sdk/ports/authorization), which must allow one action. Without the port, every decision fails closed. | Operation | Action | |---|---| | `inspect()` | `agent_harness.approvals.read` | | `grant()` | `agent_harness.approvals.grant` | | `deny()` | `agent_harness.approvals.deny` | | The approved call | The tool's action from [`toolActions`](/docs/agent-sdk/harness-sdk/create-harness#spec-authorization) | ## HarnessApproval ## HarnessApprovalStatus | Status | Meaning | |---|---| | `pending` | Requested and undecided. | | `granted` | Approved, and usable until `expiresAt`. | | `denied` | Refused. | | `expired` | Not decided, or not used, within its lifetime. The run then fails with `failureCode` `HARNESS_APPROVAL_EXPIRED`. | | `consumed` | Used by the call it approved. | ## HARNESS_APPROVAL_STATUSES ## HarnessAuthorizationError | Code | Meaning | |---|---| | `identity_unavailable` | There is no authorization port, or it gives no identity. | | `unauthenticated` | The identity names no Service Account or organization. | | `forbidden` | The policy denies the action. | | `policy_unavailable` | The policy gives no decision. | ## HarnessApprovalError | Code | Meaning | |---|---| | `expired` | Its lifetime has passed. | | `replayed` | The same decision is already recorded. | | `conflicting` | The opposite decision is already recorded. | | `consumed` | The approved call has already executed. | | `missing` | The approval record the decision needs does not exist. | --- ### createVirtualWorkspace() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/plug-ins/workspace.md ## createVirtualWorkspaceTools() | Tool | Effect | |---|---| | `read_file` | Reads a file, cut at `maxOutputBytes` and then marked `truncated`. | | `write_file` | Writes a file. With `expectedRevision`, only while the file is at that revision. | | `edit_file` | Replaces a text that occurs exactly once in a file, under the same guard. | | `list_files` | Lists the files in path order. | | `find_files` | Lists the files whose content contains a text. | | `grep_files` | Returns the lines that contain a text, with their paths and line numbers. | | `activate_skill` | Returns one skill's document as data. | The results of the file tools carry `origin: "workspace"`, and the result of `activate_skill` carries `origin: "activated-skill"`. A path is relative, such as `notes/order.md`, and at most 128 bytes. ## read() ## write() ## edit() ## list() ## find() ## grep() ## journal() ## discoverSkills() A skill is the document at `skills//SKILL.md`, written like any other file. Nothing in a skill is executed. ## activateSkill() ## assemblePrompt() ## VirtualWorkspaceLimits | Limit | Default | Bounds | |---|---|---| | `maxFileBytes` | 64 KiB | One file. | | `maxWorkspaceBytes` | 1 MiB | Every file together. | | `maxOutputBytes` | 16 KiB | One result, the assembled prompt included. | | `maxResults` | 100 | The entries of one listing or search. | ## WorkspaceEntry ## WorkspaceRead ## WorkspaceWriteOptions ## WorkspaceJournalEntry ## WorkspaceSkill ## WorkspaceSkillCatalog ## ActivatedWorkspaceSkill ## PromptSource ## WorkspaceToolEntry ## ToolVirtualWorkspace --- ### createHarnessWithScheduling() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/plug-ins/scheduling.md ## createHarnessScheduling() ## schedule() ## listSchedules() ## cancelSchedule() ## dispatch() ## recover() ## tools ## HarnessSchedule ## HarnessScheduleRequest ## HarnessScheduleCancellation --- ### HarnessMiddleware > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/plug-ins/middleware.md ## runMiddleware() ## HarnessTurnMiddlewareContext ## HarnessModelStepMiddlewareContext ## HarnessToolMiddlewareContext ## HarnessMiddlewareError --- ### The Contract > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports.md A port is one capability of the runtime behind an interface the package defines. `ports` is the full set, `AgentHarnessPorts` version 2 of the contract, and it is the runtime plugged into the harness. The harness reaches the instance's storage and scheduler only through it. The raw environment, the actor context and the actor storage never cross the boundary. | Port | What it is | |---|---| | `identity` | The instance's id, asserted by the host and never taken from an input. | | `activation` | An opaque token, stable for one live activation of the instance. | | `clock` | The host's clock, used by queue and schedule operations. | | `messages` | The message log, with `append`, `appendMany`, `all`, `last` and `count`. | | `state` | The state, with `get`, `merge` and `replace`. | | `events` | The event log, with `emit`, `read` and `count`. | | `tasks` | The task scheduler, with `queue`, `schedule`, `every`, `cancel` and `list`, delivered at least once. | | `sql` | The per-instance SQL surface, `exec` and `transactionSync`. | | `environment` | An explicitly selected, immutable bag of bindings. Nothing crosses by default. | | `authorization` | The gateway-attested identity and policy that approval decisions require. Optional, and approvals fail closed without it. | On the Agent Runtime, [the runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the ports from members of the [`Agent`](/docs/agent-sdk/runtime-sdk) class. ## Building Ports `createRuntimeAgentHarnessPorts(agent, { environmentKeys })` builds the ports from an instance of the Agent Runtime SDK's [`Agent`](/docs/agent-sdk/runtime-sdk) class, through a seam the runtime owns and without reflecting over the class. `environmentKeys` names the bindings that cross into `environment`, and none do unless named. For tests, `createNodeAgentHarnessPorts(options)` from `@telnyx/agent-harness/node` is a deterministic plain-Node implementation, in memory for the log, state, events and tasks, and file-backed through `node:sqlite` for SQL. `defineAgentHarnessPortsContract(label, factory)` from `@telnyx/agent-harness/contract` registers the shared contract suite against any adapter, so a host of the user's own is checked against the same semantics as the runtime's. The contract module imports `vitest`, an optional peer dependency, so `vitest` must be installed wherever the suite runs. The suite asserts fixed fixture values. The factory's ports must report the identity id `"contract-agent"`, a clock reading of `1_700_000_000_000` and the environment `{ MODEL: "deterministic-model" }`. --- ### ports.messages > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/messages.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts const last = ((count) => count === undefined ? host.messages.last() : host.messages.last(count)); const messages = Object.freeze({ append: (message) => host.messages.append(message), appendMany: (batch) => host.messages.appendMany(batch), all: () => host.messages.all(), last, count: () => host.messages.count(), }); ``` - `append()` calls [`this.messages.append()`](/docs/agent-sdk/interface/messages#append), which assigns the message's `seq` and writes its row in one commit. - `appendMany()` calls [`this.messages.appendMany()`](/docs/agent-sdk/interface/messages#appendmany), which commits the whole batch together. - `all()` calls [`this.messages.all()`](/docs/agent-sdk/interface/messages#all), which returns the whole history in order. - `last()` calls [`this.messages.last()`](/docs/agent-sdk/interface/messages#last), with the count when one is given. - `count()` calls [`this.messages.count()`](/docs/agent-sdk/interface/messages#count). The log is append-only, so the count is also the `seq` of the last message. - [`AgentMessage`](/docs/agent-sdk/interface/messages#agentmessage) and [`StoredMessage`](/docs/agent-sdk/interface/messages#storedmessage) are the runtime SDK's own types. ## AgentHarnessMessagePort --- ### ports.state > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/state.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts const state = Object.freeze({ get: () => host.getState(), merge: (patch) => host.mergeState(patch), replace: (next) => host.replaceState(next), }); ``` - `get()` calls [`getState()`](/docs/agent-sdk/interface/getState). Until a state is written, it returns [`initialState()`](/docs/agent-sdk/interface/initialState). - `merge()` calls [`setState()`](/docs/agent-sdk/interface/setState), which `host` carries as `mergeState`. The patch follows RFC 7396, so `null` deletes a key. - `replace()` calls [`replaceState()`](/docs/agent-sdk/interface/replaceState). Nothing of the previous state survives. - `merge()` and `replace()` run the class's [`onStateChanged()`](/docs/agent-sdk/interface/onStateChanged) after the write commits. ## AgentHarnessStatePort --- ### ports.events > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/events.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts const events = Object.freeze({ emit: (type, payload) => host.events.emit(type, payload), read: (afterSeq, limit) => host.events.read(afterSeq, limit), count: () => host.events.count(), }); ``` - `emit()` calls [`this.events.emit()`](/docs/agent-sdk/interface/events#emit), which assigns the event's `seq` and writes its row in one commit. - `read()` calls [`this.events.read()`](/docs/agent-sdk/interface/events#read). `afterSeq` is exclusive, and only retained events are returned. - `count()` calls [`this.events.count()`](/docs/agent-sdk/interface/events#count), the number of events ever emitted. It includes events already pruned. - [`StoredEvent`](/docs/agent-sdk/interface/events#storedevent) is the runtime SDK's own type. ## AgentHarnessEventPort --- ### ports.tasks > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/tasks.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts const tasks = Object.freeze({ delivery: "at-least-once", queue: async (method, payload, scheduleOptions) => host.queue(method, payload, scheduleOptions), schedule: async (delaySeconds, method, payload, scheduleOptions) => host.schedule(delaySeconds, method, payload, scheduleOptions), every: async (intervalSeconds, method, payload, scheduleOptions) => host.every(intervalSeconds, method, payload, scheduleOptions), cancel: (id) => host.cancelSchedule(id), list: () => host.listSchedules(), }); ``` - `queue()` calls [`queue()`](/docs/agent-sdk/interface/queue), for a task due at once. - `schedule()` calls [`schedule()`](/docs/agent-sdk/interface/schedule), for a task due after a delay. - `every()` calls [`every()`](/docs/agent-sdk/interface/every), for a task that recurs at an interval. - `cancel()` calls [`cancelSchedule()`](/docs/agent-sdk/interface/cancelSchedule), which removes a pending task. - `list()` calls [`listSchedules()`](/docs/agent-sdk/interface/listSchedules), which returns a [`TaskRecord`](/docs/agent-sdk/interface/listSchedules#taskrecord) for every pending task. - With a stable `id` in its [`ScheduleOptions`](/docs/agent-sdk/interface/schedule#scheduleoptions), a call to `queue()`, `schedule()` or `every()` replaces the pending task with that id. - `delivery` declares the scheduler's own guarantee. A task survives crashes and restarts. It can be delivered more than once. - The harness's task names begin with `__telnyx_agent_harness_` and name no method of the class, so they reach [`onTask()`](/docs/agent-sdk/interface/onTask). Its default does nothing and counts the task as delivered. ## AgentHarnessTaskPort --- ### ports.sql > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/sql.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts sql: Object.freeze({ exec(query, ...bindings) { return host.sql.exec(query, ...bindings); }, transactionSync(fn) { return host.transactionSync(fn); }, }), ``` - `exec()` calls `exec()` on [`this.ctx.storage.sql`](/docs/agent-sdk/interface/ctx), the instance's own SQLite database. It rejects transaction-control statements such as `BEGIN`. - `transactionSync()` calls `this.ctx.storage.transactionSync()`. If its function throws, every statement the function ran is rolled back. ## AgentHarnessSqlPort --- ### ports.identity > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/identity.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts const host = getAgentHarnessHost(agent); // … identity: Object.freeze({ id: host.id }), ``` - `id` is [`this.ctx.id`](/docs/agent-sdk/interface/ctx), the id the Agent Runtime assigned to the instance. ## AgentHarnessIdentity --- ### ports.clock > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/clock.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts const host = getAgentHarnessHost(agent); // … clock: Object.freeze({ now: () => host.now() }), ``` - `now()` calls the class's [`now()`](/docs/agent-sdk/interface/now). The task scheduler reads time through the same method, so an override of `now()` sets the time for both. ## AgentHarnessClock --- ### ports.authorization > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/authorization.md ## On the Agent Runtime The [`Agent`](/docs/agent-sdk/runtime-sdk) class of the runtime SDK has no public member for authorization. [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) omits the port unless the host registers a provider for the instance through `setAgentHarnessAuthorizationProvider()`, from `@telnyx/edge-runtime/internal`. ```ts // runtime-adapter.ts import { getAgentHarnessHost, getAgentHarnessAuthorizationProvider } from "@telnyx/edge-runtime/internal"; // … const authorization = getAgentHarnessAuthorizationProvider(agent); // … ...(authorization === undefined ? {} : { authorization }), ``` A registered provider is passed through as the port. ## HarnessAuthorizationPort ## AgentHarnessAuthorizationIdentity --- ### ports.environment > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/environment.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the port from `host`, which carries the protected members of the `Agent` class. ```ts // runtime-adapter.ts const host = getAgentHarnessHost(agent); // … const environment = host.selectEnvironment(options.environmentKeys ?? []); // … environment, ``` - `environment` holds only the bindings of [`this.env`](/docs/agent-sdk/interface/env) that `environmentKeys` names, in a frozen object. --- ### ports.activation > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/activation.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) builds the token from the `Agent` object itself. ```ts // runtime-adapter.ts const activations = new WeakMap(); function activationFor(agent) { let activation = activations.get(agent); if (activation === undefined) { activation = Object.freeze({}); activations.set(agent, activation); } return activation; } ``` - The token is one frozen object per `Agent` object. Ports built twice on one object share it. - The Agent Runtime calls the [`constructor()`](/docs/agent-sdk/interface/constructor) on every activation. Each activation therefore has a new `Agent` object and a new token. - The harness keeps its in-memory claims on runs and steps per token. A new activation starts without them. --- ### ports.version > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/ports/version.md ## On the Agent Runtime [The runtime adapter](/docs/agent-sdk/harness-sdk/adapters/runtime#on-the-agent-runtime) takes `version` from the constant. No member of the `Agent` class is involved. ```ts // runtime-adapter.ts import { AGENT_HARNESS_PORTS_VERSION } from "./ports.js"; // … version: AGENT_HARNESS_PORTS_VERSION, ``` ## AGENT_HARNESS_PORTS_VERSION --- ### createRuntimeAgentHarnessPorts() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/adapters/runtime.md ## On the Agent Runtime `getAgentHarnessHost(agent)`, from `@telnyx/edge-runtime/internal`, hands the harness the protected members of the [`Agent`](/docs/agent-sdk/runtime-sdk) class. The harness therefore works on the same stores as the class's own methods. ```ts // runtime-adapter.ts import { getAgentHarnessHost, getAgentHarnessAuthorizationProvider } from "@telnyx/edge-runtime/internal"; import { AGENT_HARNESS_PORTS_VERSION } from "./ports.js"; // … export function createRuntimeAgentHarnessPorts(agent, options = {}) { const host = getAgentHarnessHost(agent); const authorization = getAgentHarnessAuthorizationProvider(agent); const environment = host.selectEnvironment(options.environmentKeys ?? []); // … messages, state, events and tasks const ports = { version: AGENT_HARNESS_PORTS_VERSION, identity: Object.freeze({ id: host.id }), activation: activationFor(agent), clock: Object.freeze({ now: () => host.now() }), ...(authorization === undefined ? {} : { authorization }), messages, state, events, tasks, sql: Object.freeze({ exec(query, ...bindings) { return host.sql.exec(query, ...bindings); }, transactionSync(fn) { return host.transactionSync(fn); }, }), environment, }; return Object.freeze(ports); } ``` | Port | Built from | |---|---| | [`messages`](/docs/agent-sdk/harness-sdk/ports/messages) | [`this.messages`](/docs/agent-sdk/interface/messages) | | [`state`](/docs/agent-sdk/harness-sdk/ports/state) | [`getState()`](/docs/agent-sdk/interface/getState), [`setState()`](/docs/agent-sdk/interface/setState) and [`replaceState()`](/docs/agent-sdk/interface/replaceState) | | [`events`](/docs/agent-sdk/harness-sdk/ports/events) | [`this.events`](/docs/agent-sdk/interface/events) | | [`tasks`](/docs/agent-sdk/harness-sdk/ports/tasks) | [`queue()`](/docs/agent-sdk/interface/queue), [`schedule()`](/docs/agent-sdk/interface/schedule), [`every()`](/docs/agent-sdk/interface/every), [`cancelSchedule()`](/docs/agent-sdk/interface/cancelSchedule) and [`listSchedules()`](/docs/agent-sdk/interface/listSchedules). The harness's tasks name no method of the class, so they reach [`onTask()`](/docs/agent-sdk/interface/onTask). | | [`sql`](/docs/agent-sdk/harness-sdk/ports/sql) | The instance's own SQLite database, [`this.ctx.storage.sql`](/docs/agent-sdk/interface/ctx), and `this.ctx.storage.transactionSync()` | | [`identity`](/docs/agent-sdk/harness-sdk/ports/identity) | The instance's id, [`this.ctx.id`](/docs/agent-sdk/interface/ctx) | | [`clock`](/docs/agent-sdk/harness-sdk/ports/clock) | [`now()`](/docs/agent-sdk/interface/now) | | [`environment`](/docs/agent-sdk/harness-sdk/ports/environment) | [`this.env`](/docs/agent-sdk/interface/env), restricted to the `environmentKeys` option | | [`activation`](/docs/agent-sdk/harness-sdk/ports/activation) | One frozen token per `Agent` object. The Agent Runtime constructs a new object on every activation, through the [`constructor()`](/docs/agent-sdk/interface/constructor). | | [`authorization`](/docs/agent-sdk/harness-sdk/ports/authorization) | No public `Agent` member. Omitted unless a provider is registered through `@telnyx/edge-runtime/internal`. | | [`version`](/docs/agent-sdk/harness-sdk/ports/version) | The constant `AGENT_HARNESS_PORTS_VERSION` | ## RuntimeAgentHarnessPortsOptions --- ### createNodeAgentHarnessPorts() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/adapters/node.md ## NodeAgentHarnessPorts ## NodeAgentHarnessPortsOptions --- ### defineAgentHarnessPortsContract() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/adapters/contract.md ## AgentHarnessPortsContractFixture ## AgentHarnessPortsContractFactory --- ### createHarnessRunLedger() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/persistence/run-ledger.md ## On the Agent Runtime The ledger records each run in the SQL port. It executes each run in a task of the tasks port. ```ts // run ledger (@telnyx/agent-harness) export function ensureHarnessDurabilitySchema(ports) { ports.sql.transactionSync(() => { ports.sql.exec("CREATE TABLE IF NOT EXISTS __telnyx_agent_harness_schema (version INTEGER PRIMARY KEY)"); // … }); } ``` - `ports.sql.exec()` runs on the instance's own SQLite database, [`this.ctx.storage.sql`](/docs/agent-sdk/interface/ctx). - `ports.sql.transactionSync()` is `this.ctx.storage.transactionSync()`. The schema is created and migrated in one such transaction. A migration that throws is rolled back whole. - The ledger's tables share the database with any table the class creates. Their names begin with `__telnyx_agent_harness_`. ```ts // run ledger (@telnyx/agent-harness) const TASK_METHOD = "__telnyx_agent_harness_run"; // … const enqueue = async (run) => { if (run.status !== "accepted") return; await ports.tasks.queue(TASK_METHOD, { runId: run.id }, { id: run.task_id, maxRetries: 5 }); }; // … const admission = ports.sql.transactionSync(() => { // … }); // This is intentionally outside the SQL transaction: a queue failure leaves an accepted record for recover(). await enqueue(admission.run); ``` - `accept()` queues the run's task after the run's row commits, through the class's [`queue()`](/docs/agent-sdk/interface/queue). - The task's `id` is the run's `task_id`. Queueing the run again, as `recover()` does, leaves one pending task for it. - `__telnyx_agent_harness_run` names no method of the class, so the task reaches [`onTask()`](/docs/agent-sdk/interface/onTask). The class overrides `onTask()` to hand it to [`dispatch()`](/docs/agent-sdk/harness-sdk/harness/dispatch). ## HarnessRunLedger ## HarnessRunLedgerOptions ## HarnessCheckpoint ## ensureHarnessDurabilitySchema() ## HarnessInjectedCrash ## HarnessApprovalPause --- ### createHarnessStepContext() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/persistence/step-context.md ## On the Agent Runtime The step memo is a table in the SQL port, written in transactions around the step's function. ```ts // steps.ts ports.sql.transactionSync(() => { const next = first(ports.sql.exec("SELECT COALESCE(MAX(journal_seq), 0) + 1 AS next FROM __telnyx_agent_harness_steps WHERE run_id = ?", options.runId).toArray()).next; ports.sql.exec("INSERT INTO __telnyx_agent_harness_steps(run_id,name,identity,input_fingerprint,version,status,output,journal_seq) VALUES (?,?,?,?,?,'prepared',NULL,?)", options.runId, name, identity, inputFingerprint, version, next); }); // … const output = await fn(); // … const serialized = serializeStepOutput(output, stepOptions.external === true); ports.sql.transactionSync(() => { ports.sql.exec("UPDATE __telnyx_agent_harness_steps SET status = 'completed', output = ?, output_failure = ? WHERE run_id = ? AND name = ?", serialized.output, serialized.failure, options.runId, name); }); ``` - `ports.sql.exec()` runs on the instance's own SQLite database, [`this.ctx.storage.sql`](/docs/agent-sdk/interface/ctx). The memo's table sits beside the run ledger's. - `ports.sql.transactionSync()` is `this.ctx.storage.transactionSync()`. A transaction that throws is rolled back whole. ## HarnessStepContext ## HarnessStepContextOptions ## HarnessStepOptions ## HarnessStepOutputSerializationError ## isToolOutputSerializationFailure() --- ### createChannelRouter() > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/router.md ## ChannelRouterOptions ## ChannelAdapter ## ChannelLedger ## OutboundChannelPolicy ## ChannelContent ## NormalizedChannelInbound ## ChannelAcceptance ## ChannelSendRequest ## ChannelReplyRequest ## ChannelOutboundRequest ## ChannelDeliveryEvent ## ChannelAuthorizationError --- ### InMemoryChannelLedger > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/in-memory-ledger.md ## InMemoryChannelLedgerOptions --- ### SmsReferenceAdapter > Source: https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/sms-reference-adapter.md ## SmsReferenceAdapterOptions --- ### The Package > Source: https://developers.telnyx.com/docs/agent-sdk/runtime-sdk.md The Agent Runtime SDK is the package `@telnyx/edge-runtime`. Its export is the `Agent` class. Anything that implements the turn taking runs on the class. The class is hosted by the Agent Runtime Platform, which invokes it at its entry points; see [Entry Points](/docs/agent-sdk/contract/entry-points) for detail. | Export | What it is | |---|---| | `@telnyx/edge-runtime` | The `Agent` class, the `StatefulActor` it extends, and the types of the environment and its bindings. | | `@telnyx/edge-runtime/client` | `AgentClient`, documented as [The Agent Client SDK](/docs/agent-sdk/client-sdk). | | `@telnyx/edge-runtime/mount` | `mountAgents`, the function entry that serves every instance at one address. | | `@telnyx/edge-runtime/agent-socket` | `AgentSocketServer`, the server half of the agent socket protocol. | | `@telnyx/edge-runtime/agent-http` | `AgentHttpServer`, the streams and RPC over HTTP. | | `@telnyx/edge-runtime/voice` | The `voice()` mixin, with Telnyx STT and TTS for calls. | | `@telnyx/edge-runtime/internal` | Seams for trusted adapters, such as the harness's. Not for application code. | The pages document the `Agent` class in two parts. **The Interface** is what is exposed to the layer above, the class and what an instance has. | Group | What it is for | |---|---| | [Lifecycle](/docs/agent-sdk/interface/constructor) | Construct an instance with its options, and erase everything it holds. | | [Values](/docs/agent-sdk/interface/messages) | Reach the message log, the event log, the bindings and the actor context. | | [State](/docs/agent-sdk/interface/initialState) | Read and write the instance's persistent state, and react to its changes. | | [Tasks](/docs/agent-sdk/interface/queue) | Run work after the request returns, now, later or at an interval. | | [Children](/docs/agent-sdk/interface/spawn) | Create, list and end child instances. | | [Connections](/docs/agent-sdk/interface/authorize) | Decide who may connect, and receive the sockets and requests that arrive. | | [RPC](/docs/agent-sdk/interface/rpc) | Make a method callable over the address. | [Bring Your Own Framework](/docs/agent-sdk/examples/bring-your-own-framework) shows the glue code that runs any framework on the class. **The Internals** is how the class is served on the Agent Runtime Platform. | Group | What it is | |---|---| | [Entry Points](/docs/agent-sdk/contract/entry-points) | The methods the platform invokes on the class. | | [The Address and Transports](/docs/agent-sdk/contract/address-and-transports) | Where an instance answers, and over what. | | [Mount](/docs/agent-sdk/mounting-agents) | The function entry that serves every instance at one address. | | [AgentSocketServer](/docs/agent-sdk/internals/the-socket-server) | The server half of the agent socket protocol. | | [AgentHttpServer](/docs/agent-sdk/internals/the-http-server) | The streams and RPC over HTTP. | | [Limits](/docs/agent-sdk/limits) | The budgets, retries, storage and name lengths an instance works within. | --- ### constructor() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/constructor.md ## Type Parameters | Parameter | Constraint | Default | Meaning | |---|---|---|---| | `E` | `extends Env` | `Env` | The bindings, typed as `this.env`. | | `State` | `extends Record` | `Record` | The shape of the persistent state. | ## AgentOptions --- ### destroy() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/destroy.md --- ### messages > Source: https://developers.telnyx.com/docs/agent-sdk/interface/messages.md ## add() ## append() ## appendMany() ## all() ## last() ## count() ## toOpenAI() ## toAnthropic() ## toLangChain() ## new MessageLog() ## AgentMessage ## ToolCall ## StoredMessage --- ### events > Source: https://developers.telnyx.com/docs/agent-sdk/interface/events.md ## emit() ## read() ## count() ## new EventLog() ## StoredEvent ## EventLogOptions --- ### env > Source: https://developers.telnyx.com/docs/agent-sdk/interface/env.md --- ### ctx > Source: https://developers.telnyx.com/docs/agent-sdk/interface/ctx.md --- ### initialState() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/initialState.md --- ### getState() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/getState.md --- ### setState() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/setState.md --- ### replaceState() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/replaceState.md --- ### onStateChanged() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/onStateChanged.md --- ### queue() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/queue.md --- ### schedule() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/schedule.md ## ScheduleOptions --- ### every() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/every.md --- ### cancelSchedule() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/cancelSchedule.md --- ### listSchedules() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/listSchedules.md ## TaskRecord --- ### onTask() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/onTask.md --- ### now() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/now.md --- ### alarm() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/alarm.md --- ### spawn() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/spawn.md ## QuotaExceededError ### constructor ### code ### detail ### status --- ### children() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/children.md ## ChildRef --- ### despawn() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/despawn.md --- ### authorize() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/authorize.md ## Claim --- ### onConnect() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/onConnect.md --- ### onAttach() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/onAttach.md ## Attachment --- ### attachments > Source: https://developers.telnyx.com/docs/agent-sdk/interface/attachments.md ## AgentAttachments --- ### connectionEngine > Source: https://developers.telnyx.com/docs/agent-sdk/interface/connectionEngine.md --- ### webSocket() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/webSocket.md ## AgentSocketsNotEnabledError --- ### fetch() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/fetch.md --- ### @rpc > Source: https://developers.telnyx.com/docs/agent-sdk/interface/rpc.md ## RpcOptions --- ### rpcSurface() > Source: https://developers.telnyx.com/docs/agent-sdk/interface/rpcSurface.md --- ### BYO Framework > Source: https://developers.telnyx.com/docs/agent-sdk/examples/bring-your-own-framework.md Any turn taking runs on the `Agent` class, whether a framework or a loop the user writes. Glue code joins the two. The class keeps the conversation, and the turn taking produces each reply. ## Write the Glue ```ts import { Agent, type Env } from "@telnyx/edge-runtime"; export class Conversation extends Agent { async receive(text: string): Promise { await this.messages.add("user", text); await this.queue("process"); } async process(): Promise { const history = await this.messages.toOpenAI(); const reply = await takeTurn(history); await this.messages.add("assistant", reply); } } ``` - `receive` records the message in the [log](/docs/agent-sdk/interface/messages) and returns, inside the 30-second budget of an inbound call. - [`queue`](/docs/agent-sdk/interface/queue) runs `process` as a task, which has minutes, retries on failure and survives restarts. - `process` reads the history in the shape the turn taking expects and records the reply, so the next turn sees it. `takeTurn` is the slot. The log reads the history out in three shapes for it, `toOpenAI()`, `toAnthropic()` and `toLangChain()`. Under the entry `deploy` generates, a text or an email reaches the class's `receiveVia(text, from, key, via)`, so a class wired to channels implements that method, and `receive` is the RPC door. A client reaches it once the method is marked with [`@rpc()`](/docs/agent-sdk/interface/rpc) and [`authorize`](/docs/agent-sdk/interface/authorize) grants the client the `rpc` claim. ## Write the Loop by Hand The smallest turn taking is one model call. The Telnyx inference endpoint is reached through the `TELNYX` binding on [`env`](/docs/agent-sdk/interface/env) with the account's credential, so the loop holds no key. ```ts import { Agent, type Env } from "@telnyx/edge-runtime"; import type Telnyx from "telnyx"; type ChatResponse = { choices: Array<{ message: { content: string } }> }; export class Conversation extends Agent { // receive() as above async process(): Promise { const history = await this.messages.toOpenAI(); const res = (await this.env.TELNYX.ai.openai.chat.createCompletion({ model: "zai-org/GLM-5.3-Flash", messages: [{ role: "system", content: "Answer in one or two sentences." }, ...history] as never, })) as ChatResponse; await this.messages.add("assistant", res.choices[0].message.content); } } ``` `Env` types no bindings, so the class adds `TELNYX`, and the history and the response are cast, as `managed/model.ts` does. A loop with tools calls the model again after each tool result. It records the call and the result with [`append()`](/docs/agent-sdk/interface/messages#append), and `toOpenAI()` returns them to the model on the next read. ## Plug In a Framework A framework takes the same place. LangGraph reads the `toLangChain()` history as it is. ```bash npm install @langchain/openai @langchain/langgraph @langchain/core zod ``` ```ts import { Agent, type Env } 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 lookupOrder = tool( async ({ orderId }) => JSON.stringify({ orderId, status: "shipped" }), { name: "lookup_order", description: "Look up an order by its id.", schema: z.object({ orderId: z.string() }), }, ); const graph = createReactAgent({ llm: new ChatOpenAI({ model: "zai-org/GLM-5.3-Flash", apiKey: process.env.TELNYX_API_KEY, configuration: { baseURL: "https://api.telnyx.com/v2/ai/openai" }, }), tools: [lookupOrder], }); export class Conversation extends Agent { // receive() as above async process(): Promise { const history = await this.messages.toLangChain(); const out = await graph.invoke({ messages: [{ role: "system", content: "Answer in one or two sentences." }, ...history], }); await this.messages.add("assistant", String(out.messages.at(-1)?.content ?? "")); } } ``` - A framework makes its own HTTP calls, so it authenticates like any client. Its key reaches the class as an environment variable. - Only the final reply reaches the log. The tool calls inside the run stay with the framework, and `append()` records them when a later turn needs them. [The Agent Harness SDK](/docs/agent-sdk/harness-sdk) is Telnyx's own turn taking, and it takes the same place. [`createHarness()`](/docs/agent-sdk/harness-sdk/create-harness) builds it inside the class. --- ### Entry Points > Source: https://developers.telnyx.com/docs/agent-sdk/contract/entry-points.md The platform invokes the class at a few known points, and the class returns within a budget on each. The `Agent` class extends Edge Compute's [`StatefulActor`](/docs/edge-compute/stateful-actors/api-reference/base), and the platform calls it through the actor's entries. | Entry point | Invoked by | Contract | |---|---|---| | [`fetch(req)`](/docs/edge-compute/stateful-actors/guides/project-structure#two-exports) on the function | Every request to the address | Answered by `mountAgents`, which resolves the instance and picks the transport. [The Address and Transports](/docs/agent-sdk/contract/address-and-transports) has the three. | | Any method marked `@rpc` | `POST …/rpc/` on the conversation's address, and the socket's RPC frames | One call, JSON in and out, within the [30-second budget](/docs/edge-compute/stateful-actors/api-reference/errors). Work that needs longer goes to a queued task. | | A public method | A call through an [`ActorStub`](/docs/edge-compute/stateful-actors/api-reference/stub), from the function or from another instance | Actor RPC, under the [dispatch rules](/docs/edge-compute/stateful-actors/api-reference/base#dispatch-rules) of `StatefulActor`. `@rpc` does not apply to it. | | [`webSocket(ws, req)`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req) | A WebSocket upgrade on the conversation's address | The class owns the socket. The default runs the agent socket protocol, and the `voice()` mixin takes a call's media stream. The mixin is the `@telnyx/edge-runtime/voice` export of [The Agent Runtime SDK](/docs/agent-sdk/runtime-sdk). | | `onTask(name, payload, ctx)` | The task scheduler, when a due task names no method on the class | Delivered at least once, inside the [alarm handler](/docs/edge-compute/stateful-actors/alarms), with a budget on the order of minutes. A task that names a method on the class is delivered to that method. [`queue()`](/docs/agent-sdk/interface/queue) creates a task. | | `authorize(token, req)` | Every connection, once | Resolve the credential into claims. [`authorize`](/docs/agent-sdk/interface/authorize) is the reference, and [Mounting Agents](/docs/agent-sdk/mounting-agents#authorization-composes-—-it-does-not-replace) has both gates. | | `onConnect(conn)`, `onAttach(att)` | A connection, as it is established and as it is admitted | Hooks. | | [`alarm(info)`](/docs/edge-compute/stateful-actors/api-reference/base#alarm-alarminfo) | The platform's alarm | Reserved by the SDK, which drives the task scheduler with it. | One instance runs one of these [at a time](/docs/edge-compute/stateful-actors/concepts/execution-model), so a handler never interleaves with another on the same conversation. [Execution Model](/docs/agents/model/execution-model) has the rest of the rules. A webhook is a request to `fetch` like any other, and what the function does with it is the function's own code. ## Activation The platform constructs the class on every activation. The constructor opens the instance's stores and re-arms the task scheduler before the instance takes a call. ```ts // agent/agent.ts constructor(ctx, env, options = {}) { super(ctx, env); // … this._tasks = new TaskScheduler(ctx.storage, () => randomUUID()); this._state = new StateStore(ctx.storage, () => this.initialState()); ctx.blockConcurrencyWhile(async () => { await beginActivation(ctx.storage); await this._tasks.rearm(this.now()); }); } ``` - [`StatefulActor`](/docs/edge-compute/stateful-actors/api-reference/base#construction) receives `ctx` and `env` through `super(ctx, env)`. It keeps them as `this.ctx` and `this.env`. - [`ctx.storage`](/docs/edge-compute/stateful-actors/api-reference/storage) backs the task scheduler and the state store. The message log and the event log are built on it too. - [`ctx.blockConcurrencyWhile`](/docs/edge-compute/stateful-actors/api-reference/context#ctx-blockconcurrencywhile-fn) runs the store setup and the re-arm. Every other call waits until both finish. The callback has a 30-second budget. ## The Fetch Entry The class does not override the actor's `fetch`. It inherits the default. ```ts // stateful-actor.ts async fetch(_req) { return new Response("Not Found", { status: 404 }); } ``` - [`fetch(req)`](/docs/edge-compute/stateful-actors/api-reference/base#fetch-req) receives every request to the instance that is not an upgrade. A class that serves the streams and RPC over HTTP overrides it and passes the request to an [`AgentHttpServer`](/docs/agent-sdk/internals/the-http-server). ## The WebSocket Entry The class overrides the actor's `webSocket` and hands each socket to its [socket server](/docs/agent-sdk/internals/the-socket-server). ```ts // agent/agent.ts webSocket(ws, req) { if (!this._overridesSeam("authorize") && !this._overridesSeam("onAttach") && !this._overridesSeam("webSocket")) { throw new AgentSocketsNotEnabledError(); } return this._socketServer().attach(ws, req); } ``` - [`webSocket(ws, req)`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req) receives the accepted socket and the handshake request. A class that overrides none of the three seams throws before it touches the socket, so the socket is refused. ## The Alarm Entry The instance has one alarm. The task scheduler sets it through `ctx.storage`, to the earliest due task. ```ts // agent/tasks.ts async rearm(_now) { const due = await taskEarliestDue(this.storage); if (due === undefined) { await this.storage.deleteAlarm(); return; } await this.storage.setAlarm(due); } ``` - [`ctx.storage.setAlarm`](/docs/edge-compute/stateful-actors/alarms) receives the earliest due time, in milliseconds since the epoch. Setting it again replaces the previous time, so every task shares the one alarm. - [`ctx.storage.deleteAlarm`](/docs/edge-compute/stateful-actors/alarms) clears the alarm when no task is pending. When the alarm fires, the class's `alarm` drains every due task and re-arms. ```ts // agent/agent.ts async alarm(_info) { const dispatch = (task) => this._dispatch(task); // … for (;;) { const now = this.now(); const earliest = await taskEarliestDue(this.ctx.storage); if (earliest === undefined || earliest > now) break; // … await this._tasks.runDue(now, dispatch); } // … await this._tasks.rearm(this.now()); } ``` - [`alarm(info)`](/docs/edge-compute/stateful-actors/api-reference/base#alarm-alarminfo) is the entry the platform calls when the alarm fires. Every task the drain dispatches runs inside it, under the alarm's [time budget](/docs/edge-compute/stateful-actors/alarms#limits). --- ### The Address and Transports > Source: https://developers.telnyx.com/docs/agent-sdk/contract/address-and-transports.md Every conversation of a deployed agent is reachable at one address: ```text https://-.telnyxcompute.com/agents// ``` The host is the Edge Compute function's [public URL](/docs/edge-compute/configuration/routing#public-url-pattern). The path is the mount's. The shape of the request selects the transport, not the path: | The client sends | It gets | Use | |---|---|---| | `Upgrade: websocket` | The agent socket protocol: state and message sync, cursor-based resume | Clients, and the voice media stream | | `GET …?subscribe=state` | Server-Sent Events | State and message streams in a browser | | `POST …/rpc/` | One RPC call, JSON in and out | Any HTTP client | The upgrade reaches the instance's [`webSocket(ws, req)`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req) entry. The `GET` and the `POST` reach its [`fetch(req)`](/docs/edge-compute/stateful-actors/api-reference/base#fetch-req) entry. The [AgentClient](/docs/agent-sdk/client-sdk/constructor) wraps the socket, and any HTTP or WebSocket client can speak the wire. The mount routes and adds no policy of its own. Two gates decide who may call. The mount's `authorize` runs at the edge, and the agent's [`authorize`](/docs/agent-sdk/interface/authorize) runs inside the instance. [Mounting Agents](/docs/agent-sdk/mounting-agents) is the reference, and its [authorization section](/docs/agent-sdk/mounting-agents#authorization-composes-—-it-does-not-replace) covers both gates. **Beta.** On a deployed function, Server-Sent Events arrive in batches about once a second rather than live. The WebSocket carries the same frames on the same address and is the live path. [AgentHttpServer](/docs/agent-sdk/internals/the-http-server) has the detail. --- ### Mounting Agents > Source: https://developers.telnyx.com/docs/agent-sdk/mounting-agents.md An agent is only reachable through an Edge Compute [function](/docs/edge-compute/stateful-actors/guides/project-structure#two-exports). Before `mountAgents`, every project wrote the function's front door by hand, and each copy differed a little. - It parsed the URL and checked for an upgrade. - It pulled the credential and encoded the name. - It picked the binding and called [`idFromName(...).fetch(req)`](/docs/edge-compute/stateful-actors/api-reference/namespace#idfromname-name-options). - It answered health checks. `mountAgents` *(SDK ≥ 0.12.0)* is that front door, already written. The user supplies the routing table and the policy, and the mount supplies the rest. ```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. Each value is an actor binding, an [`ActorNamespace`](/docs/edge-compute/stateful-actors/api-reference/namespace). An `[[actors]]` block in `telnyx.toml` declares it, as the Stateful Actors [configuration reference](/docs/edge-compute/stateful-actors/api-reference/configuration) shows. ## One Address, Three Transports Each mounted agent answers on `//`: - `` is the path prefix the mount owns, `/agents` unless the option sets another, [`base`](/docs/agent-sdk/internals/mount/mountAgents#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`. Each name reaches one [actor instance](/docs/edge-compute/stateful-actors/concepts/addressing), the same one on every request. The first request brings it up, as [Lifecycle & Placement](/docs/edge-compute/stateful-actors/concepts/lifecycle#where-it-runs) describes. **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/client-sdk/constructor) 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/interface/rpc) are callable. **SSE arrives in batches on deployed functions.** A streamed response cannot cross the actor boundary, so each SSE response carries the frames ready when the agent answers. The `EventSource` reconnects about once a second and resumes from `Last-Event-ID`. The WebSocket and RPC paths are unaffected, and SSE streams live in local development. The WebSocket path is the live one for 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 reconnects and resumes on its own. The full detail is on the [AgentHttpServer](/docs/agent-sdk/internals/the-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 it is given. 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/internals/mount/mountAgents#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/interface/authorize) resolves this connection's [claims](/docs/agent-sdk/interface/authorize#claim) from it exactly as it would with no mount in front, and [`onAttach`](/docs/agent-sdk/interface/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 the agent trusts. - **Headers the mount does not stamp are forwarded as the caller sent them.** The agent trusts only what the mount stamps. 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 verified 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 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 an application routes 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/internals/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 other front door that addresses the same instances, or that door points the name at a different, empty instance. - **Case is significant.** `alice` and `Alice` are two agents. Names meant to reach one agent are lowercased before routing. - **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` | the [`fallback`](/docs/agent-sdk/internals/mount/mountAgents#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 Other Routes A mount does not have to own the whole function. `fallback` receives every request whose path is not under `base`, so the function's 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, // every other path health: false, // the function answers its 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/internals/the-mount) — every option, type, and refusal in full - [AgentClient](/docs/agent-sdk/client-sdk/constructor) — the browser/Node client that connects to the address above - [AgentSocketServer](/docs/agent-sdk/internals/the-socket-server) and [AgentHttpServer](/docs/agent-sdk/internals/the-http-server) — what answers on the agent side of each transport - [`webSocket()`](/docs/agent-sdk/interface/webSocket) — the entry a class overrides to own its sockets --- ### The Mount > Source: https://developers.telnyx.com/docs/agent-sdk/internals/the-mount.md `mountAgents` *(SDK ≥ 0.12.0)* is the front door for an Edge Compute [function](/docs/edge-compute/stateful-actors/guides/project-structure#two-exports) that serves agents. One call replaces the routing file a function would otherwise write by hand. The function keeps only its own policy. 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 mounted agent answers on the same shape, `//`, where `` is a key of the 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/client-sdk/constructor) 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`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req); `GET` and `POST` are handed to the agent's [`fetch`](/docs/edge-compute/stateful-actors/api-reference/base#fetch-req), where an [AgentHttpServer](/docs/agent-sdk/internals/the-http-server) over the agent's connection engine answers them once the class routes `fetch` there. 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 arrives in batches on deployed functions.** Each SSE response carries the frames ready when the agent answers, and the `EventSource` reconnects about once a second. The WebSocket and RPC paths are unaffected, and SSE streams live in local development. [AgentHttpServer](/docs/agent-sdk/internals/the-http-server) has the detail. The WebSocket path is the live one for production, on the same address under the same policy. ## The Hand-Off The handler the mount returns resolves the binding, then hands the request to the instance. ```ts // mount.ts return async function handleAgentRequest(req, env) { // … const table = map(env); const namespace = table !== null && typeof table === "object" && ownKey(table, mount) ? table[mount] : undefined; if (namespace === undefined || typeof namespace.idFromName !== "function") { return withCors(notFound(), cors, req); } // … const stub = namespace.idFromName(actorId); return withCors(await stub.fetch(new Request(forwardable, { headers })), cors, req); }; ``` - [`fetch`](/docs/edge-compute/stateful-actors/guides/project-structure#two-exports) on the function's default export is the entry this handler fills. The platform calls it with each request and the function's `env`. - [`ActorNamespace`](/docs/edge-compute/stateful-actors/api-reference/namespace) is what each map value must be. `map(env)` reads the bindings on every request. The mount answers `404` for a value without `idFromName`. - [`idFromName`](/docs/edge-compute/stateful-actors/api-reference/namespace#idfromname-name-options) receives the actor id that `encodeAgentName` built. The same id always reaches the same instance, as [Addressing](/docs/edge-compute/stateful-actors/concepts/addressing) describes. - [`ActorStub.fetch`](/docs/edge-compute/stateful-actors/api-reference/stub) receives the request with its URL unchanged and the stamped headers. An upgrade reaches the instance's [`webSocket`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req) entry, and any other request reaches its [`fetch`](/docs/edge-compute/stateful-actors/api-reference/base#fetch-req) entry. ## Two Gates, Not One The mount's [`authorize`](/docs/agent-sdk/internals/mount/mountAgents#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/interface/authorize) resolves this connection's [claims](/docs/agent-sdk/interface/authorize#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 the mount does **not** stamp are forwarded as the caller sent them, so the agent trusts only what the mount stamps. The [Mounting Agents](/docs/agent-sdk/mounting-agents) guide walks the whole path, including composing the mount with other routes and the cross-origin opt-in. ## 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. Any front door written by hand that must address the same instances calls the same function. Re-deriving the rule points a name at a different, empty instance. The instance reads its actor id as [`ctx.id`](/docs/edge-compute/stateful-actors/api-reference/context#ctx-id). 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. --- ### mountAgents() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/mount/mountAgents.md ## AgentMountMap ## MountOptions ## MountRoute ## MountTransport ## MountCorsOptions ## MountAuthorizeResult ## MountHeadersInit --- ### encodeAgentName() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/mount/encodeAgentName.md --- ### decodeAgentName() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/mount/decodeAgentName.md --- ### The Socket Server > Source: https://developers.telnyx.com/docs/agent-sdk/internals/the-socket-server.md `AgentSocketServer` is the server half of the agent socket protocol — it drives every [WebSocket](/docs/edge-compute/stateful-actors/websockets) an agent holds. Construct one per actor instance and delegate the actor's [`webSocket(ws, req)`](/docs/edge-compute/stateful-actors/api-reference/base#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/interface/rpc)-decorated methods, and pushes incremental updates through the `broadcast*` methods as the agent's data changes. ```ts import { Agent, type Env } from "@telnyx/edge-runtime"; import type { MergePatch } from "@telnyx/edge-runtime/internal"; 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. ## Attaching a Socket `attach()` registers its listeners on the actor's socket before its first `await`. ```ts // agent-client/server.ts ws.on("close", gone); ws.on("error", gone); ws.on("message", (data, isBinary) => { if (admitted) { return ordered ? enqueue(data, isBinary) : this.handleMessage(ws, data, isBinary, req); } // … if (buffered.length >= MAX_PENDING_INBOUND) { this.sendError(ws, "protocol_error", "too many frames before connect"); this.reject(ws, 1013, "inbound queue overflow"); return; } buffered.push({ data, isBinary }); // … }); ``` - [`webSocket(ws, req)`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req) hands `attach()` the accepted socket and the handshake request. The listeners go on before the entry returns, as the entry requires. - [`ws.on("message")`](/docs/edge-compute/stateful-actors/websockets#messages-are-single-threaded-like-everything-else) delivers each frame as one turn of the instance, serialized with its calls and its alarm. Each turn runs under the 30-second method budget. - [`ws.close(1013)`](/docs/edge-compute/stateful-actors/websockets/lifecycle#how-connections-end) ends a connection that sends more than 64 frames, the value of `MAX_PENDING_INBOUND`, before it is admitted. The platform uses the same code when its own event queue overflows. ## Broadcasts Every `broadcast*` method ends in the same loop over the sockets the server admitted. ```ts // agent-client/server.ts broadcastTo(frame, deliver) { const text = encodeFrame(frame); for (const ws of [...this.sockets]) { if (!deliver(this.sessions.get(ws))) continue; if (ws.readyState !== ws.OPEN) continue; try { ws.send(text); } catch { this.sockets.delete(ws); this.sessions.delete(ws); } } } ``` - [`ws.send`](/docs/edge-compute/stateful-actors/websockets#messages-are-single-threaded-like-everything-else) goes to each admitted socket subscribed to the frame's stream. The send is immediate, and it is not held until the turn's writes commit. - [`ctx.broadcast()`](/docs/edge-compute/stateful-actors/api-reference/context#ctx-broadcast-data) and [`ctx.count()`](/docs/edge-compute/stateful-actors/api-reference/context#ctx-count) are not used. Both cover every socket on the instance. The server keeps its own set instead, as [Sending to Some Sockets, Not All](/docs/edge-compute/stateful-actors/websockets#sending-to-some-sockets-not-all) describes. ## AgentSocketServerOptions ## AgentConnectContext --- ### new AgentSocketServer() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/constructor.md > **new AgentSocketServer**\<`TState`\>(`actor`: `object`, `opts`: [`AgentSocketServerOptions`](/docs/agent-sdk/internals/the-socket-server#agentsocketserveroptions)\<`TState`\>): `AgentSocketServer`\<`TState`\> Creates the socket server for one actor instance. Construct one per instance, usually as a field of the class, and delegate the class's `webSocket(ws, req)` to [`attach`](/docs/agent-sdk/internals/socket/attach). ```ts import { Agent, type Env } 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(), authorize: (token) => (token === undefined ? ["read"] : ["read", "rpc"]), }); async webSocket(ws: AgentServerSocket, req: Request): Promise { await this.desk.attach(ws, req); } } ``` **Parameters** | Parameter | Type | Meaning | |---|---|---| | `actor` | `object` | The instance whose `@rpc()` methods inbound `call` frames dispatch to, usually `this`. | | `opts` | [`AgentSocketServerOptions`](/docs/agent-sdk/internals/the-socket-server#agentsocketserveroptions)\<`TState`\> | How the server reads state and messages, and how it authorizes a connection. | **Type Parameters** | Parameter | Meaning | |---|---| | `TState` | The agent's persistent state shape, as snapshotted and broadcast to clients. | --- ### attach() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/attach.md ## The Actor Entry The actor's [`webSocket(ws, req)`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req) entry calls `attach()` once per accepted connection. [The Socket Server](/docs/agent-sdk/internals/the-socket-server#attaching-a-socket) shows the listeners it registers. ## AgentServerSocket --- ### broadcastPatch() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/broadcastPatch.md --- ### broadcastSnapshot() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/broadcastSnapshot.md --- ### broadcastMessages() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/broadcastMessages.md --- ### broadcastEvent() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/broadcastEvent.md --- ### handleCall() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/handleCall.md ## HandleCallOptions ## HandleCallResult --- ### close() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/close.md ## Close Codes The default code `1001` is the code a client also receives when an actor restarts or moves. On it, a client reconnects promptly, as [Connection Lifecycle](/docs/edge-compute/stateful-actors/websockets/lifecycle#how-connections-end) describes. --- ### watcherCount > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/watcherCount.md --- ### authorizeEnabled > Source: https://developers.telnyx.com/docs/agent-sdk/internals/socket/authorizeEnabled.md --- ### The HTTP Server > Source: https://developers.telnyx.com/docs/agent-sdk/internals/the-http-server.md `AgentHttpServer` *(SDK ≥ 0.11.0)* is the HTTP front door for an [AgentSocketServer](/docs/agent-sdk/internals/the-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 arrives in batches on deployed functions.** A streamed response cannot cross the actor boundary on the deployed platform. Each SSE response carries the frames available when the handler returns, waiting up to 200 ms for the first, and ends with `retry: 1000`. The `EventSource` then reconnects and resumes from `Last-Event-ID`, so a deployed agent's SSE behaves as a poll about once a second. The RPC path over POST is unaffected, and SSE streams live in local development. The WebSocket path is the live one for production. It carries the same frames under the same `authorize` and `onAttach` policy, and `mountAgents` serves it on the same address. The change is `new WebSocket(url)` in place of `new EventSource(url)`, or [AgentClient](/docs/agent-sdk/client-sdk/constructor), which reconnects and resumes on its own. Construct one over the socket server the actor's [`webSocket`](/docs/edge-compute/stateful-actors/api-reference/base#websocket-ws-req) delegates to, and route HTTP requests from the actor's [`fetch`](/docs/edge-compute/stateful-actors/api-reference/base#fetch-req) into [`fetch()`](/docs/agent-sdk/internals/http/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` hands a mounted agent's `GET` and `POST` requests to the actor's `fetch` on `/agents//`. The override above answers them there, and the function needs no routing code of its own. See [`mountAgents`](/docs/agent-sdk/internals/the-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. ## The Fetch Entry `AgentHttpServer.fetch` is the body of the actor's `fetch` entry. It chooses between the RPC and the stream by method and address. ```ts // agent-client/http.ts async fetch(req) { const url = new URL(req.url); const parsed = rpcMethodFromPath(url.pathname); if (parsed !== undefined) { // … if (req.method !== "POST") { return frameResponse(syntheticError("protocol_error", "RPC requires POST"), 405); } return this.handleRpc(req, parsed.method); } if (req.method === "GET") return this.handleSse(req); return frameResponse(syntheticError("protocol_error", `unsupported method ${req.method}`), 405); } ``` - [`fetch(req)`](/docs/edge-compute/stateful-actors/api-reference/base#fetch-req) on the actor passes the request the mount forwarded, with its URL and query string intact. `Agent` inherits the base `fetch`, which answers `404`. The example above overrides it. - [`ActorFetchTimeoutError`](/docs/edge-compute/stateful-actors/api-reference/errors) ends a `fetch` that runs past its 30-second budget. An RPC over `POST` runs inside that `fetch` and shares the budget. --- ### new AgentHttpServer() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/http/constructor.md > **new AgentHttpServer**\<`TState`\>(`server`: [`AgentSocketServer`](/docs/agent-sdk/internals/the-socket-server)\<`TState`\>): `AgentHttpServer`\<`TState`\> Creates the HTTP transport over an existing socket server. RPC over `POST …/rpc/` and the SSE streams use that server's authorization and dispatch, so one policy covers both transports. ```ts import { Agent, type Env } from "@telnyx/edge-runtime"; import { AgentSocketServer } 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(), authorize: (token) => (token === undefined ? ["read"] : ["read", "rpc"]), }); private door = new AgentHttpServer(this.desk); override fetch(req: Request): Promise { return this.door.fetch(req); } } ``` **Parameters** | Parameter | Type | Meaning | |---|---|---| | `server` | [`AgentSocketServer`](/docs/agent-sdk/internals/the-socket-server)\<`TState`\> | The instance's socket server. | **Type Parameters** | Parameter | Default | Meaning | |---|---|---| | `TState` | `Record` | The agent's persistent state shape, as served by the socket server. | --- ### fetch() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/http/fetch.md ## The Actor Entry The actor's [`fetch(req)`](/docs/edge-compute/stateful-actors/api-reference/base#fetch-req) entry calls it, from an override on the class. [The HTTP Server](/docs/agent-sdk/internals/the-http-server#the-fetch-entry) shows the method. --- ### handleRpc() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/http/handleRpc.md --- ### handleSse() > Source: https://developers.telnyx.com/docs/agent-sdk/internals/http/handleSse.md --- ### The Protocol > Source: https://developers.telnyx.com/docs/agent-sdk/internals/protocol.md The socket server, the HTTP server and [`AgentClient`](/docs/agent-sdk/client-sdk) speak one protocol of JSON frames, encoded with SuperJSON so that a `Date`, a `Map`, a `Set` or `undefined` in a payload survives the wire. `@telnyx/edge-runtime/agent-socket` exports the frame functions and types for servers, and `@telnyx/edge-runtime/client` exports the same set for clients, with `isFrame` and `applyMergePatch` besides. ```ts import { encodeFrame, parseKnownFrame, type AnyKnownFrame } from "@telnyx/edge-runtime/agent-socket"; import { applyMergePatch, isFrame } from "@telnyx/edge-runtime/client"; ``` ## Versions | Constant | Value | Meaning | |---|---|---| | `PROTOCOL_V1` | `1` | The v1 wire, the version the server and the client speak today. | | `PROTOCOL_V2` | `2` | The v2 wire, the `attach`, `attached` and `event` vocabulary of attach-mode sessions. | | `PROTOCOL_VERSION` | `1` | The version that is safe to stamp on the wire today. It stays equal to `PROTOCOL_V1` until both sides emit v2 frames, and v2 frames are stamped with `PROTOCOL_V2`. | Every frame carries its version as `v` and its kind as `kind`. ## Functions | Function | Exported From | Does | |---|---|---| | `encodeFrame(frame: AnyKnownFrame): string` | both | Encodes a frame of either version to a SuperJSON text frame. | | `parseFrame(text: string): AnyFrame` | both | Parses a v1 text frame. Throws on malformed input. | | `parseKnownFrame(text: string): AnyKnownFrame` | both | Parses a text frame of either version. Throws on malformed input. | | `isKnownFrame(value: unknown): value is AnyKnownFrame` | both | `true` for a frame of either version. | | `isFrame(value: unknown): value is AnyFrame` | `./client` | `true` for a frame of the shipped v1 protocol. | | `applyMergePatch(target, patch): Record` | `./client` | Applies an RFC 7396 merge patch to `target` and returns it: `null` deletes a key, objects merge recursively, and anything else replaces. It is the same function the server's `setState` uses. | ## v1 Frames `AnyFrame` is the union of every v1 frame. | `kind` | Sent By | Fields | Meaning | |---|---|---|---| | `call` | Client | `id`, `method`, `args?` | Invoke an `@rpc()` method. The answer is a `result` or an `error` with the same `id`. | | `ping` | Client | None | A heartbeat, answered with `pong`. | | `hello` | Server | None | Ends the bootstrap: the link is usable, and buffered calls flush. | | `state` | Server | `snapshot?`, `patch?` | A `snapshot` replaces the client's state; a `patch` is a merge patch on top of it. A connect sends a snapshot, and each `setState` a patch. | | `messages` | Server | `snapshot?`, `appended?` | The full message log on connect, then the newly appended messages. | | `result` | Server | `id`, `value` | The return value of the `call` with that `id`. | | `error` | Server | `id?`, `code`, `message` | With an `id`, that call rejects. Without one, the error is connection-level and the client rebuilds the link. | | `pong` | Server | None | The answer to `ping`. | `ErrorCode` is `"method_not_found"`, `"method_private"`, `"method_error"`, `"protocol_error"` or `"version_mismatch"`. ## v2 Frames `AnyFrameV2` is the union of the attach-mode frames, `AnyKnownFrame` the union of both versions, and `FrameKindV2` the v2 `kind` values. Each v2 frame is exported as its own type. | Type | `kind` | Sent By | Fields | Meaning | |---|---|---|---|---| | `AttachFrame` | `attach` | Client | `token?`, `subscribe?`, `cursors?` | Opens or reopens a session. The token is the credential, `subscribe` the [`Stream`](#streams-and-claims)s wanted, and `cursors` the per-stream positions to resume after, `{ events: 41 }` for example. | | `AttachedFrame` | `attached` | Server | `grants`, `accepted` | The [`Claim`](#streams-and-claims)s the connection holds, and the accepted subset of the requested streams. | | `EventFrame` | `event` | Server | `seq`, `type`, `payload`, `at` | One item of the agent's persistent [event log](/docs/agent-sdk/interface/events), in strict `seq` order. `seq` is also the resume cursor. | | `ErrorFrameV2` | `error` | Server | `id?`, `code`, `message` | The v2 error, with an `ErrorCodeV2`. | `ErrorCodeV2` is any `ErrorCode`, `"unauthorized"` or `"subscription_denied"`. ## Streams and Claims `Stream` is `"state"`, `"messages"` or `"events"`, the streams a v2 client can subscribe to. `Claim` is `"read"`, `"rpc"` or any other string: a capability [`authorize`](/docs/agent-sdk/interface/authorize) grants a connection. With `authorize` configured, a `call` frame needs the `"rpc"` claim. --- ### 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`](/docs/edge-compute/stateful-actors/api-reference/errors) | | Queued / scheduled task (runs in [`alarm()`](/docs/edge-compute/stateful-actors/api-reference/base#alarm-alarminfo)) | Larger — [on the order of minutes](/docs/edge-compute/stateful-actors/alarms#limits) | Run counts as failed; task retries | Do LLM and outbound API work in tasks, not inbound methods — see [Execution Model](/docs/agents/model/execution-model). 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. The drain catches a task that throws, so the failure never leaves the alarm handler. ```ts // agent/tasks.ts for (const task of due) { // … let threw = false; try { await dispatch(task); } catch { threw = true; } ran += 1; if (threw) { await this.settleFailure(now, task, spokenFor(task.id)); continue; } // … } ``` - [`alarm(info)`](/docs/edge-compute/stateful-actors/api-reference/base#alarm-alarminfo) returns normally after a task fails. The SDK counts the attempt itself, and `info.retryCount` plays no part. - The alarm's own [redelivery](/docs/edge-compute/stateful-actors/alarms#delivery-contract) applies only when the handler itself fails, as on an overrun of its budget. That retry follows the platform's policy, not `maxRetries`. ## 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). 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 instance's embedded SQL database instead, which the [SQL guide](/docs/edge-compute/stateful-actors/guides/storage/sql) covers. One database holds up to 1 GB, and one bound value up to 2 MiB. Since SDK 0.13.0 the message log, the state, the progress events and the scheduled tasks are kept in that same database, the one at `this.ctx.storage.sql`. The SDK owns every table whose name starts with `_a_`. Those tables count toward the database's 1 GB. Reads stay flat as a log grows. An instance that already has data migrates itself once, on its first write after the upgrade. The SDK creates the tables through `ctx.storage` when an instance activates. ```ts // agent/sql-backing.ts export function createSchema(storage) { const engine = storage.sql; const present = engine .exec(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`, TASK_DUE_INDEX) .toArray(); if (present.length > 0) return; storage.transactionSync(() => engine.exec(SCHEMA)); } ``` - [`ctx.storage.sql`](/docs/edge-compute/stateful-actors/api-reference/storage#sql) is the engine the SDK's tables are created in. It is the instance's one SQL database. - [`ctx.storage.transactionSync`](/docs/edge-compute/stateful-actors/api-reference/storage#transactionsync-fn) creates every table in one transaction. Either all of them exist or none does. **Do not roll back across 0.13.0.** An SDK older than the migration does not look in the SQL database and reads a migrated instance as empty. Its data is still stored, but invisible to that code. Upgrading is safe; downgrading is not. ## WebSockets The agent's sockets are Stateful Actor WebSockets, and their [limits](/docs/edge-compute/stateful-actors/websockets/lifecycle#limits) apply unchanged. - A connection is capped at one hour from the handshake, even while frames flow. It ends with close code `1006`, and the client reconnects to the same name, where the history, the state and the pending tasks remain. - Frames that arrive while a handler runs queue up to 256 events or 1 MiB. Past that, the socket closes with `1013`. - `send` and `broadcast` [go out at once](/docs/edge-compute/stateful-actors/websockets#messages-are-single-threaded-like-everything-else), not after the write that prompted them commits. A frame that carries the message log's `seq` lets a client detect a gap and read again on reconnect. ## 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/internals/mount/encodeAgentName) and [`mountAgents()`](/docs/agent-sdk/internals/mount/mountAgents) apply the rule. The two exports below are for a front door written by hand. 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() ## MAX_AGENT_NAME_LENGTH ## What's shipped - `Agent` base class with message history, scheduled tasks, and merge-patch state - WebSocket termination via [`webSocket()`](/docs/agent-sdk/interface/webSocket) - The agent socket layer *(SDK ≥ 0.10.0)* — [AgentClient](/docs/agent-sdk/client-sdk/constructor) for browser/Node clients (automatic reconnect, state mirroring, typed RPC) and [AgentSocketServer](/docs/agent-sdk/internals/the-socket-server) on the agent side, with the [`@rpc()`](/docs/agent-sdk/interface/rpc) opt-in - `BlobStore` for blob access within actors - The mount front door *(SDK ≥ 0.12.0)* — [`mountAgents()`](/docs/agent-sdk/internals/the-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. --- ### The Package > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk.md The Agent Client SDK ships in the runtime package as `@telnyx/edge-runtime/client`. Its main export is the `AgentClient` class. Anything outside an agent that reaches it, a browser page or a Node app, uses the client. The client speaks the agent socket protocol to an instance's address; see [The Address and Transports](/docs/agent-sdk/contract/address-and-transports) for detail. | Export | What it is | |---|---| | `@telnyx/edge-runtime/client` | The `AgentClient` class. It has no server dependencies and runs in a browser or in Node 22 and later. | The pages document the `AgentClient` class. **The Interface** is what is exposed to the app above, the class and what a client has. | Group | What it is for | |---|---| | [Connection](/docs/agent-sdk/client-sdk/constructor) | Open a connection to an instance, read what it may do, and close it. | | [Streams](/docs/agent-sdk/client-sdk/onState) | Follow an instance's state, messages and events. | | [RPC](/docs/agent-sdk/client-sdk/stub) | Call an instance's methods. | --- ### new AgentClient() > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/constructor.md ## Type Parameters | Parameter | Constraint | Default | Meaning | |---|---|---|---| | `TStub` | `extends Record Promise>` | None | The agent's remote-callable methods, typed as `stub`. | | `TState` | None | None | The shape of the agent's state, as `onState` listeners receive it. | ## The Address The `url` is the address of one instance. [`mountAgents`](/docs/agent-sdk/mounting-agents) serves every instance it mounts at an address of this shape. ```text wss:///agents// ``` - `/agents` is the mount's default base path. - `` is a key of the mount map. - `` is the instance's routing name. Behind a hand-written front door, the address is the one that front door serves. ## Attach Mode Attach mode opens every connection, reconnects included, with an `attach` frame. The frame carries the token, the requested streams and, with `resume`, the cursors. The server answers with the granted claims and the accepted streams. [`claims`](/docs/agent-sdk/client-sdk/claims) holds the claims. Without `token`, `subscribe` or `resume`, the client speaks the plain protocol. Each connection opens with snapshots of the state and the messages, followed by `hello`. The plain protocol works with servers that speak nothing else. ## Reconnects The client reconnects on its own whenever the link drops. Only [`close`](/docs/agent-sdk/client-sdk/close) stops it. A heartbeat detects a link that died without closing. The client replaces such a link as it does a dropped one. The backoff and heartbeat options in [AgentClientOptions](#agentclientoptions) set the timing. ## AgentClientOptions ## Stream --- ### claims > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/claims.md ## Across Reconnects Claims belong to one connection. The list empties when the link drops. The next connection's `attached` answer fills it again. --- ### isConnected() > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/isConnected.md ## Across Reconnects `isConnected()` returns `false` from the moment the link drops until the next connection's `hello`. --- ### close() > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/close.md --- ### onState() > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/onState.md ## The Local Copy The client keeps a local copy of the agent's state. Every connection, reconnects included, starts the copy from a snapshot. Each [`setState`](/docs/agent-sdk/interface/setState) on the agent then arrives as an RFC 7396 merge patch. Each [`replaceState`](/docs/agent-sdk/interface/replaceState) arrives as a new snapshot. A listener receives the whole copy after every change. A listener added after the first snapshot is called at once with the current copy. --- ### onMessages() > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/onMessages.md ## The Message Stream Each item is one of the agent's [stored messages](/docs/agent-sdk/interface/messages#storedmessage). The client types the items `unknown`. Every message the agent adds to [`this.messages`](/docs/agent-sdk/interface/messages) arrives in `appended`. A listener added after the first snapshot is called at once with the current log, as a `snapshot`. --- ### onEvents() > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/onEvents.md ## The Event Stream Each [`this.events.emit`](/docs/agent-sdk/interface/events#emit) on the agent arrives as one [`AgentEvent`](#agentevent). The first connection replays the events the agent retains. Without [`resume`](/docs/agent-sdk/client-sdk/constructor#agentclientoptions), every reconnect replays them again. A listener then receives earlier events more than once. A listener receives only the events that arrive after it is added. The client keeps no copy of past events. ## AgentEvent --- ### stub > Source: https://developers.telnyx.com/docs/agent-sdk/client-sdk/stub.md ## While the Link Is Down A call made while no connection is ready waits in a buffer. The client sends it once a connection is ready. When the link drops, or a connection attempt fails, every call still waiting for an answer rejects. That includes the buffered calls. The client does not resend a rejected call. ---