> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# LangGraph Agent

> The same SMS support agent, with LangGraph running the reasoning loop and tool calls — the Agent SDK as its durable memory.

Here an agent framework does the thinking. LangGraph's ReAct agent runs **inside** the
actor's `process()` method — with tool calling, multi-step reasoning, the works — while
the Agent SDK supplies what the framework doesn't have: durable per-customer history,
retries, and the follow-up timer.

`this.messages.toLangChain()` returns plain `{ role, content }` messages, which LangGraph
accepts as-is. The LLM is `ChatOpenAI` pointed at Telnyx Inference — swap `baseURL`,
key, and model to bring any OpenAI-compatible provider. The same pattern fits any agent
framework that runs on Node.

Install the framework alongside the runtime:

```bash theme={null}
npm install @langchain/openai @langchain/langgraph @langchain/core zod
```

**`src/conversation.ts`** — the actor:

```ts theme={null}
import { Agent } from "@telnyx/edge-runtime";
import { ChatOpenAI } from "@langchain/openai";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { tool } from "@langchain/core/tools";
import { z } from "zod";

const SYSTEM_PROMPT =
  "You are a terse, helpful SMS support agent. Answer in one or two sentences.";

const llm = new ChatOpenAI({
  model: "zai-org/GLM-5.2",
  apiKey: process.env.TELNYX_API_KEY,
  configuration: { baseURL: "https://api.telnyx.com/v2/ai/openai" },
});

const lookupOrder = tool(
  async ({ orderId }) => {
    // ...query your order system...
    return JSON.stringify({ orderId, status: "shipped", eta: "Friday" });
  },
  {
    name: "lookup_order",
    description: "Look up the status of a customer order by id.",
    schema: z.object({ orderId: z.string() }),
  },
);

const supportAgent = createReactAgent({ llm, tools: [lookupOrder] });

interface ConvEnv {
  TELNYX: {
    messages: {
      send(m: { from: string; to: string; text: string }): Promise<unknown>;
    };
  };
}

interface ConvState extends Record<string, unknown> {
  from: string;
  to: string;
  at: number;
}

export class Conversation extends Agent<ConvEnv, ConvState> {
  protected override initialState(): ConvState {
    return { from: "", to: "", at: 0 };
  }

  async receive({ text, from, to }: { text: string; from: string; to: string }): Promise<void> {
    await this.setState({ from, to });
    await this.messages.add("user", text);
    await this.queue("process"); // ack the webhook now, think in the background
  }

  async process(): Promise<void> {
    // toLangChain() emits plain {role, content} — LangGraph takes it as-is
    const history = await this.messages.toLangChain();

    const out = await supportAgent.invoke({
      messages: [{ role: "system", content: SYSTEM_PROMPT }, ...history],
    });
    const reply = String(out.messages.at(-1)?.content ?? "");
    // Throwing before this point marks the task failed — the scheduler retries

    await this.messages.add("assistant", reply);
    const { from, to } = await this.getState();
    await this.env.TELNYX.messages.send({ from: to, to: from, text: reply });
    await this.setState({ at: Date.now() });

    // Follow up in 24 hours if the customer goes quiet
    await this.schedule(86_400, "nudge", null, { id: "nudge" });
  }

  async nudge(): Promise<void> {
    const last = await this.messages.last();
    if (last?.role !== "assistant") return; // customer replied — skip
    const { from, to } = await this.getState();
    await this.env.TELNYX.messages.send({
      from: to,
      to: from,
      text: "Just checking in — did that sort things out?",
    });
  }
}
```

Only the framework's final reply lands in `this.messages` — intermediate tool calls and
tool results stay inside the LangGraph run. If you want them in the durable history too,
`append()` them from `out.messages`.

**`src/index.ts`** — the function that routes inbound webhooks to the right actor:

```ts theme={null}
import type { ActorNamespace } from "@telnyx/edge-runtime";
import { Conversation } from "./conversation.js";

export { Conversation };

interface Env {
  CONVOS: ActorNamespace<Conversation>;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (new URL(req.url).pathname.startsWith("/health")) return new Response("ok");
    const hook = await req.json() as {
      data: { event_type: string; payload: { from: { phone_number: string }; to: Array<{ phone_number: string }>; text: string } };
    };
    if (hook.data.event_type !== "message.received") return new Response("ignored");
    const { from, to, text } = {
      from: hook.data.payload.from.phone_number,
      to: hook.data.payload.to[0]?.phone_number ?? "",
      text: hook.data.payload.text,
    };
    await env.CONVOS.idFromName(from).receive({ text, from, to });
    return new Response("ok");
  },
};
```

<Note>
  Verify Telnyx webhook signatures before processing — see
  [receiving webhooks](/development/api-fundamentals/webhooks/receiving-webhooks).
  The examples above omit verification for brevity; production code must check the
  `telnyx-signature-ed25519` header.
</Note>

**`telnyx.toml`:**

```toml theme={null}
name = "support-agent"
main = "src/index.ts"
compatibility_date = "2026-05-01"

[[actors]]
binding = "CONVOS"
type    = "Conversation"

[telnyx]
binding = "TELNYX"
```

**Set the API key** as a [secret](/docs/edge-compute/configuration/secrets) — it reaches
the actor as `process.env.TELNYX_API_KEY`:

```bash theme={null}
telnyx-edge secrets add TELNYX_API_KEY "KEY..."
```

Prefer owning the loop yourself? The
[hand-rolled version](/docs/agent-sdk/examples/roll-your-own) of this same agent needs
no key at all — it calls inference through the pre-authenticated Telnyx binding. A
framework owns its own HTTP stack, so it authenticates like any external client.

<Note>
  Task delivery is [at-least-once](/docs/agent-sdk/api-reference/scheduling): a crash
  after `messages.add()` or `messages.send()` succeeds retries the whole `process()`
  method. For production, guard outbound side effects — e.g. check state before sending,
  or use a stable message ID to deduplicate.
</Note>
